├── tests ├── resources │ └── file ├── Implementation │ ├── Resources │ │ └── Stats.php │ ├── FsWithoutProcessDelegation.php │ ├── FsProcessDelegate.php │ ├── FsWithProcessDelegation.php │ └── FsConnectionDelegate.js ├── TestCase.php └── ImplementationTest.php ├── .gitignore ├── src ├── node-process │ ├── index.js │ ├── Logger.js │ ├── ConnectionDelegate.js │ ├── Data │ │ ├── Serializer.js │ │ ├── ResourceIdentity.js │ │ ├── Value.js │ │ ├── Unserializer.js │ │ └── ResourceRepository.js │ ├── serve.js │ ├── NodeInterceptors │ │ ├── StandardStreamsInterceptor.js │ │ └── ConsoleInterceptor.js │ ├── Server.js │ ├── Connection.js │ └── Instruction.js ├── Traits │ ├── UsesBasicResourceAsDefault.php │ ├── IdentifiesResource.php │ └── CommunicatesWithProcessSupervisor.php ├── Interfaces │ ├── ShouldCommunicateWithProcessSupervisor.php │ ├── ShouldHandleProcessDelegation.php │ └── ShouldIdentifyResource.php ├── Exceptions │ ├── IdentifiesProcess.php │ ├── Node │ │ ├── Exception.php │ │ ├── FatalException.php │ │ └── HandlesNodeErrors.php │ ├── ProcessUnexpectedlyTerminatedException.php │ ├── ReadSocketTimeoutException.php │ └── IdleTimeoutException.php ├── Data │ ├── BasicResource.php │ ├── ResourceIdentity.php │ ├── UnserializesData.php │ └── JsFunction.php ├── AbstractEntryPoint.php ├── Logger.php ├── Instruction.php └── ProcessSupervisor.php ├── package.json ├── .github └── workflows │ └── tests.yaml ├── phpunit.xml ├── LICENSE ├── composer.json ├── README.md ├── CHANGELOG.md └── docs ├── tutorial.md └── api.md /tests/resources/file: -------------------------------------------------------------------------------- 1 | Hello world! -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /vendor/ 3 | composer.lock 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /src/node-process/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | ConnectionDelegate: require('./ConnectionDelegate'), 5 | }; 6 | -------------------------------------------------------------------------------- /tests/Implementation/Resources/Stats.php: -------------------------------------------------------------------------------- 1 | process; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Exceptions/Node/Exception.php: -------------------------------------------------------------------------------- 1 | setTraceAndGetMessage($error, $appendStackTraceToMessage); 15 | 16 | parent::__construct($message); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Interfaces/ShouldHandleProcessDelegation.php: -------------------------------------------------------------------------------- 1 | process = $process; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Interfaces/ShouldIdentifyResource.php: -------------------------------------------------------------------------------- 1 | =8.0.0" 23 | }, 24 | "dependencies": { 25 | "lodash": "^4.17.10" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Implementation/FsWithProcessDelegation.php: -------------------------------------------------------------------------------- 1 | getResourceIdentity(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request] 3 | jobs: 4 | phpunit: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | php-version: [7.1, 7.2, 7.3] 9 | composer-flags: [null, --prefer-lowest] 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v1 13 | - uses: shivammathur/setup-php@v2 14 | with: 15 | php-version: ${{ matrix.php-version }} 16 | tools: pecl 17 | extensions: weakref-beta 18 | coverage: none 19 | - run: composer update ${{ matrix.composer-flags }} --no-interaction --no-progress --prefer-dist --ansi 20 | - run: npm install 21 | - run: ./vendor/bin/phpunit --color=always 22 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | tests 13 | 14 | 15 | 16 | 17 | src 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/node-process/ConnectionDelegate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @callback responseHandler 5 | * @param {*} value 6 | */ 7 | 8 | /** 9 | * @callback errorHandler 10 | * @param {Error} error 11 | */ 12 | 13 | /** 14 | * Handle the requests of a connection. 15 | */ 16 | class ConnectionDelegate 17 | { 18 | /** 19 | * Constructor. 20 | * 21 | * @param {Object} options 22 | */ 23 | constructor(options) 24 | { 25 | this.options = options; 26 | } 27 | 28 | /** 29 | * Handle the provided instruction and respond to it. 30 | * 31 | * @param {Instruction} instruction 32 | * @param {responseHandler} responseHandler 33 | * @param {errorHandler} errorHandler 34 | */ 35 | handleInstruction(instruction, responseHandler, errorHandler) 36 | { 37 | responseHandler(null); 38 | } 39 | } 40 | 41 | module.exports = ConnectionDelegate; 42 | -------------------------------------------------------------------------------- /src/Exceptions/Node/FatalException.php: -------------------------------------------------------------------------------- 1 | getErrorOutput()); 18 | } 19 | 20 | /** 21 | * Constructor. 22 | */ 23 | public function __construct(Process $process, bool $appendStackTraceToMessage = false) 24 | { 25 | $this->process = $process; 26 | 27 | $message = $this->setTraceAndGetMessage($process->getErrorOutput(), $appendStackTraceToMessage); 28 | 29 | parent::__construct($message); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Traits/IdentifiesResource.php: -------------------------------------------------------------------------------- 1 | resourceIdentity; 23 | } 24 | 25 | /** 26 | * Set the identity of the resource. 27 | * 28 | * @throws \RuntimeException if the resource identity has already been set. 29 | */ 30 | public function setResourceIdentity(ResourceIdentity $identity): void 31 | { 32 | if ($this->resourceIdentity !== null) { 33 | throw new RuntimeException('The resource identity has already been set.'); 34 | } 35 | 36 | $this->resourceIdentity = $identity; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Exceptions/IdleTimeoutException.php: -------------------------------------------------------------------------------- 1 | getErrorOutput(), true); 16 | 17 | return $error['message'] === 'The idle timeout has been reached.'; 18 | } 19 | 20 | return false; 21 | } 22 | 23 | /** 24 | * Constructor. 25 | */ 26 | public function __construct(float $timeout, \Throwable $previous = null) 27 | { 28 | $timeout = number_format($timeout, 3); 29 | 30 | parent::__construct(implode(' ', [ 31 | "The idle timeout ($timeout seconds) has been exceeded.", 32 | 'Maybe you should increase the "idle_timeout" option.', 33 | ]), 0, $previous); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Johann Pardanaud 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/node-process/Data/Serializer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Value = require('./Value'); 4 | 5 | class Serializer 6 | { 7 | /** 8 | * Serialize an error to JSON. 9 | * 10 | * @param {Error} error 11 | * @return {Object} 12 | */ 13 | static serializeError(error) 14 | { 15 | return { 16 | __rialto_error__: true, 17 | message: error.message, 18 | stack: error.stack, 19 | }; 20 | } 21 | 22 | /** 23 | * Constructor. 24 | * 25 | * @param {ResourceRepository} resources 26 | */ 27 | constructor(resources) 28 | { 29 | this.resources = resources; 30 | } 31 | 32 | /** 33 | * Serialize a value. 34 | * 35 | * @param {*} value 36 | * @return {*} 37 | */ 38 | serialize(value) 39 | { 40 | value = value === undefined ? null : value; 41 | 42 | if (Value.isContainer(value)) { 43 | return Value.mapContainer(value, this.serialize.bind(this)); 44 | } else if (Value.isScalar(value)) { 45 | return value; 46 | } else { 47 | return this.resources.store(value).serialize(); 48 | } 49 | } 50 | } 51 | 52 | module.exports = Serializer; 53 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nesk/rialto", 3 | "description": "Manage Node resources from PHP", 4 | "keywords": [ 5 | "php", 6 | "node", 7 | "wrapper", 8 | "communication", 9 | "bridge", 10 | "socket" 11 | ], 12 | "type": "library", 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Johann Pardanaud", 17 | "email": "pardanaud.j@gmail.com" 18 | } 19 | ], 20 | "require": { 21 | "php": ">=7.1", 22 | "clue/socket-raw": "^1.2", 23 | "psr/log": "^1.0", 24 | "symfony/process": "^3.3|^4.0|^5.0" 25 | }, 26 | "require-dev": { 27 | "monolog/monolog": "^1.23", 28 | "phpunit/phpunit": "^6.5|^7.0" 29 | }, 30 | "suggest": { 31 | "ext-weakref": "Required to run all the tests" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Nesk\\Rialto\\": "src/" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Nesk\\Rialto\\Tests\\": "tests/" 41 | } 42 | }, 43 | "scripts": { 44 | "post-install-cmd": "npm install", 45 | "test": "./vendor/bin/phpunit" 46 | }, 47 | "config": { 48 | "sort-packages": true 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Exceptions/Node/HandlesNodeErrors.php: -------------------------------------------------------------------------------- 1 | originalTrace = $error['stack'] ?? null; 32 | 33 | $message = $error['message']; 34 | 35 | if ($appendStackTraceToMessage) { 36 | $message .= "\n\n".$error['stack']; 37 | } 38 | 39 | return $message; 40 | } 41 | 42 | /** 43 | * Return the original stack trace. 44 | */ 45 | public function getOriginalTrace(): ?string 46 | { 47 | return $this->originalTrace; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Data/ResourceIdentity.php: -------------------------------------------------------------------------------- 1 | className = $className; 27 | $this->uniqueIdentifier = $uniqueIdentifier; 28 | } 29 | 30 | /** 31 | * Return the class name of the resource. 32 | */ 33 | public function className(): string 34 | { 35 | return $this->className; 36 | } 37 | 38 | /** 39 | * Return the unique identifier of the resource. 40 | */ 41 | public function uniqueIdentifier(): string 42 | { 43 | return $this->uniqueIdentifier; 44 | } 45 | 46 | /** 47 | * Serialize the object to a value that can be serialized natively by {@see json_encode}. 48 | */ 49 | public function jsonSerialize(): array 50 | { 51 | return [ 52 | '__rialto_resource__' => true, 53 | 'class_name' => $this->className(), 54 | 'id' => $this->uniqueIdentifier(), 55 | ]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/node-process/serve.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ConsoleInterceptor = require('./NodeInterceptors/ConsoleInterceptor'), 4 | Logger = require('./Logger'), 5 | Server = require('./Server'), 6 | DataSerializer = require('./Data/Serializer'); 7 | 8 | // Throw unhandled rejections 9 | process.on('unhandledRejection', error => { 10 | throw error; 11 | }); 12 | 13 | // Output the exceptions in JSON format 14 | process.on('uncaughtException', error => { 15 | process.stderr.write(JSON.stringify(DataSerializer.serializeError(error))); 16 | process.exit(1); 17 | }); 18 | 19 | // Retrieve the options 20 | let options = process.argv.slice(2)[1]; 21 | options = options !== undefined ? JSON.parse(options) : {}; 22 | 23 | // Intercept Node logs 24 | if (options.log_node_console === true) { 25 | ConsoleInterceptor.startInterceptingLogs((type, originalMessage) => { 26 | const level = ConsoleInterceptor.getLevelFromType(type); 27 | const message = ConsoleInterceptor.formatMessage(originalMessage); 28 | 29 | Logger.log('Node', level, message); 30 | }); 31 | } 32 | 33 | // Instanciate the custom connection delegate 34 | const connectionDelegate = new (require(process.argv.slice(2)[0]))(options); 35 | 36 | // Start the server with the custom connection delegate 37 | const server = new Server(connectionDelegate, options); 38 | 39 | // Write the server port to the process output 40 | server.started.then(() => server.writePortToOutput()); 41 | -------------------------------------------------------------------------------- /src/node-process/Data/ResourceIdentity.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class ResourceIdentity 4 | { 5 | /** 6 | * Constructor. 7 | * 8 | * @param {string} uniqueIdentifier 9 | * @param {string|null} className 10 | */ 11 | constructor(uniqueIdentifier, className = null) 12 | { 13 | this.resource = {uniqueIdentifier, className}; 14 | } 15 | 16 | /** 17 | * Return the unique identifier of the resource. 18 | * 19 | * @return {string} 20 | */ 21 | uniqueIdentifier() 22 | { 23 | return this.resource.uniqueIdentifier; 24 | } 25 | 26 | /** 27 | * Return the class name of the resource. 28 | * 29 | * @return {string|null} 30 | */ 31 | className() 32 | { 33 | return this.resource.className; 34 | } 35 | 36 | /** 37 | * Unserialize a resource identity. 38 | * 39 | * @param {Object} identity 40 | * @return {ResourceIdentity} 41 | */ 42 | static unserialize(identity) 43 | { 44 | return new ResourceIdentity(identity.id, identity.class_name); 45 | } 46 | 47 | /** 48 | * Serialize the resource identity. 49 | * 50 | * @return {Object} 51 | */ 52 | serialize() 53 | { 54 | return { 55 | __rialto_resource__: true, 56 | id: this.uniqueIdentifier(), 57 | class_name: this.className(), 58 | }; 59 | } 60 | } 61 | 62 | module.exports = ResourceIdentity; 63 | -------------------------------------------------------------------------------- /src/AbstractEntryPoint.php: -------------------------------------------------------------------------------- 1 | consolidateOptions($implementationOptions, $userOptions) 31 | ); 32 | 33 | $this->setProcessSupervisor($process); 34 | } 35 | 36 | /** 37 | * Clean the user options. 38 | */ 39 | protected function consolidateOptions(array $implementationOptions, array $userOptions): array 40 | { 41 | // Filter out the forbidden option 42 | $userOptions = array_diff_key($userOptions, array_flip($this->forbiddenOptions)); 43 | 44 | // Merge the user options with the implementation ones 45 | return array_merge($implementationOptions, $userOptions); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Implementation/FsConnectionDelegate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'), 4 | {ConnectionDelegate} = require('../../src/node-process'); 5 | 6 | /** 7 | * Handle the requests of a connection to control the "fs" module. 8 | */ 9 | class FsConnectionDelegate extends ConnectionDelegate 10 | { 11 | async handleInstruction(instruction, responseHandler, errorHandler) 12 | { 13 | instruction.setDefaultResource(this.extendFsModule(fs)); 14 | 15 | let value = null; 16 | 17 | try { 18 | value = await instruction.execute(); 19 | } catch (error) { 20 | if (instruction.shouldCatchErrors()) { 21 | return errorHandler(error); 22 | } 23 | 24 | throw error; 25 | } 26 | 27 | responseHandler(value); 28 | } 29 | 30 | extendFsModule(fs) 31 | { 32 | fs.multipleStatSync = (...paths) => paths.map(fs.statSync); 33 | 34 | fs.multipleResourcesIsFile = resources => resources.map(resource => resource.isFile()); 35 | 36 | fs.getHeavyPayloadWithNonAsciiChars = () => { 37 | let payload = ''; 38 | 39 | for (let i = 0 ; i < 1024 ; i++) { 40 | payload += 'a'; 41 | } 42 | 43 | return `😘${payload}😘`; 44 | }; 45 | 46 | fs.wait = ms => new Promise(resolve => setTimeout(resolve, ms)); 47 | 48 | fs.runCallback = cb => cb(fs); 49 | 50 | fs.getOption = name => this.options[name]; 51 | 52 | return fs; 53 | } 54 | } 55 | 56 | module.exports = FsConnectionDelegate; 57 | -------------------------------------------------------------------------------- /src/node-process/NodeInterceptors/StandardStreamsInterceptor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | 5 | const STANDARD_STREAMS = [process.stdout, process.stderr]; 6 | 7 | class StandardStreamsInterceptor 8 | { 9 | /** 10 | * Standard stream interceptor. 11 | * 12 | * @callback standardStreamInterceptor 13 | * @param {string} message 14 | */ 15 | 16 | /** 17 | * Start intercepting data written on the standard streams. 18 | * 19 | * @param {standardStreamInterceptor} interceptor 20 | */ 21 | static startInterceptingStrings(interceptor) { 22 | STANDARD_STREAMS.forEach(stream => { 23 | this.standardStreamWriters.set(stream, stream.write); 24 | 25 | stream.write = (chunk, encoding, callback) => { 26 | if (_.isString(chunk)) { 27 | interceptor(chunk); 28 | 29 | if (_.isFunction(callback)) { 30 | callback(); 31 | } 32 | 33 | return true; 34 | } 35 | 36 | return stream.write(chunk, encoding, callback); 37 | }; 38 | }); 39 | } 40 | 41 | /** 42 | * Stop intercepting data written on the standard streams. 43 | */ 44 | static stopInterceptingStrings() { 45 | STANDARD_STREAMS.forEach(stream => { 46 | stream.write = this.standardStreamWriters.get(stream); 47 | this.standardStreamWriters.delete(stream); 48 | }); 49 | } 50 | } 51 | 52 | StandardStreamsInterceptor.standardStreamWriters = new Map; 53 | 54 | module.exports = StandardStreamsInterceptor; 55 | -------------------------------------------------------------------------------- /src/node-process/Data/Value.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | 5 | class Value 6 | { 7 | /** 8 | * Determine if the value is a string, a number, a boolean, or null. 9 | * 10 | * @param {*} value 11 | * @return {boolean} 12 | */ 13 | static isScalar(value) 14 | { 15 | return _.isString(value) || _.isNumber(value) || _.isBoolean(value) || _.isNull(value); 16 | } 17 | 18 | /** 19 | * Determine if the value is an array or a plain object. 20 | * 21 | * @param {*} value 22 | * @return {boolean} 23 | */ 24 | static isContainer(value) 25 | { 26 | return _.isArray(value) || _.isPlainObject(value); 27 | } 28 | 29 | /** 30 | * Map the values of a container. 31 | * 32 | * @param {*} container 33 | * @param {callback} mapper 34 | * @return {array} 35 | */ 36 | static mapContainer(container, mapper) 37 | { 38 | if (_.isArray(container)) { 39 | return container.map(mapper); 40 | } else if (_.isPlainObject(container)) { 41 | return Object.entries(container).reduce((finalObject, [key, value]) => { 42 | finalObject[key] = mapper(value); 43 | 44 | return finalObject; 45 | }, {}); 46 | } else { 47 | return container; 48 | } 49 | } 50 | 51 | /** 52 | * Determine if the value is a resource. 53 | * 54 | * @param {*} value 55 | * @return {boolean} 56 | */ 57 | static isResource(value) 58 | { 59 | return !Value.isContainer(value) && !Value.isScalar(value); 60 | } 61 | } 62 | 63 | module.exports = Value; 64 | -------------------------------------------------------------------------------- /src/Data/UnserializesData.php: -------------------------------------------------------------------------------- 1 | options['debug']); 21 | } else if (($value['__rialto_resource__'] ?? false) === true) { 22 | if ($this->delegate instanceof ShouldHandleProcessDelegation) { 23 | $classPath = $this->delegate->resourceFromOriginalClassName($value['class_name']) 24 | ?: $this->delegate->defaultResource(); 25 | } else { 26 | $classPath = $this->defaultResource(); 27 | } 28 | 29 | $resource = new $classPath; 30 | 31 | if ($resource instanceof ShouldIdentifyResource) { 32 | $resource->setResourceIdentity(new ResourceIdentity($value['class_name'], $value['id'])); 33 | } 34 | 35 | if ($resource instanceof ShouldCommunicateWithProcessSupervisor) { 36 | $resource->setProcessSupervisor($this); 37 | } 38 | 39 | return $resource; 40 | } else { 41 | return array_map(function ($value) { 42 | return $this->unserialize($value); 43 | }, $value); 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚨 This project is not maintained anymore 2 | 3 | As I write these lines, it's been nearly two years since the latest release of Rialto. Despite the enthusiasm around this project, I no longer have the motivation to support its development, mainly because it never really had any use to me. So its time to be honest with you, Rialto is no longer maintained. 4 | 5 | If you create a fork and plan to maintain it, let me know and I will link it here. 6 | 7 | # Rialto 8 | 9 | [![PHP Version](https://img.shields.io/packagist/php-v/nesk/rialto.svg?style=flat-square)](http://php.net/) 10 | [![Composer Version](https://img.shields.io/packagist/v/nesk/rialto.svg?style=flat-square&label=Composer)](https://packagist.org/packages/nesk/rialto) 11 | [![Node Version](https://img.shields.io/node/v/@nesk/rialto.svg?style=flat-square&label=Node)](https://nodejs.org/) 12 | [![NPM Version](https://img.shields.io/npm/v/@nesk/rialto.svg?style=flat-square&label=NPM)](https://www.npmjs.com/package/@nesk/rialto) 13 | [![Build Status](https://img.shields.io/travis/nesk/rialto.svg?style=flat-square&label=Build%20Status)](https://travis-ci.org/nesk/rialto) 14 | 15 | A package to manage Node resources from PHP. It can be used to create bridges to interact with Node libraries in PHP, like [PuPHPeteer](https://github.com/nesk/puphpeteer/). 16 | 17 | It works by creating a Node process and communicates with it through sockets. 18 | 19 | ## Requirements and installation 20 | 21 | Rialto requires PHP >= 7.1 and Node >= 8. 22 | 23 | Install it in your project: 24 | 25 | ```shell 26 | composer require nesk/rialto 27 | npm install @nesk/rialto 28 | ``` 29 | 30 | ## Usage 31 | 32 | See our tutorial to [create your first bridge with Rialto](docs/tutorial.md). 33 | 34 | An [API documentation](docs/api.md) is also available. 35 | 36 | ## License 37 | 38 | The MIT License (MIT). Please see [License File](LICENSE) for more information. 39 | -------------------------------------------------------------------------------- /src/node-process/Server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const net = require('net'), 4 | Connection = require('./Connection'); 5 | 6 | /** 7 | * Listen for new socket connections. 8 | */ 9 | class Server 10 | { 11 | /** 12 | * Constructor. 13 | * 14 | * @param {ConnectionDelegate} connectionDelegate 15 | * @param {Object} options 16 | */ 17 | constructor(connectionDelegate, options = {}) 18 | { 19 | this.options = options; 20 | 21 | this.started = this.start(connectionDelegate); 22 | 23 | this.resetIdleTimeout(); 24 | } 25 | 26 | /** 27 | * Start the server and listen for new connections. 28 | * 29 | * @param {ConnectionDelegate} connectionDelegate 30 | * @return {Promise} 31 | */ 32 | start(connectionDelegate) 33 | { 34 | this.server = net.createServer(socket => { 35 | const connection = new Connection(socket, connectionDelegate); 36 | 37 | connection.on('activity', () => this.resetIdleTimeout()); 38 | 39 | this.resetIdleTimeout(); 40 | }); 41 | 42 | return new Promise(resolve => { 43 | this.server.listen(() => resolve()); 44 | }); 45 | } 46 | 47 | /** 48 | * Write the listening port on the process output. 49 | */ 50 | writePortToOutput() 51 | { 52 | process.stdout.write(`${this.server.address().port}\n`); 53 | } 54 | 55 | /** 56 | * Reset the idle timeout. 57 | * 58 | * @protected 59 | */ 60 | resetIdleTimeout() 61 | { 62 | clearTimeout(this.idleTimer); 63 | 64 | const {idle_timeout: idleTimeout} = this.options; 65 | 66 | if (idleTimeout !== null) { 67 | this.idleTimer = setTimeout(() => { 68 | throw new Error('The idle timeout has been reached.'); 69 | }, idleTimeout * 1000); 70 | } 71 | } 72 | } 73 | 74 | module.exports = Server; 75 | -------------------------------------------------------------------------------- /src/node-process/Data/Unserializer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'), 4 | Value = require('./Value'), 5 | ResourceIdentity = require('./ResourceIdentity'), 6 | ResourceRepository = require('./ResourceRepository'); 7 | 8 | // Some unserialized functions require an access to the ResourceRepository class, so we must put it in the global scope. 9 | global.__rialto_ResourceRepository__ = ResourceRepository; 10 | 11 | class Unserializer 12 | { 13 | /** 14 | * Constructor. 15 | * 16 | * @param {ResourceRepository} resources 17 | */ 18 | constructor(resources) 19 | { 20 | this.resources = resources; 21 | } 22 | 23 | /** 24 | * Unserialize a value. 25 | * 26 | * @param {*} value 27 | * @return {*} 28 | */ 29 | unserialize(value) 30 | { 31 | if (_.get(value, '__rialto_resource__') === true) { 32 | return this.resources.retrieve(ResourceIdentity.unserialize(value)); 33 | } else if (_.get(value, '__rialto_function__') === true) { 34 | return this.unserializeFunction(value); 35 | } else if (Value.isContainer(value)) { 36 | return Value.mapContainer(value, this.unserialize.bind(this)); 37 | } else { 38 | return value; 39 | } 40 | } 41 | 42 | /** 43 | * Return a string used to embed a value in a function. 44 | * 45 | * @param {*} value 46 | * @return {string} 47 | */ 48 | embedFunctionValue(value) 49 | { 50 | value = this.unserialize(value); 51 | const valueUniqueIdentifier = ResourceRepository.storeGlobal(value); 52 | 53 | const a = Value.isResource(value) 54 | ? ` 55 | __rialto_ResourceRepository__ 56 | .retrieveGlobal(${JSON.stringify(valueUniqueIdentifier)}) 57 | ` 58 | : JSON.stringify(value); 59 | 60 | return a; 61 | } 62 | 63 | /** 64 | * Unserialize a function. 65 | * 66 | * @param {Object} value 67 | * @return {Function} 68 | */ 69 | unserializeFunction(value) 70 | { 71 | const scopedVariables = []; 72 | 73 | for (let [varName, varValue] of Object.entries(value.scope)) { 74 | scopedVariables.push(`var ${varName} = ${this.embedFunctionValue(varValue)};`); 75 | } 76 | 77 | const parameters = []; 78 | 79 | for (let [paramKey, paramValue] of Object.entries(value.parameters)) { 80 | if (!isNaN(parseInt(paramKey, 10))) { 81 | parameters.push(paramValue); 82 | } else { 83 | parameters.push(`${paramKey} = ${this.embedFunctionValue(paramValue)}`); 84 | } 85 | } 86 | 87 | const asyncFlag = value.async ? 'async' : ''; 88 | 89 | return new Function(` 90 | return ${asyncFlag} function (${parameters.join(', ')}) { 91 | ${scopedVariables.join('\n')} 92 | ${value.body} 93 | }; 94 | `)(); 95 | } 96 | } 97 | 98 | module.exports = Unserializer; 99 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | _In progress…_ 10 | 11 | ## [1.4.0] - 2020-04-12 12 | ### Added 13 | - Support symfony/process v5 14 | 15 | ## [1.3.0] - 2019-03-14 16 | ### Added 17 | - Support string casting for resources 18 | 19 | ## [1.2.1] - 2018-08-28 20 | ### Fixed 21 | - Heavy socket payloads are no longer unreadable in slow environments 22 | - Fix an issue where the console object can't be set in some environments 23 | 24 | ## [1.2.0] - 2018-08-20 25 | ### Added 26 | - Add a `log_node_console` option to log the output of console methods (`console.log`, `console.debug`, `console.table`, etc…) to the PHP logger 27 | 28 | ### Changed 29 | - Drastically improve the log contents 30 | 31 | ### Fixed 32 | - Fix a bug where some standard streams logs were missing 33 | - Fix double declarations of some JS classes due to a require with two different paths 34 | - Fix a bug where sending `null` values was crashing the Node process 35 | 36 | ## [1.1.0] - 2018-07-20 37 | ### Added 38 | - Support passing Node resources in JS functions 39 | - Add chaining methods to the `JsFunction` class 40 | - Add an `async()` method to the `JsFunction` class to allow developers to write `await` instructions in their JS code 41 | - The `idle_timeout` and `read_timeout` options can be disabled by setting them to `null` 42 | 43 | ### Deprecated 44 | - Deprecate the `JsFunction::create` method in favor of the new chaining methods 45 | 46 | ## [1.0.2] - 2018-06-18 47 | ### Fixed 48 | - Fix an issue where the socket port couldn't be retrieved 49 | 50 | ## [1.0.1] - 2018-06-12 51 | ### Fixed 52 | - Fix `false` values being parsed as `null` by the unserializer 53 | - Fix Travis tests 54 | 55 | ## [1.0.0] - 2018-06-05 56 | ### Changed 57 | - Change PHP's vendor name from `extractr-io` to `nesk` 58 | - Change NPM's scope name from `@extractr-io` to `@nesk` 59 | 60 | ## [0.1.2] - 2018-04-09 61 | ### Added 62 | - Support PHPUnit v7 63 | - Add Travis integration 64 | 65 | ### Changed 66 | - Improve the conditions to throw `ReadSocketTimeoutException` 67 | 68 | ### Fixed 69 | - Support heavy socket payloads containing non-ASCII characters 70 | 71 | ## [0.1.1] - 2018-01-29 72 | ### Fixed 73 | - Fix an issue on an internal type check 74 | 75 | ## 0.1.0 - 2018-01-29 76 | First release 77 | 78 | 79 | [Unreleased]: https://github.com/nesk/rialto/compare/1.4.0...HEAD 80 | [1.4.0]: https://github.com/nesk/rialto/compare/1.3.0...1.4.0 81 | [1.3.0]: https://github.com/nesk/rialto/compare/1.2.1...1.3.0 82 | [1.2.1]: https://github.com/nesk/rialto/compare/1.2.0...1.2.1 83 | [1.2.0]: https://github.com/nesk/rialto/compare/1.1.0...1.2.0 84 | [1.1.0]: https://github.com/nesk/rialto/compare/1.0.2...1.1.0 85 | [1.0.2]: https://github.com/nesk/rialto/compare/1.0.1...1.0.2 86 | [1.0.1]: https://github.com/nesk/rialto/compare/1.0.0...1.0.1 87 | [1.0.0]: https://github.com/nesk/rialto/compare/0.1.2...1.0.0 88 | [0.1.2]: https://github.com/nesk/rialto/compare/0.1.1...0.1.2 89 | [0.1.1]: https://github.com/nesk/rialto/compare/0.1.0...0.1.1 90 | -------------------------------------------------------------------------------- /src/node-process/NodeInterceptors/ConsoleInterceptor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const StandardStreamsInterceptor = require('./StandardStreamsInterceptor'); 4 | 5 | const SUPPORTED_CONSOLE_METHODS = { 6 | 'debug': 'DEBUG', 7 | 'dir': 'DEBUG', 8 | 'dirxml': 'INFO', 9 | 'error': 'ERROR', 10 | 'info': 'INFO', 11 | 'log': 'INFO', 12 | 'table': 'DEBUG', 13 | 'warn': 'WARNING', 14 | }; 15 | 16 | class ConsoleInterceptor 17 | { 18 | /** 19 | * Log interceptor. 20 | * 21 | * @callback logInterceptor 22 | * @param {string} type 23 | * @param {string} message 24 | */ 25 | 26 | /** 27 | * Replace the global "console" object by a proxy to intercept the logs. 28 | * 29 | * @param {logInterceptor} interceptor 30 | */ 31 | static startInterceptingLogs(interceptor) { 32 | const consoleProxy = new Proxy(console, { 33 | get: (_, type) => this.getLoggingMethod(type, interceptor), 34 | }); 35 | 36 | // Define the property instead of directly setting the property, the latter is forbidden in some environments. 37 | Object.defineProperty(global, 'console', {value: consoleProxy}); 38 | } 39 | 40 | /** 41 | * Return an appropriate logging method for the console proxy. 42 | * 43 | * @param {string} type 44 | * @param {logInterceptor} interceptor 45 | * @return {callback} 46 | */ 47 | static getLoggingMethod(type, interceptor) { 48 | const originalMethod = this.originalConsole[type].bind(this.originalConsole); 49 | 50 | if (!this.typeIsSupported(type)) { 51 | return originalMethod; 52 | } 53 | 54 | return (...args) => { 55 | StandardStreamsInterceptor.startInterceptingStrings(message => interceptor(type, message)); 56 | originalMethod(...args); 57 | StandardStreamsInterceptor.stopInterceptingStrings(); 58 | }; 59 | } 60 | 61 | /** 62 | * Check if the type of the log is supported. 63 | * 64 | * @param {*} type 65 | * @return {boolean} 66 | */ 67 | static typeIsSupported(type) { 68 | return Object.keys(SUPPORTED_CONSOLE_METHODS).includes(type); 69 | } 70 | 71 | /** 72 | * Return a log level based on the provided type. 73 | * 74 | * @param {*} type 75 | * @return {string|null} 76 | */ 77 | static getLevelFromType(type) { 78 | return SUPPORTED_CONSOLE_METHODS[type] || null; 79 | } 80 | 81 | /** 82 | * Format a message from a console method. 83 | * 84 | * @param {string} message 85 | * @return {string} 86 | */ 87 | static formatMessage(message) { 88 | // Remove terminal colors written as escape sequences 89 | // See: https://stackoverflow.com/a/41407246/1513045 90 | message = message.replace(/\x1b\[\d+m/g, ''); 91 | 92 | // Remove the final new line 93 | message = message.endsWith('\n') ? message.slice(0, -1) : message; 94 | 95 | return message; 96 | } 97 | } 98 | 99 | ConsoleInterceptor.originalConsole = console; 100 | 101 | module.exports = ConsoleInterceptor; 102 | -------------------------------------------------------------------------------- /src/node-process/Data/ResourceRepository.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ResourceIdentity = require('./ResourceIdentity'); 4 | 5 | class ResourceRepository 6 | { 7 | /** 8 | * Constructor. 9 | */ 10 | constructor() 11 | { 12 | this.resources = new Map; 13 | } 14 | 15 | /** 16 | * Retrieve a resource with its identity from a specific storage. 17 | * 18 | * @param {Map} storage 19 | * @param {ResourceIdentity} identity 20 | * @return {*} 21 | */ 22 | static retrieveFrom(storage, identity) 23 | { 24 | for (let [resource, id] of storage) { 25 | if (identity.uniqueIdentifier() === id) { 26 | return resource; 27 | } 28 | } 29 | 30 | return null; 31 | } 32 | 33 | /** 34 | * Retrieve a resource with its identity from the local storage. 35 | * 36 | * @param {ResourceIdentity} identity 37 | * @return {*} 38 | */ 39 | retrieve(identity) 40 | { 41 | return ResourceRepository.retrieveFrom(this.resources, identity); 42 | } 43 | 44 | /** 45 | * Retrieve a resource with its unique identifier from the global storage. 46 | * 47 | * @param {string} uniqueIdentifier 48 | * @return {*} 49 | */ 50 | static retrieveGlobal(uniqueIdentifier) 51 | { 52 | const identity = new ResourceIdentity(uniqueIdentifier); 53 | return ResourceRepository.retrieveFrom(ResourceRepository.globalResources, identity); 54 | } 55 | 56 | /** 57 | * Store a resource in a specific storage and return its identity. 58 | * 59 | * @param {Map} storage 60 | * @param {*} resource 61 | * @return {ResourceIdentity} 62 | */ 63 | static storeIn(storage, resource) 64 | { 65 | if (storage.has(resource)) { 66 | return ResourceRepository.generateResourceIdentity(resource, storage.get(resource)); 67 | } 68 | 69 | const id = String(Date.now() + Math.random()); 70 | 71 | storage.set(resource, id); 72 | 73 | return ResourceRepository.generateResourceIdentity(resource, id); 74 | } 75 | 76 | /** 77 | * Store a resource in the local storage and return its identity. 78 | * 79 | * @param {*} resource 80 | * @return {ResourceIdentity} 81 | */ 82 | store(resource) 83 | { 84 | return ResourceRepository.storeIn(this.resources, resource); 85 | } 86 | 87 | /** 88 | * Store a resource in the global storage and return its unique identifier. 89 | * 90 | * @param {*} resource 91 | * @return {string} 92 | */ 93 | static storeGlobal(resource) 94 | { 95 | return ResourceRepository.storeIn(ResourceRepository.globalResources, resource).uniqueIdentifier(); 96 | } 97 | 98 | /** 99 | * Generate a resource identity. 100 | * 101 | * @param {*} resource 102 | * @param {string} uniqueIdentifier 103 | * @return {ResourceIdentity} 104 | */ 105 | static generateResourceIdentity(resource, uniqueIdentifier) 106 | { 107 | return new ResourceIdentity(uniqueIdentifier, resource.constructor.name); 108 | } 109 | } 110 | 111 | ResourceRepository.globalResources = new Map; 112 | 113 | module.exports = ResourceRepository; 114 | -------------------------------------------------------------------------------- /src/Logger.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 22 | } 23 | 24 | /** 25 | * {@inheritDoc} 26 | * 27 | * @param string $message 28 | */ 29 | public function emergency($message, array $context = []): void { 30 | $this->log(LogLevel::EMERGENCY, $message, $context); 31 | } 32 | 33 | /** 34 | * {@inheritDoc} 35 | * 36 | * @param string $message 37 | */ 38 | public function alert($message, array $context = []): void { 39 | $this->log(LogLevel::ALERT, $message, $context); 40 | } 41 | 42 | /** 43 | * {@inheritDoc} 44 | * 45 | * @param string $message 46 | */ 47 | public function critical($message, array $context = []): void { 48 | $this->log(LogLevel::CRITICAL, $message, $context); 49 | } 50 | 51 | /** 52 | * {@inheritDoc} 53 | * 54 | * @param string $message 55 | */ 56 | public function error($message, array $context = []): void { 57 | $this->log(LogLevel::ERROR, $message, $context); 58 | } 59 | 60 | /** 61 | * {@inheritDoc} 62 | * 63 | * @param string $message 64 | */ 65 | public function warning($message, array $context = []): void { 66 | $this->log(LogLevel::WARNING, $message, $context); 67 | } 68 | 69 | /** 70 | * {@inheritDoc} 71 | * 72 | * @param string $message 73 | */ 74 | public function notice($message, array $context = []): void { 75 | $this->log(LogLevel::NOTICE, $message, $context); 76 | } 77 | 78 | /** 79 | * {@inheritDoc} 80 | * 81 | * @param string $message 82 | */ 83 | public function info($message, array $context = []): void { 84 | $this->log(LogLevel::INFO, $message, $context); 85 | } 86 | 87 | /** 88 | * {@inheritDoc} 89 | * 90 | * @param string $message 91 | */ 92 | public function debug($message, array $context = []): void { 93 | $this->log(LogLevel::DEBUG, $message, $context); 94 | } 95 | 96 | /** 97 | * {@inheritDoc} 98 | * 99 | * @param mixed $level 100 | * @param string $message 101 | */ 102 | public function log($level, $message, array $context = []): void { 103 | if ($this->logger instanceof LoggerInterface) { 104 | $message = $this->interpolate($message, $context); 105 | $this->logger->log($level, $message, $context); 106 | } 107 | } 108 | 109 | /** 110 | * Interpolate context values into the message placeholders. 111 | * 112 | * @see https://www.php-fig.org/psr/psr-3/#12-message 113 | */ 114 | protected function interpolate(string $message, array $context = []): string { 115 | $replace = array(); 116 | 117 | foreach ($context as $key => $val) { 118 | if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) { 119 | $replace['{' . $key . '}'] = $val; 120 | } 121 | } 122 | 123 | return strtr($message, $replace); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Data/JsFunction.php: -------------------------------------------------------------------------------- 1 | parameters = $parameters; 57 | $this->body = $body; 58 | $this->scope = $scope; 59 | } 60 | 61 | /** 62 | * Return a new instance with the specified parameters. 63 | */ 64 | public function parameters(array $parameters): self { 65 | $clone = clone $this; 66 | $clone->parameters = $parameters; 67 | return $clone; 68 | } 69 | 70 | /** 71 | * Return a new instance with the specified body. 72 | */ 73 | public function body(string $body): self { 74 | $clone = clone $this; 75 | $clone->body = $body; 76 | return $clone; 77 | } 78 | 79 | /** 80 | * Return a new instance with the specified scope. 81 | */ 82 | public function scope(array $scope): self { 83 | $clone = clone $this; 84 | $clone->scope = $scope; 85 | return $clone; 86 | } 87 | 88 | /** 89 | * Return a new instance with the specified async state. 90 | */ 91 | public function async(bool $isAsync = true): self { 92 | $clone = clone $this; 93 | $clone->async = $isAsync; 94 | return $clone; 95 | } 96 | 97 | /** 98 | * Serialize the object to a value that can be serialized natively by {@see json_encode}. 99 | */ 100 | public function jsonSerialize(): array 101 | { 102 | return [ 103 | '__rialto_function__' => true, 104 | 'parameters' => (object) $this->parameters, 105 | 'body' => $this->body, 106 | 'scope' => (object) $this->scope, 107 | 'async' => $this->async, 108 | ]; 109 | } 110 | 111 | /** 112 | * Proxy the "createWith*" static method calls to the "*" non-static method calls of a new instance. 113 | */ 114 | public static function __callStatic(string $name, array $arguments) 115 | { 116 | $name = lcfirst(substr($name, strlen('createWith'))); 117 | 118 | if ($name === 'jsonSerialize') { 119 | throw new BadMethodCallException; 120 | } 121 | 122 | return call_user_func([new self, $name], ...$arguments); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | getName()); 23 | $docComment = $testMethod->getDocComment(); 24 | 25 | if (preg_match('/@dontPopulateProperties (.*)/', $docComment, $matches)) { 26 | $this->dontPopulateProperties = array_values(array_filter(explode(' ', $matches[1]))); 27 | } 28 | } 29 | 30 | public function canPopulateProperty(string $propertyName): bool 31 | { 32 | return !in_array($propertyName, $this->dontPopulateProperties); 33 | } 34 | 35 | public function ignoreUserDeprecation(string $messagePattern, callable $callback) { 36 | set_error_handler( 37 | function (int $errorNumber, string $errorString, string $errorFile, int $errorLine) use ($messagePattern) { 38 | if ($errorNumber !== E_USER_DEPRECATED || preg_match($messagePattern, $errorString) !== 1) { 39 | ErrorHandler::handleError($errorNumber, $errorString, $errorFile, $errorLine); 40 | } 41 | } 42 | ); 43 | 44 | $value = $callback(); 45 | 46 | restore_error_handler(); 47 | 48 | return $value; 49 | } 50 | 51 | public function getPidsForProcessName(string $processName) { 52 | $pgrep = new Process(['pgrep', $processName]); 53 | $pgrep->run(); 54 | 55 | $pids = explode("\n", $pgrep->getOutput()); 56 | 57 | $pids = array_filter($pids, function ($pid) { 58 | return !empty($pid); 59 | }); 60 | 61 | $pids = array_map(function ($pid) { 62 | return (int) $pid; 63 | }, $pids); 64 | 65 | return $pids; 66 | } 67 | 68 | public function loggerMock($expectations) { 69 | $loggerMock = $this->getMockBuilder(Logger::class) 70 | ->setConstructorArgs(['rialto']) 71 | ->setMethods(['log']) 72 | ->getMock(); 73 | 74 | if ($expectations instanceof Invocation) { 75 | $expectations = [func_get_args()]; 76 | } 77 | 78 | foreach ($expectations as $expectation) { 79 | [$matcher] = $expectation; 80 | $with = array_slice($expectation, 1); 81 | 82 | $loggerMock->expects($matcher) 83 | ->method('log') 84 | ->with(...$with); 85 | } 86 | 87 | return $loggerMock; 88 | } 89 | 90 | public function isLogLevel(): Callback { 91 | $psrLogLevels = (new ReflectionClass(LogLevel::class))->getConstants(); 92 | $monologLevels = (new ReflectionClass(Logger::class))->getConstants(); 93 | $monologLevels = array_intersect_key($monologLevels, $psrLogLevels); 94 | 95 | return $this->callback(function ($level) use ($psrLogLevels, $monologLevels) { 96 | if (is_string($level)) { 97 | return in_array($level, $psrLogLevels, true); 98 | } else if (is_int($level)) { 99 | return in_array($level, $monologLevels, true); 100 | } 101 | 102 | return false; 103 | }); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Traits/CommunicatesWithProcessSupervisor.php: -------------------------------------------------------------------------------- 1 | processSupervisor; 30 | } 31 | 32 | /** 33 | * Set the process supervisor. 34 | * 35 | * @throws \RuntimeException if the process supervisor has already been set. 36 | */ 37 | public function setProcessSupervisor(ProcessSupervisor $processSupervisor): void 38 | { 39 | if ($this->processSupervisor !== null) { 40 | throw new RuntimeException('The process supervisor has already been set.'); 41 | } 42 | 43 | $this->processSupervisor = $processSupervisor; 44 | } 45 | 46 | /** 47 | * Clone the resource and catch its instruction errors. 48 | */ 49 | protected function createCatchingResource() 50 | { 51 | $resource = clone $this; 52 | 53 | $resource->catchInstructionErrors = true; 54 | 55 | return $resource; 56 | } 57 | 58 | /** 59 | * Proxy an action. 60 | */ 61 | protected function proxyAction(string $actionType, string $name, $value = null) 62 | { 63 | switch ($actionType) { 64 | case Instruction::TYPE_CALL: 65 | $value = $value ?? []; 66 | $instruction = Instruction::withCall($name, ...$value); 67 | break; 68 | case Instruction::TYPE_GET: 69 | $instruction = Instruction::withGet($name); 70 | break; 71 | case Instruction::TYPE_SET: 72 | $instruction = Instruction::withSet($name, $value); 73 | break; 74 | } 75 | 76 | $identifiesResource = $this instanceof ShouldIdentifyResource; 77 | 78 | $instruction->linkToResource($identifiesResource ? $this : null); 79 | 80 | if ($this->catchInstructionErrors) { 81 | $instruction->shouldCatchErrors(true); 82 | } 83 | 84 | return $this->getProcessSupervisor()->executeInstruction($instruction); 85 | } 86 | 87 | /** 88 | * Proxy the string casting to the process supervisor. 89 | */ 90 | public function __toString(): string 91 | { 92 | return $this->proxyAction(Instruction::TYPE_CALL, 'toString'); 93 | } 94 | 95 | /** 96 | * Proxy the method call to the process supervisor. 97 | */ 98 | public function __call(string $name, array $arguments) 99 | { 100 | return $this->proxyAction(Instruction::TYPE_CALL, $name, $arguments); 101 | } 102 | 103 | /** 104 | * Proxy the property reading to the process supervisor. 105 | */ 106 | public function __get(string $name) 107 | { 108 | if ($name === 'tryCatch' && !$this->catchInstructionErrors) { 109 | return $this->createCatchingResource(); 110 | } 111 | 112 | return $this->proxyAction(Instruction::TYPE_GET, $name); 113 | } 114 | 115 | /** 116 | * Proxy the property writing to the process supervisor. 117 | */ 118 | public function __set(string $name, $value) 119 | { 120 | return $this->proxyAction(Instruction::TYPE_SET, $name, $value); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/node-process/Connection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EventEmitter = require('events'), 4 | ConnectionDelegate = require('./ConnectionDelegate'), 5 | ResourceRepository = require('./Data/ResourceRepository'), 6 | Instruction = require('./Instruction'), 7 | DataSerializer = require('./Data/Serializer'), 8 | DataUnserializer = require('./Data/Unserializer'), 9 | Logger = require('./Logger'); 10 | 11 | /** 12 | * Handle a connection interacting with this process. 13 | */ 14 | class Connection extends EventEmitter 15 | { 16 | /** 17 | * Constructor. 18 | * 19 | * @param {net.Socket} socket 20 | * @param {ConnectionDelegate} delegate 21 | */ 22 | constructor(socket, delegate) 23 | { 24 | super(); 25 | 26 | this.socket = this.configureSocket(socket); 27 | 28 | this.delegate = delegate; 29 | 30 | this.resources = new ResourceRepository; 31 | 32 | this.dataSerializer = new DataSerializer(this.resources); 33 | this.dataUnserializer = new DataUnserializer(this.resources); 34 | } 35 | 36 | /** 37 | * Configure the socket for communication. 38 | * 39 | * @param {net.Socket} socket 40 | * @return {net.Socket} 41 | */ 42 | configureSocket(socket) 43 | { 44 | socket.setEncoding('utf8'); 45 | 46 | socket.on('data', data => { 47 | this.emit('activity'); 48 | 49 | this.handleSocketData(data); 50 | }); 51 | 52 | return socket; 53 | } 54 | 55 | /** 56 | * Handle data received on the socket. 57 | * 58 | * @param {string} data 59 | */ 60 | handleSocketData(data) 61 | { 62 | const instruction = new Instruction(JSON.parse(data), this.resources, this.dataUnserializer), 63 | {responseHandler, errorHandler} = this.createInstructionHandlers(); 64 | 65 | this.delegate.handleInstruction(instruction, responseHandler, errorHandler); 66 | } 67 | 68 | /** 69 | * Generate response and errors handlers. 70 | * 71 | * @return {Object} 72 | */ 73 | createInstructionHandlers() 74 | { 75 | let handlerHasBeenCalled = false; 76 | 77 | const handler = (serializingMethod, value) => { 78 | if (handlerHasBeenCalled) { 79 | throw new Error('You can call only once the response/error handler.'); 80 | } 81 | 82 | handlerHasBeenCalled = true; 83 | 84 | this.writeToSocket(JSON.stringify({ 85 | logs: Logger.logs(), 86 | value: this[serializingMethod](value), 87 | })); 88 | }; 89 | 90 | return { 91 | responseHandler: handler.bind(this, 'serializeValue'), 92 | errorHandler: handler.bind(this, 'serializeError'), 93 | }; 94 | } 95 | 96 | /** 97 | * Write a string to the socket by slitting it in packets of fixed length. 98 | * 99 | * @param {string} str 100 | */ 101 | writeToSocket(str) 102 | { 103 | const payload = Buffer.from(str).toString('base64'); 104 | 105 | const bodySize = Connection.SOCKET_PACKET_SIZE - Connection.SOCKET_HEADER_SIZE, 106 | chunkCount = Math.ceil(payload.length / bodySize); 107 | 108 | for (let i = 0 ; i < chunkCount ; i++) { 109 | const chunk = payload.substr(i * bodySize, bodySize); 110 | 111 | let chunksLeft = String(chunkCount - 1 - i); 112 | chunksLeft = chunksLeft.padStart(Connection.SOCKET_HEADER_SIZE - 1, '0'); 113 | 114 | this.socket.write(`${chunksLeft}:${chunk}`); 115 | } 116 | } 117 | 118 | /** 119 | * Serialize a value to return to the client. 120 | * 121 | * @param {*} value 122 | * @return {Object} 123 | */ 124 | serializeValue(value) 125 | { 126 | return this.dataSerializer.serialize(value); 127 | } 128 | 129 | /** 130 | * Serialize an error to return to the client. 131 | * 132 | * @param {Error} error 133 | * @return {Object} 134 | */ 135 | serializeError(error) 136 | { 137 | return DataSerializer.serializeError(error); 138 | } 139 | } 140 | 141 | /** 142 | * The size of a packet sent through the sockets. 143 | * 144 | * @constant 145 | * @type {number} 146 | */ 147 | Connection.SOCKET_PACKET_SIZE = 1024; 148 | 149 | /** 150 | * The size of the header in each packet sent through the sockets. 151 | * 152 | * @constant 153 | * @type {number} 154 | */ 155 | Connection.SOCKET_HEADER_SIZE = 5; 156 | 157 | module.exports = Connection; 158 | -------------------------------------------------------------------------------- /src/Instruction.php: -------------------------------------------------------------------------------- 1 | type = self::TYPE_CALL; 66 | $this->name = $name; 67 | $this->setValue($arguments, $this->type); 68 | 69 | return $this; 70 | } 71 | 72 | /** 73 | * Define a getter. 74 | */ 75 | public function get(string $name): self 76 | { 77 | $this->type = self::TYPE_GET; 78 | $this->name = $name; 79 | $this->setValue(null, $this->type); 80 | 81 | return $this; 82 | } 83 | 84 | /** 85 | * Define a setter. 86 | */ 87 | public function set(string $name, $value): self 88 | { 89 | $this->type = self::TYPE_SET; 90 | $this->name = $name; 91 | $this->setValue($value, $this->type); 92 | 93 | return $this; 94 | } 95 | 96 | /** 97 | * Link the instruction to the provided resource. 98 | */ 99 | public function linkToResource(?ShouldIdentifyResource $resource): self 100 | { 101 | $this->resource = $resource; 102 | 103 | return $this; 104 | } 105 | 106 | /** 107 | * Define if instruction errors should be catched. 108 | */ 109 | public function shouldCatchErrors(bool $catch): self 110 | { 111 | $this->shouldCatchErrors = $catch; 112 | 113 | return $this; 114 | } 115 | 116 | /** 117 | * Set the instruction value. 118 | */ 119 | protected function setValue($value, string $type) 120 | { 121 | $this->value = $type !== self::TYPE_CALL 122 | ? $this->validateValue($value) 123 | : array_map(function ($value) { 124 | return $this->validateValue($value); 125 | }, $value); 126 | } 127 | 128 | /** 129 | * Validate a value. 130 | * 131 | * @throws \InvalidArgumentException if the value contains PHP closures. 132 | */ 133 | protected function validateValue($value) 134 | { 135 | if (is_object($value) && ($value instanceof Closure)) { 136 | throw new InvalidArgumentException('You must use JS function wrappers instead of PHP closures.'); 137 | } 138 | 139 | return $value; 140 | } 141 | 142 | /** 143 | * Serialize the object to a value that can be serialized natively by {@see json_encode}. 144 | */ 145 | public function jsonSerialize(): array 146 | { 147 | $instruction = ['type' => $this->type]; 148 | 149 | if ($this->type !== self::TYPE_NOOP) { 150 | $instruction = array_merge($instruction, [ 151 | 'name' => $this->name, 152 | 'catched' => $this->shouldCatchErrors, 153 | ]); 154 | 155 | if ($this->type !== self::TYPE_GET) { 156 | $instruction['value'] = $this->value; 157 | } 158 | 159 | if ($this->resource !== null) { 160 | $instruction['resource'] = $this->resource; 161 | } 162 | } 163 | 164 | return $instruction; 165 | } 166 | 167 | /** 168 | * Proxy the "with*" static method calls to the "*" non-static method calls of a new instance. 169 | */ 170 | public static function __callStatic(string $name, array $arguments) 171 | { 172 | $name = lcfirst(substr($name, strlen('with'))); 173 | 174 | if ($name === 'jsonSerialize') { 175 | throw new BadMethodCallException; 176 | } 177 | 178 | return call_user_func([new self, $name], ...$arguments); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/node-process/Instruction.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ResourceIdentity = require('./Data/ResourceIdentity'); 4 | 5 | class Instruction 6 | { 7 | /** 8 | * Constructor. 9 | * 10 | * @param {Object} serializedInstruction 11 | * @param {ResourceRepository} resources 12 | * @param {DataUnserializer} dataUnserializer 13 | */ 14 | constructor(serializedInstruction, resources, dataUnserializer) 15 | { 16 | this.instruction = serializedInstruction; 17 | this.resources = resources; 18 | this.dataUnserializer = dataUnserializer; 19 | this.defaultResource = process; 20 | } 21 | 22 | /** 23 | * Return the type of the instruction. 24 | * 25 | * @return {instructionTypeEnum} 26 | */ 27 | type() 28 | { 29 | return this.instruction.type; 30 | } 31 | 32 | /** 33 | * Override the type of the instruction. 34 | * 35 | * @param {instructionTypeEnum} type 36 | * @return {this} 37 | */ 38 | overrideType(type) 39 | { 40 | this.instruction.type = type; 41 | 42 | return this; 43 | } 44 | 45 | /** 46 | * Return the name of the instruction. 47 | * 48 | * @return {string} 49 | */ 50 | name() 51 | { 52 | return this.instruction.name; 53 | } 54 | 55 | /** 56 | * Override the name of the instruction. 57 | * 58 | * @param {string} name 59 | * @return {this} 60 | */ 61 | overrideName(name) 62 | { 63 | this.instruction.name = name; 64 | 65 | return this; 66 | } 67 | 68 | /** 69 | * Return the value of the instruction. 70 | * 71 | * @return {*} 72 | */ 73 | value() 74 | { 75 | const {value} = this.instruction; 76 | 77 | return value !== undefined ? value : null; 78 | } 79 | 80 | /** 81 | * Override the value of the instruction. 82 | * 83 | * @param {*} value 84 | * @return {this} 85 | */ 86 | overrideValue(value) 87 | { 88 | this.instruction.value = value; 89 | 90 | return this; 91 | } 92 | 93 | /** 94 | * Return the resource of the instruction. 95 | * 96 | * @return {Object|null} 97 | */ 98 | resource() 99 | { 100 | const {resource} = this.instruction; 101 | 102 | return resource 103 | ? this.resources.retrieve(ResourceIdentity.unserialize(resource)) 104 | : null; 105 | } 106 | 107 | /** 108 | * Override the resource of the instruction. 109 | * 110 | * @param {Object|null} resource 111 | * @return {this} 112 | */ 113 | overrideResource(resource) 114 | { 115 | if (resource !== null) { 116 | this.instruction.resource = this.resources.store(resource); 117 | } 118 | 119 | return this; 120 | } 121 | 122 | /** 123 | * Set the default resource to use. 124 | * 125 | * @param {Object} resource 126 | * @return {this} 127 | */ 128 | setDefaultResource(resource) 129 | { 130 | this.defaultResource = resource; 131 | 132 | return this; 133 | } 134 | 135 | /** 136 | * Whether errors thrown by the instruction should be catched. 137 | * 138 | * @return {boolean} 139 | */ 140 | shouldCatchErrors() 141 | { 142 | return this.instruction.catched; 143 | } 144 | 145 | /** 146 | * Execute the instruction. 147 | * 148 | * @return {*} 149 | */ 150 | execute() 151 | { 152 | const type = this.type(), 153 | name = this.name(), 154 | value = this.value(), 155 | resource = this.resource() || this.defaultResource; 156 | 157 | let output = null; 158 | 159 | switch (type) { 160 | case Instruction.TYPE_CALL: 161 | output = this.callResourceMethod(resource, name, value || []); 162 | break; 163 | case Instruction.TYPE_GET: 164 | output = resource[name]; 165 | break; 166 | case Instruction.TYPE_SET: 167 | output = resource[name] = this.unserializeValue(value); 168 | break; 169 | } 170 | 171 | return output; 172 | } 173 | 174 | /** 175 | * Call a method on a resource. 176 | * 177 | * @protected 178 | * @param {Object} resource 179 | * @param {string} methodName 180 | * @param {array} args 181 | * @return {*} 182 | */ 183 | callResourceMethod(resource, methodName, args) 184 | { 185 | try { 186 | return resource[methodName](...args.map(this.unserializeValue.bind(this))); 187 | } catch (error) { 188 | if (error.message === 'resource[methodName] is not a function') { 189 | const resourceName = resource.constructor.name === 'Function' 190 | ? resource.name 191 | : resource.constructor.name; 192 | 193 | throw new Error(`"${resourceName}.${methodName} is not a function"`); 194 | } 195 | 196 | throw error; 197 | } 198 | } 199 | 200 | /** 201 | * Unserialize a value. 202 | * 203 | * @protected 204 | * @param {Object} value 205 | * @return {*} 206 | */ 207 | unserializeValue(value) 208 | { 209 | return this.dataUnserializer.unserialize(value); 210 | } 211 | } 212 | 213 | /** 214 | * Instruction types. 215 | * 216 | * @enum {instructionTypeEnum} 217 | * @readonly 218 | */ 219 | Object.assign(Instruction, { 220 | TYPE_CALL: 'call', 221 | TYPE_GET: 'get', 222 | TYPE_SET: 'set', 223 | }); 224 | 225 | module.exports = Instruction; 226 | -------------------------------------------------------------------------------- /docs/tutorial.md: -------------------------------------------------------------------------------- 1 | # Creating your first bridge with Rialto 2 | 3 | We will create a bridge to use [Node's File System module](https://nodejs.org/api/fs.html) in PHP. This is not especially useful but it will show you how Rialto works and handles pretty much everything for you. 4 | 5 | ## Importing Rialto 6 | 7 | Import Rialto in your project: 8 | 9 | ``` 10 | composer require nesk/rialto 11 | npm install @nesk/rialto 12 | ``` 13 | 14 | ## The essential files 15 | 16 | You will need to create at least two files for your package: 17 | 18 | - **An entry point** (`FileSystem.php`): this PHP class inherits [`AbstractEntryPoint`](../src/AbstractEntryPoint.php) and its instanciation creates the Node process. Every instruction (calling a method, setting a property, etc…) made on this class will be intercepted and sent to Node. 19 | 20 | ```php 21 | use Nesk\Rialto\AbstractEntryPoint; 22 | 23 | class FileSystem extends AbstractEntryPoint 24 | { 25 | public function __construct() 26 | { 27 | parent::__construct(__DIR__.'/FileSystemConnectionDelegate.js'); 28 | } 29 | } 30 | ``` 31 | 32 | - **A connection delegate** (`FileSystemConnectionDelegate.js`): this JavaScript class inherits [`ConnectionDelegate`](../src/node-process/ConnectionDelegate.js) and will execute the instructions made with PHP (calling a method, setting a property, etc…). 33 | 34 | ```js 35 | const fs = require('fs'), 36 | {ConnectionDelegate} = require('@nesk/rialto'); 37 | 38 | module.exports = class FileSystemConnectionDelegate extends ConnectionDelegate 39 | { 40 | handleInstruction(instruction, responseHandler, errorHandler) 41 | { 42 | // Define on which resource the instruction should be applied by default, 43 | // here we want to apply them on the "fs" module. 44 | instruction.setDefaultResource(fs); 45 | 46 | let value = null; 47 | 48 | try { 49 | // Try to execute the instruction 50 | value = instruction.execute(); 51 | } catch (error) { 52 | // If the instruction fails and the user asked to catch errors (see the `tryCatch` property in the API), 53 | // send it with the error handler. 54 | if (instruction.shouldCatchErrors()) { 55 | return errorHandler(error); 56 | } 57 | 58 | throw error; 59 | } 60 | 61 | // Send back the value returned by the instruction 62 | responseHandler(value); 63 | } 64 | } 65 | ``` 66 | 67 | With these two files, you should already be able to use your bridge: 68 | 69 | ```php 70 | use Nesk\Puphpeteer\Fs\FileSystem; 71 | 72 | $fs = new FileSystem; 73 | 74 | $stats = $fs->statSync('/valid/file/path'); // Returns a basic resource representing a Stats instance 75 | 76 | $stats->isFile(); // Returns true if the path points to a file 77 | ``` 78 | 79 | **Note:** You should use the synchronous methods of Node's FileSystem module. There is no way to handle asynchronous callbacks with Rialto for the moment. 80 | 81 | ## Creating specific resources 82 | 83 | The example above returns a [`BasicResource`](../src/Data/BasicResource.php) class when the JavaScript API returns a resource (typically, a class instance). See this example: 84 | 85 | ```php 86 | $buffer = $fs->readFileSync('/valid/file/path'); // Returns a basic resource representing a Buffer instance 87 | 88 | $stats = $fs->statSync('/valid/file/path'); // Returns a basic resource representing a Stats instance 89 | ``` 90 | 91 | Its possible to know the name of the resource class: 92 | 93 | ```php 94 | $buffer->getResourceIdentity()->className(); // Returns "Buffer" 95 | 96 | $stats->getResourceIdentity()->className(); // Returns "Stats" 97 | ``` 98 | 99 | However, this is not convenient. That's why you can create specific resources to improve that. We will create 3 files: 100 | 101 | - **A process delegate** (`FileSystemProcessDelegate.php`): this PHP class implements [`ShouldHandleProcessDelegation`](../src/Interfaces/ShouldHandleProcessDelegation.php) and is responsible to return the class names of the specific and default resources. 102 | 103 | ```php 104 | use Nesk\Rialto\Traits\UsesBasicResourceAsDefault; 105 | use Nesk\Rialto\Interfaces\ShouldHandleProcessDelegation; 106 | 107 | class FileSystemProcessDelegate implements ShouldHandleProcessDelegation 108 | { 109 | // Define that we want to use the BasicResource class as a default if resourceFromOriginalClassName() returns null 110 | use UsesBasicResourceAsDefault; 111 | 112 | public function resourceFromOriginalClassName(string $jsClassName): ?string 113 | { 114 | // Generate the appropriate class name for PHP 115 | $class = "{$jsClassName}Resource"; 116 | 117 | // If the PHP class doesn't exist, return null, it will automatically create a basic resource. 118 | return class_exists($class) ? $class : null; 119 | } 120 | } 121 | ``` 122 | 123 | - **A resource to represent Buffer instances** (`BufferResource.php`): this class inherits `BasicResource` by convenience but the only requirement is to implement the [`ShouldIdentifyResource`](../src/Interfaces/ShouldIdentifyResource.php) interface. 124 | 125 | ```php 126 | use Nesk\Rialto\Data\BasicResource; 127 | 128 | class BufferResource extends BasicResource 129 | { 130 | } 131 | ``` 132 | 133 | - **A resource to represent Stats instances** (`StatsResource.php`): 134 | 135 | ```php 136 | use Nesk\Rialto\Data\BasicResource; 137 | 138 | class StatsResource extends BasicResource 139 | { 140 | } 141 | ``` 142 | 143 | Once those 3 files are created, you will have to register the process delegate in your entry point (`FileSystem.php`): 144 | 145 | ```php 146 | use Nesk\Rialto\AbstractEntryPoint; 147 | 148 | class FileSystem extends AbstractEntryPoint 149 | { 150 | public function __construct() 151 | { 152 | // Add the process delegate in addition to the connection delegate 153 | parent::__construct(__DIR__.'/FileSystemConnectionDelegate.js', new FileSystemProcessDelegate); 154 | } 155 | } 156 | ``` 157 | 158 | Now you will get specific resources instead of the default one: 159 | 160 | ```php 161 | $fs->readFileSync('/valid/file/path'); // Returns BufferResource 162 | 163 | $fs->statSync('/valid/file/path'); // Returns StatsResource 164 | 165 | $fs->statSync('/valid/file/path')->birthtime; // Returns a basic resource representing a Date instance 166 | ``` 167 | 168 | Specific resources can also help you to improve your API by adding methods to them: 169 | 170 | ```php 171 | use Nesk\Rialto\Data\BasicResource; 172 | 173 | class StatsResource extends BasicResource 174 | { 175 | public function birthtime(): \DateTime 176 | { 177 | return (new \DateTime)->setTimestamp($this->birthtimeMs / 1000); 178 | } 179 | } 180 | ``` 181 | 182 | ```php 183 | $fs->statSync('/valid/file/path')->birthtime(); // Returns a PHP's DateTime instance 184 | ``` 185 | 186 | ## Learn more 187 | 188 | Your first bridge with Rialto is ready, you can learn more by reading the [API documentation](api.md). 189 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API usage 2 | 3 | ## Options 4 | 5 | The entry point of Rialto accepts [multiple options](https://github.com/nesk/rialto/blob/75b5a9464235a597e3ab71ac90246779a40fe145/src/ProcessSupervisor.php#L42-L70), here are some descriptions with the default values: 6 | 7 | ```php 8 | [ 9 | // Node's executable path 10 | 'executable_path' => 'node', 11 | 12 | // How much time (in seconds) the process can stay inactive before being killed (set to null to disable) 13 | 'idle_timeout' => 60, 14 | 15 | // How much time (in seconds) an instruction can take to return a value (set to null to disable) 16 | 'read_timeout' => 30, 17 | 18 | // How much time (in seconds) the process can take to shutdown properly before being killed 19 | 'stop_timeout' => 3, 20 | 21 | // A logger instance for debugging (must implement \Psr\Log\LoggerInterface) 22 | 'logger' => null, 23 | 24 | // Logs the output of console methods (console.log, console.debug, console.table, etc...) to the PHP logger 25 | 'log_node_console' => false, 26 | 27 | // Enables debugging mode: 28 | // - adds the --inspect flag to Node's command 29 | // - appends stack traces to Node exception messages 30 | 'debug' => false, 31 | ] 32 | ``` 33 | 34 | You can define an option in your entry point using the third parameter of the parent constructor: 35 | 36 | ```php 37 | class MyEntryPoint extends AbstractEntryPoint 38 | { 39 | public function __construct() 40 | { 41 | // ... 42 | 43 | $myOptions = [ 44 | 'idle_timeout' => 300, // 5 minutes 45 | ]; 46 | 47 | parent::__construct($connectionDelegate, $processDelegate, $myOptions); 48 | } 49 | } 50 | ``` 51 | 52 | ### Accepting user options 53 | 54 | If you want your users to define some of Rialto's options, you can use the fourth parameter: 55 | 56 | ```php 57 | class MyEntryPoint extends AbstractEntryPoint 58 | { 59 | public function __construct(array $userOptions = []) 60 | { 61 | // ... 62 | 63 | parent::__construct($connectionDelegate, $processDelegate, $myOptions, $userOptions); 64 | } 65 | } 66 | ``` 67 | 68 | User options will override your own defaults. To prevent a user to define some specific options, use the `$forbiddenOptions` property: 69 | 70 | ```php 71 | class MyEntryPoint extends AbstractEntryPoint 72 | { 73 | protected $forbiddenOptions = ['idle_timeout', 'stop_timeout']; 74 | 75 | public function __construct(array $userOptions = []) 76 | { 77 | // ... 78 | 79 | parent::__construct($connectionDelegate, $processDelegate, $myOptions, $userOptions); 80 | } 81 | } 82 | ``` 83 | 84 | By default, users are forbidden to define the `stop_timeout` option. 85 | 86 | **Note:** You should authorize your users to define, at least, the `executable_path`, `logger` and `debug` options. 87 | 88 | ## Node errors 89 | 90 | If an error (or an unhandled rejection) occurs in Node, a `Node\FatalException` will be thrown and the process closed, you will have to create a new instance of your entry point. 91 | 92 | To avoid that, you can ask Node to catch these errors by prepending your instruction with `->tryCatch`: 93 | 94 | ```php 95 | use Nesk\Rialto\Exceptions\Node; 96 | 97 | try { 98 | $someResource->tryCatch->inexistantMethod(); 99 | } catch (Node\Exception $exception) { 100 | // Handle the exception... 101 | } 102 | ``` 103 | 104 | Instead, a `Node\Exception` will be thrown, the Node process will stay alive and usable. 105 | 106 | ## JavaScript functions 107 | 108 | With Rialto you can create JavaScript functions and pass them to the Node process, this can be useful to map some values or any other actions based on callbacks. 109 | 110 | To create them, you need to use the `Nesk\Rialto\Data\JsFunction` class and call one or multiple methods in this list: 111 | 112 | - `parameters(array)`: Sets parameters for your function, each string in the array is a parameter. You can define a default value for a parameter by using the parameter name as a key and its default value as the item value (e.g. `->parameters(['firstParam', 'secondParam' => 'Default string value'])`). 113 | 114 | - `body(string)`: Sets the body of your function, just write your JS code in a PHP string (e.g. `->body("return 'Hello world!'")`). 115 | 116 | - `scope(array)`: Defines scope variables for your function. Say you have `$hello = 'Hello world!'` in your PHP and you want to use it in your JS code, you can write `->scope(['myVar' => $hello])` and you will be able to use it in your body `->body("console.log(myVar)")`. 117 |
**Note:** Scope variables must be JSON serializable values or resources created by Rialto. 118 | 119 | - `async(?bool)`: Makes your JS function async. Optionally, you can provide a boolean: `true` will make the function async, `false` will remove the `async` state. 120 |
**Note:** Like in the ECMAScript specification, JS functions _aren't_ async by default. 121 | 122 | To create a new JS function, use `JsFunction::createWith__METHOD_NAME__` with the method name you want (in the list just above): 123 | 124 | ```php 125 | JsFunction::createWithParameters(['a', 'b']) 126 | ->body('return a + b;'); 127 | ``` 128 | 129 | Here we used `createWithParameters` to start the creation, but we could have used `createWithBody`, `createWithScope`, etc… 130 | 131 |
132 | ⚙️ Some examples showing how to use these methods
133 | 134 | - A function with a body: 135 | 136 | ```php 137 | $jsFunction = JsFunction::createWithBody("return process.uptime()"); 138 | 139 | $someResource->someMethodWithCallback($jsFunction); 140 | ``` 141 | 142 | - A function with parameters and a body: 143 | 144 | ```php 145 | $jsFunction = JsFunction::createWithParameters(['str', 'str2' => 'Default value!']) 146 | ->body("return 'This is my string: ' + str"); 147 | 148 | $someResource->someMethodWithCallback($jsFunction); 149 | ``` 150 | 151 | - A function with parameters, a body, scoped values, and async flag: 152 | 153 | ```php 154 | $functionScope = ['stringtoPrepend' => 'This is another string: ']; 155 | 156 | $jsFunction = JsFunction::createWithAsync() 157 | ->parameters(['str']) 158 | ->body("return stringToPrepend + str") 159 | ->scope($functionScope); 160 | 161 | $someResource->someMethodWithCallback($jsFunction); 162 | ``` 163 | 164 |
165 | 166 |
167 | 168 |
169 | ⚠️ Deprecated examples of the JsFunction::create() method
170 | 171 | - A function with a body: 172 | 173 | ```php 174 | $jsFunction = JsFunction::create(" 175 | return process.uptime(); 176 | "); 177 | 178 | $someResource->someMethodWithCallback($jsFunction); 179 | ``` 180 | 181 | - A function with parameters: 182 | 183 | ```php 184 | $jsFunction = JsFunction::create(['str', 'str2' => 'Default value!'], " 185 | return 'This is my string: ' + str; 186 | "); 187 | 188 | $someResource->someMethodWithCallback($jsFunction); 189 | ``` 190 | 191 | - A function with parameters, a body, and scoped values: 192 | 193 | ```php 194 | $functionScope = ['stringtoPrepend' => 'This is another string: ']; 195 | 196 | $jsFunction = JsFunction::create(['str'], " 197 | return stringToPrepend + str; 198 | ", $functionScope); 199 | 200 | $someResource->someMethodWithCallback($jsFunction); 201 | ``` 202 | 203 |
204 | 205 | ## Destruction 206 | 207 | If you're worried about the destruction of the Node process, here's two things you need to know: 208 | 209 | - Once the entry point and all the resources (like the `BasicResource` class) are unset, the Node process is automatically terminated. 210 | - If, for any reason, the Node process doesn't terminate, it will kill itself once the `idle_timeout` is exceeded. 211 | -------------------------------------------------------------------------------- /src/ProcessSupervisor.php: -------------------------------------------------------------------------------- 1 | 'node', 67 | 68 | // How much time (in seconds) the process can stay inactive before being killed (set to null to disable) 69 | 'idle_timeout' => 60, 70 | 71 | // How much time (in seconds) an instruction can take to return a value (set to null to disable) 72 | 'read_timeout' => 30, 73 | 74 | // How much time (in seconds) the process can take to shutdown properly before being killed 75 | 'stop_timeout' => 3, 76 | 77 | // A logger instance for debugging (must implement \Psr\Log\LoggerInterface) 78 | 'logger' => null, 79 | 80 | // Logs the output of console methods (console.log, console.debug, console.table, etc...) to the PHP logger 81 | 'log_node_console' => false, 82 | 83 | // Enables debugging mode: 84 | // - adds the --inspect flag to Node's command 85 | // - appends stack traces to Node exception messages 86 | 'debug' => false, 87 | ]; 88 | 89 | /** 90 | * The running process. 91 | * 92 | * @var \Symfony\Component\Process\Process 93 | */ 94 | protected $process; 95 | 96 | /** 97 | * The PID of the running process. 98 | * 99 | * @var int 100 | */ 101 | protected $processPid; 102 | 103 | /** 104 | * The process delegate. 105 | * 106 | * @var \Nesk\Rialto\ShouldHandleProcessDelegation; 107 | */ 108 | protected $delegate; 109 | 110 | /** 111 | * The client to communicate with the process. 112 | * 113 | * @var \Socket\Raw\Socket 114 | */ 115 | protected $client; 116 | 117 | /** 118 | * The server port. 119 | * 120 | * @var int 121 | */ 122 | protected $serverPort; 123 | 124 | /** 125 | * The logger instance. 126 | * 127 | * @var \Psr\Log\LoggerInterface 128 | */ 129 | protected $logger; 130 | 131 | /** 132 | * Constructor. 133 | */ 134 | public function __construct( 135 | string $connectionDelegatePath, 136 | ?ShouldHandleProcessDelegation $processDelegate = null, 137 | array $options = [] 138 | ) { 139 | $this->logger = new Logger($options['logger'] ?? null); 140 | 141 | $this->applyOptions($options); 142 | 143 | $this->process = $this->createNewProcess($connectionDelegatePath); 144 | 145 | $this->processPid = $this->startProcess($this->process); 146 | 147 | $this->delegate = $processDelegate; 148 | 149 | $this->client = $this->createNewClient($this->serverPort()); 150 | 151 | if ($this->options['debug']) { 152 | // Clear error output made by the "--inspect" flag 153 | $this->process->clearErrorOutput(); 154 | } 155 | } 156 | 157 | /** 158 | * Destructor. 159 | */ 160 | public function __destruct() 161 | { 162 | $logContext = ['pid' => $this->processPid]; 163 | 164 | $this->waitForProcessTermination(); 165 | 166 | if ($this->process->isRunning()) { 167 | $this->executeInstruction(Instruction::noop(), false); // Fetch the missing remote logs 168 | 169 | $this->logger->info('Stopping process with PID {pid}...', $logContext); 170 | $this->process->stop($this->options['stop_timeout']); 171 | $this->logger->info('Stopped process with PID {pid}', $logContext); 172 | } else { 173 | $this->logger->warning("The process cannot because be stopped because it's no longer running", $logContext); 174 | } 175 | } 176 | 177 | /** 178 | * Log data from the process standard streams. 179 | */ 180 | protected function logProcessStandardStreams(): void 181 | { 182 | if (!empty($output = $this->process->getIncrementalOutput())) { 183 | $this->logger->notice('Received data on stdout: {output}', [ 184 | 'pid' => $this->processPid, 185 | 'stream' => 'stdout', 186 | 'output' => $output, 187 | ]); 188 | } 189 | 190 | if (!empty($errorOutput = $this->process->getIncrementalErrorOutput())) { 191 | $this->logger->error('Received data on stderr: {output}', [ 192 | 'pid' => $this->processPid, 193 | 'stream' => 'stderr', 194 | 'output' => $errorOutput, 195 | ]); 196 | } 197 | } 198 | 199 | /** 200 | * Apply the options. 201 | */ 202 | protected function applyOptions(array $options): void 203 | { 204 | $this->logger->info('Applying options...', ['options' => $options]); 205 | 206 | $this->options = array_merge($this->options, $options); 207 | 208 | $this->logger->debug('Options applied and merged with defaults', ['options' => $this->options]); 209 | } 210 | 211 | /** 212 | * Return the script path of the Node process. 213 | * 214 | * In production, the script path must target the NPM package. In local development, the script path targets the 215 | * Composer package (since the NPM package is not installed). 216 | * 217 | * This avoids double declarations of some JS classes in production, due to a require with two different paths (one 218 | * with the NPM path, the other one with the Composer path). 219 | */ 220 | protected function getProcessScriptPath(): string { 221 | static $scriptPath = null; 222 | 223 | if ($scriptPath !== null) { 224 | return $scriptPath; 225 | } 226 | 227 | // The script path in local development 228 | $scriptPath = __DIR__.'/node-process/serve.js'; 229 | 230 | $process = new SymfonyProcess([ 231 | $this->options['executable_path'], 232 | '-e', 233 | "process.stdout.write(require.resolve('@nesk/rialto/src/node-process/serve.js'))", 234 | ]); 235 | 236 | $exitCode = $process->run(); 237 | 238 | if ($exitCode === 0) { 239 | // The script path in production 240 | $scriptPath = $process->getOutput(); 241 | } 242 | 243 | return $scriptPath; 244 | } 245 | 246 | /** 247 | * Create a new Node process. 248 | * 249 | * @throws RuntimeException if the path to the connection delegate cannot be found. 250 | */ 251 | protected function createNewProcess(string $connectionDelegatePath): SymfonyProcess 252 | { 253 | $realConnectionDelegatePath = realpath($connectionDelegatePath); 254 | 255 | if ($realConnectionDelegatePath === false) { 256 | throw new RuntimeException("Cannot find file or directory '$connectionDelegatePath'."); 257 | } 258 | 259 | // Remove useless options for the process 260 | $processOptions = array_diff_key($this->options, array_flip(self::USELESS_OPTIONS_FOR_PROCESS)); 261 | 262 | return new SymfonyProcess(array_merge( 263 | [$this->options['executable_path']], 264 | $this->options['debug'] ? ['--inspect'] : [], 265 | [$this->getProcessScriptPath()], 266 | [$realConnectionDelegatePath], 267 | [json_encode((object) $processOptions)] 268 | )); 269 | } 270 | 271 | /** 272 | * Start the Node process. 273 | */ 274 | protected function startProcess(SymfonyProcess $process): int 275 | { 276 | $this->logger->info('Starting process with command line: {commandline}', [ 277 | 'commandline' => $process->getCommandLine(), 278 | ]); 279 | 280 | $process->start(); 281 | 282 | $pid = $process->getPid(); 283 | 284 | $this->logger->info('Process started with PID {pid}', ['pid' => $pid]); 285 | 286 | return $pid; 287 | } 288 | 289 | /** 290 | * Check if the process is still running without errors. 291 | * 292 | * @throws \Symfony\Component\Process\Exception\ProcessFailedException 293 | */ 294 | protected function checkProcessStatus(): void 295 | { 296 | $this->logProcessStandardStreams(); 297 | 298 | $process = $this->process; 299 | 300 | if (!empty($process->getErrorOutput())) { 301 | if (IdleTimeoutException::exceptionApplies($process)) { 302 | throw new IdleTimeoutException( 303 | $this->options['idle_timeout'], 304 | new NodeFatalException($process, $this->options['debug']) 305 | ); 306 | } else if (NodeFatalException::exceptionApplies($process)) { 307 | throw new NodeFatalException($process, $this->options['debug']); 308 | } elseif ($process->isTerminated() && !$process->isSuccessful()) { 309 | throw new ProcessFailedException($process); 310 | } 311 | } 312 | 313 | if ($process->isTerminated()) { 314 | throw new Exceptions\ProcessUnexpectedlyTerminatedException($process); 315 | } 316 | } 317 | 318 | /** 319 | * Wait for process termination. 320 | * 321 | * The process might take a while to stop itself. So, before trying to check its status or reading its standard 322 | * streams, this method should be executed. 323 | */ 324 | protected function waitForProcessTermination(): void { 325 | usleep(self::PROCESS_TERMINATION_DELAY * 1000); 326 | } 327 | 328 | /** 329 | * Return the port of the server. 330 | */ 331 | protected function serverPort(): int 332 | { 333 | if ($this->serverPort !== null) { 334 | return $this->serverPort; 335 | } 336 | 337 | $iterator = $this->process->getIterator(SymfonyProcess::ITER_SKIP_ERR | SymfonyProcess::ITER_KEEP_OUTPUT); 338 | 339 | foreach ($iterator as $data) { 340 | return $this->serverPort = (int) $data; 341 | } 342 | 343 | // If the iterator didn't execute properly, then the process must have failed, we must check to be sure. 344 | $this->checkProcessStatus(); 345 | } 346 | 347 | /** 348 | * Create a new client to communicate with the process. 349 | */ 350 | protected function createNewClient(int $port): Socket 351 | { 352 | // Set the client as non-blocking to handle the exceptions thrown by the process 353 | return (new SocketFactory) 354 | ->createClient("tcp://127.0.0.1:$port") 355 | ->setBlocking(false); 356 | } 357 | 358 | /** 359 | * Send an instruction to the process for execution. 360 | */ 361 | public function executeInstruction(Instruction $instruction, bool $instructionShouldBeLogged = true) 362 | { 363 | // Check the process status because it could have crash in idle status. 364 | $this->checkProcessStatus(); 365 | 366 | $serializedInstruction = json_encode($instruction); 367 | 368 | if ($instructionShouldBeLogged) { 369 | $this->logger->debug('Sending an instruction to the port {port}...', [ 370 | 'pid' => $this->processPid, 371 | 'port' => $this->serverPort(), 372 | 373 | // The instruction must be fully encoded and decoded to appear properly in the logs (this way, 374 | // JS functions and resources are serialized too). 375 | 'instruction' => json_decode($serializedInstruction, true), 376 | ]); 377 | } 378 | 379 | $this->client->selectWrite(1); 380 | $this->client->write($serializedInstruction); 381 | 382 | $value = $this->readNextProcessValue($instructionShouldBeLogged); 383 | 384 | // Check the process status if the value is null because, if the process crash while executing the instruction, 385 | // the socket closes and returns an empty value (which is converted to `null`). 386 | if ($value === null) { 387 | $this->checkProcessStatus(); 388 | } 389 | 390 | return $value; 391 | } 392 | 393 | /** 394 | * Read the next value written by the process. 395 | * 396 | * @throws \Nesk\Rialto\Exceptions\ReadSocketTimeoutException if reading the socket exceeded the timeout. 397 | * @throws \Nesk\Rialto\Exceptions\Node\Exception if the process returned an error. 398 | */ 399 | protected function readNextProcessValue(bool $valueShouldBeLogged = true) 400 | { 401 | $readTimeout = $this->options['read_timeout']; 402 | $payload = ''; 403 | 404 | try { 405 | $startTimestamp = microtime(true); 406 | 407 | do { 408 | $this->client->selectRead($readTimeout); 409 | $packet = $this->client->read(static::SOCKET_PACKET_SIZE); 410 | 411 | $chunksLeft = (int) substr($packet, 0, static::SOCKET_HEADER_SIZE); 412 | $chunk = substr($packet, static::SOCKET_HEADER_SIZE); 413 | 414 | $payload .= $chunk; 415 | 416 | if ($chunksLeft > 0) { 417 | // The next chunk might be an empty string if don't wait a short period on slow environments. 418 | usleep(self::SOCKET_NEXT_CHUNK_DELAY * 1000); 419 | } 420 | } while ($chunksLeft > 0); 421 | } catch (SocketException $exception) { 422 | $this->waitForProcessTermination(); 423 | $this->checkProcessStatus(); 424 | 425 | // Extract the socket error code to throw more specific exceptions 426 | preg_match('/\(([A-Z_]+?)\)$/', $exception->getMessage(), $socketErrorMatches); 427 | $socketErrorCode = constant($socketErrorMatches[1]); 428 | 429 | $elapsedTime = microtime(true) - $startTimestamp; 430 | if ($socketErrorCode === SOCKET_EAGAIN && $readTimeout !== null && $elapsedTime >= $readTimeout) { 431 | throw new Exceptions\ReadSocketTimeoutException($readTimeout, $exception); 432 | } 433 | 434 | throw $exception; 435 | } 436 | 437 | $this->logProcessStandardStreams(); 438 | 439 | ['logs' => $logs, 'value' => $value] = json_decode(base64_decode($payload), true); 440 | 441 | foreach ($logs ?: [] as $log) { 442 | $level = (new \ReflectionClass(LogLevel::class))->getConstant($log['level']); 443 | $messageContainsLineBreaks = strstr($log['message'], PHP_EOL) !== false; 444 | $formattedMessage = $messageContainsLineBreaks ? "\n{log}\n" : '{log}'; 445 | 446 | $this->logger->log($level, "Received a $log[origin] log: $formattedMessage", [ 447 | 'pid' => $this->processPid, 448 | 'port' => $this->serverPort(), 449 | 'log' => $log['message'], 450 | ]); 451 | } 452 | 453 | $value = $this->unserialize($value); 454 | 455 | if ($valueShouldBeLogged) { 456 | $this->logger->debug('Received data from the port {port}...', [ 457 | 'pid' => $this->processPid, 458 | 'port' => $this->serverPort(), 459 | 'data' => $value, 460 | ]); 461 | } 462 | 463 | if ($value instanceof NodeException) { 464 | throw $value; 465 | } 466 | 467 | return $value; 468 | } 469 | } 470 | -------------------------------------------------------------------------------- /tests/ImplementationTest.php: -------------------------------------------------------------------------------- 1 | dirPath = realpath(__DIR__.'/resources'); 22 | $this->filePath = "{$this->dirPath}/file"; 23 | 24 | $this->fs = $this->canPopulateProperty('fs') ? new FsWithProcessDelegation : null; 25 | } 26 | 27 | protected function tearDown(): void 28 | { 29 | $this->fs = null; 30 | } 31 | 32 | /** @test */ 33 | public function can_call_method_and_get_its_return_value() 34 | { 35 | $content = $this->fs->readFileSync($this->filePath, 'utf8'); 36 | 37 | $this->assertEquals('Hello world!', $content); 38 | } 39 | 40 | /** @test */ 41 | public function can_get_property() 42 | { 43 | $constants = $this->fs->constants; 44 | 45 | $this->assertInternalType('array', $constants); 46 | } 47 | 48 | /** @test */ 49 | public function can_set_property() 50 | { 51 | $this->fs->foo = 'bar'; 52 | $this->assertEquals('bar', $this->fs->foo); 53 | 54 | $this->fs->foo = null; 55 | $this->assertNull($this->fs->foo); 56 | } 57 | 58 | /** @test */ 59 | public function can_return_basic_resources() 60 | { 61 | $resource = $this->fs->readFileSync($this->filePath); 62 | 63 | $this->assertInstanceOf(BasicResource::class, $resource); 64 | } 65 | 66 | /** @test */ 67 | public function can_return_specific_resources() 68 | { 69 | $resource = $this->fs->statSync($this->filePath); 70 | 71 | $this->assertInstanceOf(Stats::class, $resource); 72 | } 73 | 74 | /** @test */ 75 | public function can_cast_resources_to_string() 76 | { 77 | $resource = $this->fs->statSync($this->filePath); 78 | 79 | $this->assertEquals('[object Object]', (string) $resource); 80 | } 81 | 82 | /** 83 | * @test 84 | * @dontPopulateProperties fs 85 | */ 86 | public function can_omit_process_delegation() 87 | { 88 | $this->fs = new FsWithoutProcessDelegation; 89 | 90 | $resource = $this->fs->statSync($this->filePath); 91 | 92 | $this->assertInstanceOf(BasicResource::class, $resource); 93 | $this->assertNotInstanceOf(Stats::class, $resource); 94 | } 95 | 96 | /** @test */ 97 | public function can_use_nested_resources() 98 | { 99 | $resources = $this->fs->multipleStatSync($this->dirPath, $this->filePath); 100 | 101 | $this->assertCount(2, $resources); 102 | $this->assertContainsOnlyInstancesOf(Stats::class, $resources); 103 | 104 | $isFile = $this->fs->multipleResourcesIsFile($resources); 105 | 106 | $this->assertFalse($isFile[0]); 107 | $this->assertTrue($isFile[1]); 108 | } 109 | 110 | /** @test */ 111 | public function can_use_multiple_resources_without_confusion() 112 | { 113 | $dirStats = $this->fs->statSync($this->dirPath); 114 | $fileStats = $this->fs->statSync($this->filePath); 115 | 116 | $this->assertInstanceOf(Stats::class, $dirStats); 117 | $this->assertInstanceOf(Stats::class, $fileStats); 118 | 119 | $this->assertTrue($dirStats->isDirectory()); 120 | $this->assertTrue($fileStats->isFile()); 121 | } 122 | 123 | /** @test */ 124 | public function can_return_multiple_times_the_same_resource() 125 | { 126 | $stats1 = $this->fs->Stats; 127 | $stats2 = $this->fs->Stats; 128 | 129 | $this->assertEquals($stats1, $stats2); 130 | } 131 | 132 | /** 133 | * @test 134 | * @group js-functions 135 | */ 136 | public function can_use_js_functions_with_a_body() 137 | { 138 | $functions = [ 139 | $this->ignoreUserDeprecation(self::JS_FUNCTION_CREATE_DEPRECATION_PATTERN, function () { 140 | return JsFunction::create("return 'Simple callback';"); 141 | }), 142 | JsFunction::createWithBody("return 'Simple callback';"), 143 | ]; 144 | 145 | foreach ($functions as $function) { 146 | $value = $this->fs->runCallback($function); 147 | $this->assertEquals('Simple callback', $value); 148 | } 149 | } 150 | 151 | /** 152 | * @test 153 | * @group js-functions 154 | */ 155 | public function can_use_js_functions_with_parameters() 156 | { 157 | $functions = [ 158 | $this->ignoreUserDeprecation(self::JS_FUNCTION_CREATE_DEPRECATION_PATTERN, function () { 159 | return JsFunction::create(['fs'], " 160 | return 'Callback using arguments: ' + fs.constructor.name; 161 | "); 162 | }), 163 | JsFunction::createWithParameters(['fs']) 164 | ->body("return 'Callback using arguments: ' + fs.constructor.name;"), 165 | ]; 166 | 167 | foreach ($functions as $function) { 168 | $value = $this->fs->runCallback($function); 169 | $this->assertEquals('Callback using arguments: Object', $value); 170 | } 171 | } 172 | 173 | /** 174 | * @test 175 | * @group js-functions 176 | */ 177 | public function can_use_js_functions_with_scope() 178 | { 179 | $functions = [ 180 | $this->ignoreUserDeprecation(self::JS_FUNCTION_CREATE_DEPRECATION_PATTERN, function () { 181 | return JsFunction::create(" 182 | return 'Callback using scope: ' + foo; 183 | ", ['foo' => 'bar']); 184 | }), 185 | JsFunction::createWithScope(['foo' => 'bar']) 186 | ->body("return 'Callback using scope: ' + foo;"), 187 | ]; 188 | 189 | foreach ($functions as $function) { 190 | $value = $this->fs->runCallback($function); 191 | $this->assertEquals('Callback using scope: bar', $value); 192 | } 193 | } 194 | 195 | /** 196 | * @test 197 | * @group js-functions 198 | */ 199 | public function can_use_resources_in_js_functions() 200 | { 201 | $fileStats = $this->fs->statSync($this->filePath); 202 | 203 | $functions = [ 204 | JsFunction::createWithParameters(['fs', 'fileStats' => $fileStats]) 205 | ->body("return fileStats.isFile();"), 206 | JsFunction::createWithScope(['fileStats' => $fileStats]) 207 | ->body("return fileStats.isFile();"), 208 | ]; 209 | 210 | foreach ($functions as $function) { 211 | $isFile = $this->fs->runCallback($function); 212 | $this->assertTrue($isFile); 213 | } 214 | } 215 | 216 | /** 217 | * @test 218 | * @group js-functions 219 | */ 220 | public function can_use_async_with_js_functions() 221 | { 222 | $function = JsFunction::createWithAsync() 223 | ->body(" 224 | await Promise.resolve(); 225 | return true; 226 | "); 227 | 228 | $this->assertTrue($this->fs->runCallback($function)); 229 | 230 | $function = $function->async(false); 231 | 232 | $this->expectException(Node\FatalException::class); 233 | $this->expectExceptionMessage('await is only valid in async function'); 234 | 235 | $this->fs->runCallback($function); 236 | } 237 | 238 | /** 239 | * @test 240 | * @group js-functions 241 | */ 242 | public function js_functions_are_sync_by_default() 243 | { 244 | $function = JsFunction::createWithBody('await null'); 245 | 246 | $this->expectException(Node\FatalException::class); 247 | $this->expectExceptionMessage('await is only valid in async function'); 248 | 249 | $this->fs->runCallback($function); 250 | } 251 | 252 | /** @test */ 253 | public function can_receive_heavy_payloads_with_non_ascii_chars() 254 | { 255 | $payload = $this->fs->getHeavyPayloadWithNonAsciiChars(); 256 | 257 | $this->assertStringStartsWith('😘', $payload); 258 | $this->assertStringEndsWith('😘', $payload); 259 | } 260 | 261 | /** 262 | * @test 263 | * @expectedException \Nesk\Rialto\Exceptions\Node\FatalException 264 | * @expectedExceptionMessage Object.__inexistantMethod__ is not a function 265 | */ 266 | public function node_crash_throws_a_fatal_exception() 267 | { 268 | $this->fs->__inexistantMethod__(); 269 | } 270 | 271 | /** 272 | * @test 273 | * @expectedException \Nesk\Rialto\Exceptions\Node\Exception 274 | * @expectedExceptionMessage Object.__inexistantMethod__ is not a function 275 | */ 276 | public function can_catch_errors() 277 | { 278 | $this->fs->tryCatch->__inexistantMethod__(); 279 | } 280 | 281 | /** 282 | * @test 283 | * @expectedException \Nesk\Rialto\Exceptions\Node\FatalException 284 | * @expectedExceptionMessage Object.__inexistantMethod__ is not a function 285 | */ 286 | public function catching_a_node_exception_doesnt_catch_fatal_exceptions() 287 | { 288 | try { 289 | $this->fs->__inexistantMethod__(); 290 | } catch (Node\Exception $exception) { 291 | // 292 | } 293 | } 294 | 295 | /** 296 | * @test 297 | * @dontPopulateProperties fs 298 | */ 299 | public function in_debug_mode_node_exceptions_contain_stack_trace_in_message() 300 | { 301 | $this->fs = new FsWithProcessDelegation(['debug' => true]); 302 | 303 | $regex = '/\n\nError: "Object\.__inexistantMethod__ is not a function"\n\s+at /'; 304 | 305 | try { 306 | $this->fs->tryCatch->__inexistantMethod__(); 307 | } catch (Node\Exception $exception) { 308 | $this->assertRegExp($regex, $exception->getMessage()); 309 | } 310 | 311 | try { 312 | $this->fs->__inexistantMethod__(); 313 | } catch (Node\FatalException $exception) { 314 | $this->assertRegExp($regex, $exception->getMessage()); 315 | } 316 | } 317 | 318 | /** @test */ 319 | public function node_current_working_directory_is_the_same_as_php() 320 | { 321 | $result = $this->fs->accessSync('tests/resources/file'); 322 | 323 | $this->assertNull($result); 324 | } 325 | 326 | /** 327 | * @test 328 | * @expectedException \Symfony\Component\Process\Exception\ProcessFailedException 329 | * @expectedExceptionMessageRegExp /Error Output:\n=+\n.*__inexistant_process__.*not found/ 330 | */ 331 | public function executable_path_option_changes_the_process_prefix() 332 | { 333 | new FsWithProcessDelegation(['executable_path' => '__inexistant_process__']); 334 | } 335 | 336 | /** 337 | * @test 338 | * @dontPopulateProperties fs 339 | */ 340 | public function idle_timeout_option_closes_node_once_timer_is_reached() 341 | { 342 | $this->fs = new FsWithProcessDelegation(['idle_timeout' => 0.5]); 343 | 344 | $this->fs->constants; 345 | 346 | sleep(1); 347 | 348 | $this->expectException(\Nesk\Rialto\Exceptions\IdleTimeoutException::class); 349 | $this->expectExceptionMessageRegExp('/^The idle timeout \(0\.500 seconds\) has been exceeded/'); 350 | 351 | $this->fs->constants; 352 | } 353 | 354 | /** 355 | * @test 356 | * @dontPopulateProperties fs 357 | * @expectedException \Nesk\Rialto\Exceptions\ReadSocketTimeoutException 358 | * @expectedExceptionMessageRegExp /^The timeout \(0\.010 seconds\) has been exceeded/ 359 | */ 360 | public function read_timeout_option_throws_an_exception_on_long_actions() 361 | { 362 | $this->fs = new FsWithProcessDelegation(['read_timeout' => 0.01]); 363 | 364 | $this->fs->wait(20); 365 | } 366 | 367 | /** 368 | * @test 369 | * @group logs 370 | * @dontPopulateProperties fs 371 | */ 372 | public function forbidden_options_are_removed() 373 | { 374 | $this->fs = new FsWithProcessDelegation([ 375 | 'logger' => $this->loggerMock( 376 | $this->at(0), 377 | $this->isLogLevel(), 378 | 'Applying options...', 379 | $this->callback(function ($context) { 380 | $this->assertArrayHasKey('read_timeout', $context['options']); 381 | $this->assertArrayNotHasKey('stop_timeout', $context['options']); 382 | $this->assertArrayNotHasKey('foo', $context['options']); 383 | 384 | return true; 385 | }) 386 | ), 387 | 388 | 'read_timeout' => 5, 389 | 'stop_timeout' => 0, 390 | 'foo' => 'bar', 391 | ]); 392 | } 393 | 394 | /** 395 | * @test 396 | * @dontPopulateProperties fs 397 | */ 398 | public function connection_delegate_receives_options() 399 | { 400 | $this->fs = new FsWithProcessDelegation([ 401 | 'log_node_console' => true, 402 | 'new_option' => false, 403 | ]); 404 | 405 | $this->assertNull($this->fs->getOption('read_timeout')); // Assert this option is stripped by the supervisor 406 | $this->assertTrue($this->fs->getOption('log_node_console')); 407 | $this->assertFalse($this->fs->getOption('new_option')); 408 | } 409 | 410 | /** 411 | * @test 412 | * @dontPopulateProperties fs 413 | */ 414 | public function process_status_is_tracked() 415 | { 416 | if (PHP_OS === 'WINNT') { 417 | $this->markTestSkipped('This test is not supported on Windows.'); 418 | } 419 | 420 | if ((new Process(['which', 'pgrep']))->run() !== 0) { 421 | $this->markTestSkipped('The "pgrep" command is not available.'); 422 | } 423 | 424 | $oldPids = $this->getPidsForProcessName('node'); 425 | $this->fs = new FsWithProcessDelegation; 426 | $newPids = $this->getPidsForProcessName('node'); 427 | 428 | $newNodeProcesses = array_values(array_diff($newPids, $oldPids)); 429 | $newNodeProcessesCount = count($newNodeProcesses); 430 | $this->assertCount( 431 | 1, 432 | $newNodeProcesses, 433 | "One Node process should have been created instead of $newNodeProcessesCount. Try running again." 434 | ); 435 | 436 | $processKilled = posix_kill($newNodeProcesses[0], SIGKILL); 437 | $this->assertTrue($processKilled); 438 | 439 | \usleep(10000); # To make sure the process had enough time to be killed. 440 | 441 | $this->expectException(\Nesk\Rialto\Exceptions\ProcessUnexpectedlyTerminatedException::class); 442 | $this->expectExceptionMessage('The process has been unexpectedly terminated.'); 443 | 444 | $this->fs->foo; 445 | } 446 | 447 | /** @test */ 448 | public function process_is_properly_shutdown_when_there_are_no_more_references() 449 | { 450 | if (!class_exists('WeakRef')) { 451 | $this->markTestSkipped( 452 | 'This test requires weak references (unavailable for PHP 7.3): http://php.net/weakref/' 453 | ); 454 | } 455 | 456 | $ref = new \WeakRef($this->fs->getProcessSupervisor()); 457 | 458 | $resource = $this->fs->readFileSync($this->filePath); 459 | 460 | $this->assertInstanceOf(BasicResource::class, $resource); 461 | 462 | $this->fs = null; 463 | unset($resource); 464 | 465 | $this->assertFalse($ref->valid()); 466 | } 467 | 468 | /** 469 | * @test 470 | * @group logs 471 | * @dontPopulateProperties fs 472 | */ 473 | public function logger_is_used_when_provided() 474 | { 475 | $this->fs = new FsWithProcessDelegation([ 476 | 'logger' => $this->loggerMock( 477 | $this->atLeastOnce(), 478 | $this->isLogLevel(), 479 | $this->isType('string') 480 | ), 481 | ]); 482 | } 483 | 484 | /** 485 | * @test 486 | * @group logs 487 | * @dontPopulateProperties fs 488 | */ 489 | public function node_console_calls_are_logged() 490 | { 491 | $setups = [ 492 | [false, 'Received data on stdout:'], 493 | [true, 'Received a Node log:'], 494 | ]; 495 | 496 | foreach ($setups as [$logNodeConsole, $startsWith]) { 497 | $this->fs = new FsWithProcessDelegation([ 498 | 'log_node_console' => $logNodeConsole, 499 | 'logger' => $this->loggerMock( 500 | $this->at(5), 501 | $this->isLogLevel(), 502 | $this->stringStartsWith($startsWith) 503 | ), 504 | ]); 505 | 506 | $this->fs->runCallback(JsFunction::createWithBody("console.log('Hello World!')")); 507 | } 508 | } 509 | 510 | /** 511 | * @test 512 | * @group logs 513 | * @dontPopulateProperties fs 514 | */ 515 | public function delayed_node_console_calls_and_data_on_standard_streams_are_logged() 516 | { 517 | $this->fs = new FsWithProcessDelegation([ 518 | 'log_node_console' => true, 519 | 'logger' => $this->loggerMock([ 520 | [$this->at(6), $this->isLogLevel(), $this->stringStartsWith('Received data on stdout:')], 521 | [$this->at(7), $this->isLogLevel(), $this->stringStartsWith('Received a Node log:')], 522 | ]), 523 | ]); 524 | 525 | $this->fs->runCallback(JsFunction::createWithBody(" 526 | setTimeout(() => { 527 | process.stdout.write('Hello Stdout!'); 528 | console.log('Hello Console!'); 529 | }); 530 | ")); 531 | 532 | usleep(10000); // 10ms, to be sure the delayed instructions just above are executed. 533 | $this->fs = null; 534 | } 535 | } 536 | --------------------------------------------------------------------------------