├── 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 | [](http://php.net/)
10 | [](https://packagist.org/packages/nesk/rialto)
11 | [](https://nodejs.org/)
12 | [](https://www.npmjs.com/package/@nesk/rialto)
13 | [](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 |
--------------------------------------------------------------------------------