From 8dee91958e833769ce04167b57aebd376554fdc0 Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Mon, 8 Aug 2022 16:29:35 +0100 Subject: [PATCH 01/18] Initial readme --- README.md | 348 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..126f2b9 --- /dev/null +++ b/README.md @@ -0,0 +1,348 @@ +# procedure.js +The simple RPC framework for Node.js. + + + + + +[![Twitter Follow](https://img.shields.io/twitter/follow/toebean__.svg?style=social)](https://twitter.com/toebean__ "Follow @toebean__ on Twitter") [![PayPal donation button](https://img.shields.io/badge/paypal-donate-yellow.svg)](https://paypal.me/tobeyblaber "Donate to procedure.js with PayPal") + + +## Description +A lightweight alternative to the boilerplate-heavy gRPC, or spinning up a HTTP server through which to run a REST API. Procedure bridges the gap between independent applications and services with as little code as possible, so that you can just focus on building your app! + +```js +// my-app/index.js + +// a simple procedure which returns the square of a given number +const procedure = new Procedure('tcp://*:5000', (n) => n ** 2).bind(); +``` + +```js +// some-other-app/index.js + +// calling the procedure to find the square of 8 +let squared = await Procedure.call('tcp://localhost:5000', 8); +console.log(squared); //outputs 64 +``` + +Procedure allows you to define procedures which can be called over [TCP](#tcp-intrainter-network-over-tcpip), [WebSockets](#ws-intrainter-network-over-websockets), [IPC](#ipc-interprocess), and [across threads or modules in the same process](#inproc-intraprocess). Use whichever [transport](#transports-more-than-just-tcp) is most appropriate for your use case, or mix-and-match! + + + +## Table of contents +- [procedure.js](#procedurejs) + - [Description](#description) + - [Table of contents](#table-of-contents) + - [Usage](#usage) + - [`async`/`await`](#asyncawait) + - [Parameters and return types](#parameters-and-return-types) + - [A note about `null` and `undefined`](#a-note-about-null-and-undefined) + - [Optional parameter support](#optional-parameter-support) + - [`null` and `undefined` properties](#null-and-undefined-properties) + - [Pass by reference?](#pass-by-reference) + - [Error handling](#error-handling) + - [Transports: More than just TCP!](#transports-more-than-just-tcp) + - [INPROC: intraprocess](#inproc-intraprocess) + - [IPC: intra/interprocess](#ipc-intrainterprocess) + - [TCP: intra/inter-network over TCP/IP](#tcp-intrainter-network-over-tcpip) + - [WS: intra/inter-network over WebSockets](#ws-intrainter-network-over-websockets) + - [Handling breaking changes to your procedures](#handling-breaking-changes-to-your-procedures) + - [Language implementations](#language-implementations) + + + +## Usage +With Procedure, setting up your function to be called from another process (whether remote or local) is remarkably simple: +```js +const procedure = new Procedure('tcp://*:5000', (n) => n ** 2); +procedure.bind(); +``` + +And calling it is just as easy: +```js +let x = 8; +let xSquared = await Procedure.call('tcp://localhost:5000', x); +console.log(xSquared); //outputs 64 +console.log(typeof xSquared); // outputs 'number' +``` + +### `async`/`await` +Asynchronous functions are fully supported: +```js +const procedure = new Procedure('tcp://127.0.0.1:8888', async () => { + const response = await fetch('https://catfact.ninja/fact'); + if (response.ok) { + return (await response.json()).fact; + } else { + throw new Error(`${response.status}: ${response.statusText}`); + } +}); +``` + +### Parameters and return types +Parameter and return types can be anything supported by the [msgpack](https://github.com/msgpack/msgpack-javascript) serialization format, which covers much of JavaScript by default, and you can handle unsupported types with [Extension Types](https://github.com/msgpack/msgpack-javascript#extension-types). We generally recommend sticking to [PODs](https://en.wikipedia.org/wiki/Passive_data_structure "plain old data objects"). It is possible to pass more complex types around in many cases - but note that they will be passed by value, [not by reference](#pass-by-reference). + +Procedure supports a single parameter, or none. We considered supporting multiple parameters, but this increases the complexity of the design and leads to potentially inconsistent APIs for different language implementations, while multiple parameters can easily be simulated through the use of [PODs](https://en.wikipedia.org/wiki/Passive_data_structure "plain old data objects") (e.g. object literals, property bags) or arrays in virtually any programming language. + +If you have existing functions with multiple parameters which you want to expose as procedures, wrapping them is trivial: +```js +function myFunction(a, b, c) { + return a + b * c; +} + +const procedure = new Procedure('tcp://*:30666', params => myFunction(...params)).bind(); +``` +Which can then be called like so: +```js +Procedure.call('tcp://localhost:30666', [1, 2, 3]); +``` +For functions where you have optional parameters, it might make more sense to use object literals/property bags instead of arrays. + +Functions which accept multiple parameters where only the first is required (or none) will work as is, but you will only be able to pass the first parameter via `Procedure.call`. + +#### A note about `null` and `undefined` +##### Optional parameter support +In the JavaScript implementation of msgpack, [`undefined` is mapped to `null`](https://github.com/msgpack/msgpack-javascript#messagepack-mapping-table). This means that all `undefined` values will be decoded as `null`, and there is no way to differentiate between the two. + +This causes an issue for procedures which accept an optional parameter, as in most implementations of optional parameters in JavaScript, only `undefined` is coerced into a default value. + +It also means that procedures with no return value will evaluate to `null` instead of `undefined`, which could cause unexpected behavior if you were to pass the return value of a procedure into another function as an optional parameter. + +To handle these inconsistencies, we coerce a msgpack decoded `null` to `undefined`. This does not affect the properties of objects - they will still be evaluated as `null` when they were either `null` or `undefined`. + +To disable this behavior, you can set `optionalParameterSupport` to `false` for either procedure definitions or calls, or both: +```js +const procedure = new Procedure( + 'tcp://*:54321', + x => { ... }, + { optionalParameterSupport: false } +); +``` + +```js +await Procedure.call('tcp://*:54321', x, { optionalParameterSupport: false }); +``` +Note that disabling at the definition will not affect the return value, and disabling at the call will not affect the input parameter. + +##### `null` and `undefined` properties +For objects, we do not coerce `null` properties to `undefined`. Instead, we leave them as is, but strip any properties with the value of `undefined` from the object prior to transmission, thereby allowing those properties to be evaluated as `undefined` at the other end, while `null` properties remain `null`. + +This operation adds some overhead, and any code that relies on the presence of a property to infer meaning may not work as expected, e.g. `if ('prop' in obj)`. + +To disable this behavior, you can set the `stripUndefinedProperties` option to `false` for either procedure definitions or calls, or both: +```js +const procedure = new Procedure( + 'tcp://*:54321', + x => { + ... + }, + { stripUndefinedProperties: false } +); +``` + +```js +await Procedure.call('tcp://*:54321', x, { stripUndefinedProperties: false }); +``` +Note that disabling at the definition will not affect the return value, and disabling at the call will not affect the input parameter. + +#### Pass by reference? +It is **impossible** to pass by reference with Procedure. All data is encoded and then sent across the wire, similar to what happens when a REST API responds to a request by sending back a JSON string/file. You can parse that JSON into an object and access its data, but you only have a *copy* of the data that lives on the server. + +For example, if you were to implement the following procedure: +```js +const procedure = new Procedure('tcp://*:33333', x => x.foo = "bar"); +``` +And then call it like so: +```js +let obj = { foo: 123 }; +await Procedure.call('tcp://*:33333', obj); +console.log(obj); // outputs '{ foo: 123 }' +``` +The `obj` object would remain unchanged, because the procedure is acting on a *clone* of the object, not the object itself. First, the object is encoded for transmission by msgpack, then sent across the wire by nanomsg, and finally decoded by msgpack at the other end into a brand new object. + +### Error handling +Errors are thrown back to the caller, enabling you to react to and handle them the way you would as if the functions were defined locally: +```js +let x = { foo: 'bar' }; +let xSquared = await Procedure.call('tcp://localhost:5000', x); +// throws `SyntaxError: expected expression, got '**' +``` + +## Transports: More than just TCP! +The examples in this readme all use TCP to demonstrate the most common use case for RPC. However, Procedure is built on top of [nanomsg](https://nanomsg.org/), which means it supports all of the same transports that nanomsg does: + +### INPROC: intraprocess +Call functions between threads or modules of the same process. +- `inproc://foobar` + +### IPC: intra/interprocess +Call functions between different processes on the same host. +- `ipc://foobar.ipc` +- `ipc:///tmp/test.ipc` +- `ipc://MyApp/MyProcedure` + +### TCP: intra/inter-network over TCP/IP +Call functions between processes across TCP with support for both IPv4 addresses and DNS names*. IPv6 support coming soon! +- `tcp://*:80` +- `tcp://192.168.0.5:5600` +- `tcp://localhost:33000`* + +TLS (`tcp+tls://`) is not currently supported. + +_* DNS names are only supported when calling a procedure, not when defining._ + +### WS: intra/inter-network over WebSockets +Call functions between processes across WebSockets over TCP with support for both IPv4 address and DNS names*. IPv6 support coming soon! +- `ws://*` +- `ws://127.0.0.1:8080` +- `ws://example.com`* + +TLS (`wss://`) is not currently supported. + +_* DNS names are only supported when calling a procedure, not when defining._ + +## Handling breaking changes to your procedures +Procedure has no way of knowing what the parameter or return types of the procedure at the other end of the call will be. If you rewrite a procedure to return a different type or to accept a different parameter type, you will only get errors at runtime, not at compile time. + +Therefore, if you are developing procedures for public consumption, be mindful of the fact that **breaking changes on the same endpoint will result in unhappy consumers!** + +If you do need to make breaking changes to a procedure, it is recommended to either: +- implement the breaking changes on a new endpoint, while keeping the original available: + ```js + myFunction(x) { + return isNaN(x); // return boolean indicating whether x is NaN + } + + myFunctionV2(x) { + if (isNaN(x)) { + ... + // do stuff with x when it is NaN + ... + return true; + } + // breaking change, we no longer return a boolean in all cases + } + + const procedure = new Procedure('tcp://*:33000', myFunction).bind(); + const procedureV2 = new Procedure('tcp://*:33001', myFunctionV2).bind(); + ``` + + ```js + const v1Result = await Procedure.call('tcp://localhost:33000'); // returns false + const v2Result = await Procedure.call('tcp://localhost:33001'); // returns undefined + ``` +- use a parameter or property to specify a version modifier, defaulting to the original when unspecified: + ```js + myFunction(x) { + return isNaN(x); // return boolean indicating whether x is NaN + } + + myFunctionV2(x) { + if (isNaN(x)) { + ... + // do stuff with x when it is NaN + ... + return true; + } + // breaking change, we no longer return a boolean in all cases + } + + const procedure = new Procedure('tcp://*:33000', options => { + switch (options?.version) { + case 2: return myFunctionV2(options.x); + default: return myFunction(options.x); + } + }); + procedure.bind(); + ``` + + ```js + const v1Result = await Procedure.call('tcp://localhost:33000'); // returns false + const v2Result = await Procedure.call('tcp://localhost:33000', { version: 2 }); //returns undefined + ``` + + You may prefer to use a [semver](https://www.npmjs.com/package/semver) compatible string for versioning. + + + +## Language implementations +As Procedure is designed around nanomsg and msgpack, it can be implemented in any language that has both a nanomsg binding and a msgpack implementation. + +Presently, the only official implementation of Procedure is procedure.js for Node.js, but a .NET implementation written in C# and a stripped-down browser library for calling procedures via the [WS transport](#ws-intrainter-network-over-websockets) are currently being worked on. + + + +If you would like to contribute a Procedure implementation in another language, please feel free! Create a GitHub repository for the language implementation and open an issue with us once it's ready for review! 💜 From 05aeecebb2242c89831c9257aef7239d79564c83 Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Mon, 8 Aug 2022 16:31:13 +0100 Subject: [PATCH 02/18] Moved nyc config to .nycrc, added keywords --- .nycrc | 15 +++++++++++++++ package.json | 42 ++++++++++++++++++++++++++++-------------- 2 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 .nycrc diff --git a/.nycrc b/.nycrc new file mode 100644 index 0000000..5f27bd9 --- /dev/null +++ b/.nycrc @@ -0,0 +1,15 @@ +{ + "extends": "@istanbuljs/nyc-config-typescript", + "all": true, + "check-coverage": true, + "reporter": [ + "html", + "text", + "text-summary" + ], + "report-dir": "coverage", + "statements": "93", + "branches": "85", + "functions": "100", + "lines": "92" +} diff --git a/package.json b/package.json index 7db6888..2ed47df 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "@toebeann/procedure.js", - "version": "0.2.1", - "description": "A simple RPC framework, built on top of nanomsg and msgpack.", + "version": "0.3.0", + "title": "procedure.js", + "description": "The simple RPC framework for Node.js.", "main": "./dist/index", "types": "./types/index.d.ts", "author": "Tobey Blaber (https://github.com/toebeann)", @@ -49,16 +50,29 @@ "dist/**/*.js", "types/**/*.d.ts" ], - "nyc": { - "extends": "@istanbuljs/nyc-config-typescript", - "all": true, - "check-coverage": true, - "reporter": [ - "html", - "lcov", - "text", - "text-summary" - ], - "report-dir": "coverage" - } + "keywords": [ + "RPC", + "remote", + "procedure", + "call", + "IPC", + "TCP", + "TCP/IP", + "WebSockets", + "WebSocket", + "WS", + "INPROC", + "threads", + "thread", + "process", + "processes", + "intraprocess", + "intra-process", + "interprocess", + "inter-process", + "intranetwork", + "intra-network", + "internetwork", + "inter-network" + ] } From ced6a7bf32a8f781adf710b24784a48cb952a967 Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Mon, 8 Aug 2022 16:33:34 +0100 Subject: [PATCH 03/18] Added new `null` and `undefined` handling options --- src/procedure.ts | 100 ++++++++++++++++++++++++++++++---------------- test/procedure.ts | 6 +++ 2 files changed, 71 insertions(+), 35 deletions(-) diff --git a/src/procedure.ts b/src/procedure.ts index 12ab0ca..764ef6a 100644 --- a/src/procedure.ts +++ b/src/procedure.ts @@ -1,3 +1,6 @@ +// TODO: add and test ipv6 support +// TODO: rework error handling to throw a generic error with status code, and optionally an object containing an error message and relevant data - look at grpc for examples + /// import { Ping, isPing, isErrorLike, cloneError } from './utils'; import AggregateSignal from './aggregate-signal'; @@ -17,11 +20,11 @@ const uuidNamespace = uuidv5(homepage, uuidv5.URL); * Allows you to turn any generic function or callback into a procedure, which remote or local processes can call. * Includes the functionality to ping procedures to check whether they are available. */ -export default class Procedure extends (EventEmitter as { new (): TypedEmitter> }) implements ProcedureOptions { - [key: keyof ProcedureOptions]: ProcedureOptions[keyof ProcedureOptions]; +export default class Procedure extends (EventEmitter as { new (): TypedEmitter> }) implements ProcedureDefinitionOptions { + [key: keyof ProcedureDefinitionOptions]: ProcedureDefinitionOptions[keyof ProcedureDefinitionOptions]; /** The options in use by the procedure, including defaults. */ - protected options: ProcedureOptions; + protected options: ProcedureDefinitionOptions; /** The underlying nanomsg socket used for data transmission. */ protected sockets: Socket[] = []; @@ -41,18 +44,26 @@ export default class Procedure} callback The callback function powering the procedure itself. The callback may be asynchronous. - * @param {Partial} [options={}] An options bag defining how the procedure should be run. Defaults to `{}`. + * @param {Partial} [options={}] An options bag defining how the procedure should be run. Defaults to `{}`. */ - constructor(public readonly endpoint: string, protected callback: Callback, options: Partial = {}) { + constructor(public readonly endpoint: string, protected callback: Callback, options: Partial = {}) { super(); this.options = { ...{ verbose: false, - workers: 1 + workers: 1, + optionalParameterSupport: true, + stripUndefinedProperties: true }, ...options }; @@ -99,14 +110,19 @@ export default class Procedure} A promise which when resolved passes the output value to the promise's `then` handler(s). */ - static async call(endpoint: string, input: Input | null = null, options: Partial = {}): Promise { + static async call(endpoint: string, input?: Nullable, options: Partial = {}): Promise { const socket = createSocket('req'); const opts: ProcedureCallOptions = { - ...{ timeout: 1000, ping: false }, + ...{ + timeout: 1000, + ping: false, + optionalParameterSupport: true, + stripUndefinedProperties: true + }, ...options }; @@ -122,14 +138,17 @@ export default class Procedure>(buffer, opts.extensionCodec); // decode the response if ('error' in response) { throw response.error; - } else if (response.output !== undefined) { - return response.output; + } else if ('output' in response) { + return response.output + ?? (opts.optionalParameterSupport + ? undefined + : response.output); } else { throw new RangeError(`Response is not of valid shape: ${JSON.stringify(response)}`); } @@ -186,10 +205,11 @@ export default class Procedure> { try { - return { output: await this.callback(input) }; + return { + output: await this.callback(input + ?? (this.optionalParameterSupport + ? undefined + : input)) + }; } catch (error) { this.#emitAndLogError('Procedure encountered an error while executing callback', error); return { error }; @@ -241,7 +266,7 @@ export default class Procedure { - const { input, error } = this.#tryDecodeInput(data); + const decoded = this.#tryDecodeInput(data); - if (this.#tryHandlePing(input, socket)) { // input was a ping of valid uuid & pong was successfully sent + if (this.#tryHandlePing(decoded.input, socket)) { // input was a ping of valid uuid & pong was successfully sent if (this.verbose) { console.log(`PONG sent at endpoint ${this.endpoint}`); } } else { - if (input !== undefined) { - this.#emitAndLogData(input as Input); + if ('input' in decoded) { + this.#emitAndLogData(decoded.input as Input); } - const response = input !== undefined - ? await this.#tryGetCallbackResponse(input as Input) - : { error }; + const response = 'input' in decoded + ? await this.#tryGetCallbackResponse(decoded.input as Input) + : decoded; - if (response.output !== undefined && this.verbose) { + if ('output' in response && this.verbose) { console.log(`Generated output data at endpoint: ${this.endpoint}`, response.output); } @@ -361,7 +386,7 @@ export default class Procedure = (input: Input) => Output; +export type Callback = (input: Input) => Output; /** * A response from a procedure call. If the call returned successfully, the response will be of shape `{ output: Output }`, otherwise `{ error: unknown }`. */ -export type Response +export type Response = { output: Output, error?: never, pong?: never } | { output?: never, error: unknown, pong?: never } | { output?: never, error?: never, pong: string }; +export interface ProcedureCommonOptions { + optionalParameterSupport: boolean; + stripUndefinedProperties: boolean; +} + /** * Options for defining a Procedure. */ -export interface ProcedureOptions { +export interface ProcedureDefinitionOptions extends ProcedureCommonOptions { [key: string]: unknown; /** The number of socket workers to spin up for the Procedure. Useful for Procedures which may take a long time to complete. Defaults to `1`. */ workers: number; /** A boolean indicating whether to output errors and events to the console. Defaults to `false`. */ verbose: boolean; /** The msgpack `ExtensionCodec` to use for encoding and decoding messages. Defaults to `undefined`. */ - extensionCodec?: ExtensionCodec; + extensionCodec?: ExtensionCodec | undefined; } /** * Options for calling a Procedure. */ -export interface ProcedureCallOptions { +export interface ProcedureCallOptions extends ProcedureCommonOptions { /** The number of milliseconds after which the Procedure call will automatically be aborted. * Set to `Infinity` or `NaN` to never timeout. * Non-NaN, finite values will be clamped between `0` and `Number.MAX_SAFE_INTEGER`. @@ -438,16 +468,16 @@ export interface ProcedureCallOptions { ping: number | false; /** An optional msgpack `ExtensionCodec` to use for encoding and decoding messages. * Defaults to `undefined`. */ - extensionCodec?: ExtensionCodec; + extensionCodec?: ExtensionCodec | undefined; /** An optional `AbortSignal` which will be used to abort the Procedure call. * Defaults to `undefined`. */ - signal?: AbortSignal; + signal?: AbortSignal | undefined; } /** * A map of the names of events emitted by Procedures and their function signatures. */ -type ProcedureEvents = { +type ProcedureEvents = { data: (data: Input) => void; error: (error: unknown) => void; unbind: () => void; diff --git a/test/procedure.ts b/test/procedure.ts index 3b137be..f208e1d 100644 --- a/test/procedure.ts +++ b/test/procedure.ts @@ -127,6 +127,9 @@ describe('Procedure', () => { })); }); }); + + // TODO: test optionalParameterSupport property + // TODO: test stripUndefinedProperties property }); describe('unbind(): this', () => { @@ -317,6 +320,9 @@ describe('Procedure.call(endpoint: string, input: Input | null, options: Partial afterEach(() => procedure.unbind()); }); + // TODO: test optionalParameterSupport property + // TODO: test stripUndefinedProperties property + // TODO: when callback asynchronous (completes normally, times out, throws error, infinite timeout, abortion signaled during execution, abortion signaled before execution) }); From b22d0964826102713c25680c16370543e899636c Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Mon, 8 Aug 2022 17:00:22 +0100 Subject: [PATCH 04/18] Add MIT license --- LICENSE | 21 +++++++++++++++++++++ package.json | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..66df423 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Tobey Blaber + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json index 2ed47df..311aedc 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "homepage": "https://github.com/toebeann/procedure.js", "repository": "github:toebeann/procedure.js", "funding": "https://paypal.me/tobeyblaber", - "license": "UNLICENSED", + "license": "MIT", "private": true, "scripts": { "build": "tsc", From 1d5b6350c49ca0581f93b1e59f27c59b2cad0967 Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Mon, 8 Aug 2022 17:03:27 +0100 Subject: [PATCH 05/18] Add MIT license notice to readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 126f2b9..c3f8935 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ With [implementations in multiple languages](#language-implementations), applica - [WS: intra/inter-network over WebSockets](#ws-intrainter-network-over-websockets) - [Handling breaking changes to your procedures](#handling-breaking-changes-to-your-procedures) - [Language implementations](#language-implementations) + - [License](#license) If you would like to contribute a Procedure implementation in another language, please feel free! Create a GitHub repository for the language implementation and open an issue with us once it's ready for review! 💜 + +## License +procedure.js is licensed under [MIT](LICENSE). From a522e02d57aa724aca44da635c3f905c8ff49f26 Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Mon, 8 Aug 2022 18:31:47 +0100 Subject: [PATCH 06/18] Remove some redundant logic --- .nycrc | 9 +++++---- src/procedure.ts | 18 ++++++++---------- src/timeout-signal.ts | 2 +- test/procedure.ts | 9 ++++----- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/.nycrc b/.nycrc index 5f27bd9..66ec151 100644 --- a/.nycrc +++ b/.nycrc @@ -8,8 +8,9 @@ "text-summary" ], "report-dir": "coverage", - "statements": "93", - "branches": "85", - "functions": "100", - "lines": "92" + "statements": "91", + "branches": "82", + "functions": "90", + "lines": "90", + "include": "src" } diff --git a/src/procedure.ts b/src/procedure.ts index 764ef6a..37b388a 100644 --- a/src/procedure.ts +++ b/src/procedure.ts @@ -17,7 +17,7 @@ const uuidNamespace = uuidv5(homepage, uuidv5.URL); /** * A simple abstraction of a procedure (the P in RPC). - * Allows you to turn any generic function or callback into a procedure, which remote or local processes can call. + * Allows you to turn any generic function or callback into a procedure, which can be called via the transport specified. * Includes the functionality to ping procedures to check whether they are available. */ export default class Procedure extends (EventEmitter as { new (): TypedEmitter> }) implements ProcedureDefinitionOptions { @@ -37,7 +37,7 @@ export default class Procedure { }); }); + // TODO: test workers, extensionCodic, optionalParameterSupport & stripUndefinedProperties accessors + describe('bind(): this', () => { let instance: Procedure; beforeEach(() => instance = new Procedure('', x => x)); @@ -127,9 +129,6 @@ describe('Procedure', () => { })); }); }); - - // TODO: test optionalParameterSupport property - // TODO: test stripUndefinedProperties property }); describe('unbind(): this', () => { @@ -320,8 +319,8 @@ describe('Procedure.call(endpoint: string, input: Input | null, options: Partial afterEach(() => procedure.unbind()); }); - // TODO: test optionalParameterSupport property - // TODO: test stripUndefinedProperties property + // TODO: test optionalParameterSupport option works as intended + // TODO: test stripUndefinedProperties option works as intended // TODO: when callback asynchronous (completes normally, times out, throws error, infinite timeout, abortion signaled during execution, abortion signaled before execution) }); From 0d04f933d096db8846894494038eda678e0c326a Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Mon, 8 Aug 2022 18:46:28 +0100 Subject: [PATCH 07/18] Add Procedure as named export, not just default --- src/index.ts | 2 +- test/procedure.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index e2b7be1..ff6562f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ export * from './procedure'; -export { default } from './procedure'; +export { default, default as Procedure } from './procedure'; diff --git a/test/procedure.ts b/test/procedure.ts index b6c6f98..08a0761 100644 --- a/test/procedure.ts +++ b/test/procedure.ts @@ -3,6 +3,7 @@ import chai, { expect } from 'chai' import spies from 'chai-spies' import chaiAsPromised from 'chai-as-promised' import Procedure, { Callback } from '../src' +import { Procedure as namedImport } from '../src'; import { ExtensionCodec } from '@msgpack/msgpack' chai.use(spies); @@ -166,6 +167,10 @@ describe('Procedure', () => { }); }); }); + + describe('named import', () => { + describe('namedImport instance', () => it('should be: instanceof Procedure', () => expect(new namedImport('', () => false)).to.be.instanceOf(Procedure))); + }); }); describe('Procedure.call(endpoint: string, input: Input | null, options: Partial): Promise', () => { From 4ba973bdc95480c7be32e877113443a7c4441ee7 Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Mon, 8 Aug 2022 19:27:12 +0100 Subject: [PATCH 08/18] Specify `endpoint` at `bind`, not `constructor` --- README.md | 31 ++++++++++++------------- src/index.ts | 2 +- src/procedure.ts | 41 +++++++++++++++++++++------------ test/procedure.ts | 58 ++++++++++++++++++++++------------------------- 4 files changed, 69 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index c3f8935..0d0b778 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A lightweight alternative to the boilerplate-heavy gRPC, or spinning up a HTTP s // my-app/index.js // a simple procedure which returns the square of a given number -const procedure = new Procedure('tcp://*:5000', (n) => n ** 2).bind(); +const procedure = new Procedure((n) => n ** 2).bind('tcp://*:5000'); ``` ```js @@ -65,8 +65,8 @@ With [implementations in multiple languages](#language-implementations), applica ## Usage With Procedure, setting up your function to be called from another process (whether remote or local) is remarkably simple: ```js -const procedure = new Procedure('tcp://*:5000', (n) => n ** 2); -procedure.bind(); +const procedure = new Procedure((n) => n ** 2); +procedure.bind('tcp://*:5000'); ``` And calling it is just as easy: @@ -80,14 +80,14 @@ console.log(typeof xSquared); // outputs 'number' ### `async`/`await` Asynchronous functions are fully supported: ```js -const procedure = new Procedure('tcp://127.0.0.1:8888', async () => { +const procedure = new Procedure(async () => { const response = await fetch('https://catfact.ninja/fact'); if (response.ok) { return (await response.json()).fact; } else { throw new Error(`${response.status}: ${response.statusText}`); } -}); +}).bind('tcp://127.0.0.1:8888'); ``` ### Parameters and return types @@ -101,7 +101,8 @@ function myFunction(a, b, c) { return a + b * c; } -const procedure = new Procedure('tcp://*:30666', params => myFunction(...params)).bind(); +const procedure = new Procedure(params => myFunction(...params)) + .bind('tcp://*:30666'); ``` Which can then be called like so: ```js @@ -123,11 +124,7 @@ To handle these inconsistencies, we coerce a msgpack decoded `null` to `undefine To disable this behavior, you can set `optionalParameterSupport` to `false` for either procedure definitions or calls, or both: ```js -const procedure = new Procedure( - 'tcp://*:54321', - x => { ... }, - { optionalParameterSupport: false } -); +const procedure = new Procedure(x => { ... }, { optionalParameterSupport: false }).bind('tcp://*:54321'); ``` ```js @@ -161,7 +158,8 @@ It is **impossible** to pass by reference with Procedure. All data is encoded an For example, if you were to implement the following procedure: ```js -const procedure = new Procedure('tcp://*:33333', x => x.foo = "bar"); +const procedure = new Procedure(x => x.foo = 'bar') + .bind('tcp://*:33333'); ``` And then call it like so: ```js @@ -234,8 +232,8 @@ If you do need to make breaking changes to a procedure, it is recommended to eit // breaking change, we no longer return a boolean in all cases } - const procedure = new Procedure('tcp://*:33000', myFunction).bind(); - const procedureV2 = new Procedure('tcp://*:33001', myFunctionV2).bind(); + const procedure = new Procedure(myFunction).bind('tcp://*:33000'); + const procedureV2 = new Procedure(myFunctionV2).bind('tcp://*:33001'); ``` ```js @@ -258,13 +256,12 @@ If you do need to make breaking changes to a procedure, it is recommended to eit // breaking change, we no longer return a boolean in all cases } - const procedure = new Procedure('tcp://*:33000', options => { + const procedure = new Procedure(options => { switch (options?.version) { case 2: return myFunctionV2(options.x); default: return myFunction(options.x); } - }); - procedure.bind(); + }).bind('tcp://*:33000'); ``` ```js diff --git a/src/index.ts b/src/index.ts index ff6562f..e2b7be1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ export * from './procedure'; -export { default, default as Procedure } from './procedure'; +export { default } from './procedure'; diff --git a/src/procedure.ts b/src/procedure.ts index 37b388a..2cdbae0 100644 --- a/src/procedure.ts +++ b/src/procedure.ts @@ -23,13 +23,20 @@ const uuidNamespace = uuidv5(homepage, uuidv5.URL); export default class Procedure extends (EventEmitter as { new (): TypedEmitter> }) implements ProcedureDefinitionOptions { [key: keyof ProcedureDefinitionOptions]: ProcedureDefinitionOptions[keyof ProcedureDefinitionOptions]; + #endpoint?: string; + /** The endpoint at which the procedure, when bound, can be called. */ + get endpoint() { return this.#endpoint; } + protected set endpoint(value) { this.#endpoint = value; } + /** The options in use by the procedure, including defaults. */ protected options: ProcedureDefinitionOptions; /** The underlying nanomsg socket used for data transmission. */ protected sockets: Socket[] = []; + #uuid?: string; /** A v5 uuid generated for this endpoint, used for checking whether a Procedure is available and ready to respond to requests. */ - protected readonly uuid: string; + protected get uuid() { return this.#uuid; } + protected set uuid(value) { this.#uuid = value; } get verbose() { return this.options.verbose; } set verbose(value) { this.options.verbose = value; } @@ -52,11 +59,10 @@ export default class Procedure} callback The callback function powering the procedure itself. The callback may be asynchronous. * @param {Partial} [options={}] An options bag defining how the procedure should be run. Defaults to `{}`. */ - constructor(public readonly endpoint: string, protected callback: Callback, options: Partial = {}) { + constructor(protected callback: Callback, options: Partial = {}) { super(); this.options = { ...{ @@ -67,24 +73,31 @@ export default class Procedure this.#onRepSocketData(data, socket)) - .on('error', (error: unknown) => this.#onRepSocketError(error)) - .once('close', () => this.#onRepSocketClose()) - .bind(this.endpoint); // bind the socket to the endpoint + this.endpoint = endpoint ?? this.endpoint; + + if (typeof this.endpoint === 'string') { + this.uuid = uuidv5(this.endpoint, uuidNamespace); + for (let i = 0; i < this.workers; i++) { + const socket = this.sockets[this.sockets.push(createSocket('rep')) - 1]; + socket + .on('data', (data: Buffer) => this.#onRepSocketData(data, socket)) + .on('error', (error: unknown) => this.#onRepSocketError(error)) + .once('close', () => this.#onRepSocketClose()) + .bind(this.endpoint); // bind the socket to the endpoint + } } + return this; } @@ -331,7 +344,7 @@ export default class Procedurethis.endpoint, object.ping) }), socket); } else { return false; } diff --git a/test/procedure.ts b/test/procedure.ts index 08a0761..fd15651 100644 --- a/test/procedure.ts +++ b/test/procedure.ts @@ -3,7 +3,6 @@ import chai, { expect } from 'chai' import spies from 'chai-spies' import chaiAsPromised from 'chai-as-promised' import Procedure, { Callback } from '../src' -import { Procedure as namedImport } from '../src'; import { ExtensionCodec } from '@msgpack/msgpack' chai.use(spies); @@ -14,59 +13,59 @@ describe('Procedure', () => { let instance: Procedure; context('when options.verbose: true', () => { - beforeEach(() => instance = new Procedure('', x => x, { verbose: true })); + beforeEach(() => instance = new Procedure(x => x, { verbose: true })); describe('verbose', () => it('should be: true', () => expect(instance.verbose).to.be.true)); }); context('when options.verbose is false', () => { - beforeEach(() => instance = new Procedure('', x => x, { verbose: false })); + beforeEach(() => instance = new Procedure(x => x, { verbose: false })); describe('verbose', () => it('should be: false', () => expect(instance.verbose).to.be.false)); }); context('when options.verbose: undefined', () => { - beforeEach(() => instance = new Procedure('', x => x)); + beforeEach(() => instance = new Procedure(x => x)); describe('verbose', () => it('should be: false', () => expect(instance.verbose).to.be.false)); }); context('when options.workers: undefined', () => { - beforeEach(() => instance = new Procedure('', x => x)); + beforeEach(() => instance = new Procedure(x => x)); describe('workers', () => it('should be: 1', () => expect(instance.workers).to.equal(1))); }); context('when options.workers: NaN', () => { - beforeEach(() => instance = new Procedure('', x => x, { workers: NaN })); + beforeEach(() => instance = new Procedure(x => x, { workers: NaN })); describe('workers', () => it('should be: 1', () => expect(instance.workers).to.equal(1))); }); context('when options.workers: Infinity', () => { - beforeEach(() => instance = new Procedure('', x => x, { workers: Infinity })); + beforeEach(() => instance = new Procedure(x => x, { workers: Infinity })); describe('workers', () => it('should be: 1', () => expect(instance.workers).to.equal(1))); }); context('when options.workers: < 1', () => { - beforeEach(() => instance = new Procedure('', x => x, { workers: 0.8 })); + beforeEach(() => instance = new Procedure(x => x, { workers: 0.8 })); describe('workers', () => it('should be: 1', () => expect(instance.workers).to.equal(1))); }); context('when options.workers: 10', () => { - beforeEach(() => instance = new Procedure('', x => x, { workers: 10 })); + beforeEach(() => instance = new Procedure(x => x, { workers: 10 })); describe('workers', () => it('should be: 10', () => expect(instance.workers).to.equal(10))); }); context('when options.extensionCodec: undefined', () => { - beforeEach(() => instance = new Procedure('', x => x)); + beforeEach(() => instance = new Procedure(x => x)); describe('extensionCodec', () => it('should be: undefined', () => expect(instance.extensionCodec).to.be.undefined)); }) context('when options.extensionCodec: instanceof ExtensionCodec', () => { - beforeEach(() => instance = new Procedure('', x => x, { extensionCodec: new ExtensionCodec() })); + beforeEach(() => instance = new Procedure(x => x, { extensionCodec: new ExtensionCodec() })); describe('extensionCodec', () => it('should be: instanceof ExtensionCodec', () => expect(instance.extensionCodec).to.be.instanceof(ExtensionCodec))); }); }); describe('set verbose(value: boolean)', () => { let instance: Procedure; - beforeEach(() => instance = new Procedure('', x => x)); + beforeEach(() => instance = new Procedure(x => x)); context('when value: true', () => { beforeEach(() => instance.verbose = true); @@ -83,16 +82,17 @@ describe('Procedure', () => { describe('bind(): this', () => { let instance: Procedure; - beforeEach(() => instance = new Procedure('', x => x)); + beforeEach(() => instance = new Procedure(x => x)); afterEach(() => { instance.unbind().removeAllListeners() }); it('should return: this', () => expect(instance.bind()).to.equal(instance)); context('when endpoint: \'\'', () => { - beforeEach(() => instance = new Procedure('', x => x)); + beforeEach(() => instance = new Procedure(x => x)); + describe('instance', () => it('should emit: \'error\'', () => { const error = chai.spy((error: unknown) => { expect(error).to.be.instanceof(Error) }); - instance.on('error', error).bind(); + instance.on('error', error).bind(''); expect(error).to.have.been.called.once; })); @@ -103,7 +103,7 @@ describe('Procedure', () => { sandbox.on(console, 'error', () => { return }) }); describe('instance', () => it('should call console.error', () => { - instance.bind(); + instance.bind(''); expect(console.error).to.have.been.called.once; })); afterEach(() => { @@ -114,15 +114,15 @@ describe('Procedure', () => { }); context('when endpoint: \'ipc://Procedure.ipc\'', () => { - beforeEach(() => instance = new Procedure('ipc://Procedure.ipc', x => x)); + beforeEach(() => instance = new Procedure(x => x)); describe('instance', () => it('should not emit: \'error\'', () => { const error = chai.spy(() => { return }); - instance.on('error', error).bind(); + instance.on('error', error).bind('ipc://Procedure.ipc'); expect(error).to.not.have.been.called(); })); context('when already bound', () => { - beforeEach(() => instance.bind()); + beforeEach(() => instance.bind('ipc://Procedure.ipc')); describe('instance', () => it('should emit: \'unbind\'', () => { const unbind = chai.spy(() => { return }); instance.on('unbind', unbind).bind(); @@ -134,14 +134,14 @@ describe('Procedure', () => { describe('unbind(): this', () => { let instance: Procedure; - beforeEach(() => instance = new Procedure('', x => x)); + beforeEach(() => instance = new Procedure(x => x)); it('should return: this', () => expect(instance.unbind()).to.equal(instance)); - context('when endpoint: \'ipc://Procedure.ipc\' and instance is bound', () => { + context('when instance bound to endpoint: \'ipc://Procedure.ipc\'', () => { beforeEach(() => { - instance = new Procedure('ipc://Procedure.ipc', x => x); - instance.bind(); + instance = new Procedure(x => x); + instance.bind('ipc://Procedure.ipc'); }); describe('instance', () => it('should emit: \'unbind\'', () => { const unbind = chai.spy(() => { return }); @@ -167,10 +167,6 @@ describe('Procedure', () => { }); }); }); - - describe('named import', () => { - describe('namedImport instance', () => it('should be: instanceof Procedure', () => expect(new namedImport('', () => false)).to.be.instanceOf(Procedure))); - }); }); describe('Procedure.call(endpoint: string, input: Input | null, options: Partial): Promise', () => { @@ -193,8 +189,8 @@ describe('Procedure.call(endpoint: string, input: Input | null, options: Partial }); spy = chai.spy(func); procedureEndpoint = 'ipc://Procedure/Add.ipc'; - procedure = new Procedure(procedureEndpoint, spy, { workers: 3 }); - procedure.bind(); + procedure = new Procedure(spy, { workers: 3 }); + procedure.bind(procedureEndpoint); }); context('when endpoint: correct', () => { @@ -349,8 +345,8 @@ describe('Procedure.ping(endpoint: string, timeout: number | undefined = 100, si }); spy = chai.spy(func); procedureEndpoint = 'ipc://Procedure/Add.ipc'; - procedure = new Procedure(procedureEndpoint, spy, { workers: 3 }); - procedure.bind(); + procedure = new Procedure(spy, { workers: 3 }); + procedure.bind(procedureEndpoint); }); context('when endpoint: correct', () => { From 71591676ab511337ac36567a8153e84344e4311b Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Mon, 8 Aug 2022 19:35:09 +0100 Subject: [PATCH 09/18] Update rreadme to reflect api changes --- README.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0d0b778..dd68430 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ With [implementations in multiple languages](#language-implementations), applica ## Usage With Procedure, setting up your function to be called from another process (whether remote or local) is remarkably simple: ```js -const procedure = new Procedure((n) => n ** 2); +const procedure = new Procedure((n) => n ** 2) procedure.bind('tcp://*:5000'); ``` @@ -87,7 +87,8 @@ const procedure = new Procedure(async () => { } else { throw new Error(`${response.status}: ${response.statusText}`); } -}).bind('tcp://127.0.0.1:8888'); +}); +procedure.bind('tcp://127.0.0.1:8888'); ``` ### Parameters and return types @@ -124,7 +125,8 @@ To handle these inconsistencies, we coerce a msgpack decoded `null` to `undefine To disable this behavior, you can set `optionalParameterSupport` to `false` for either procedure definitions or calls, or both: ```js -const procedure = new Procedure(x => { ... }, { optionalParameterSupport: false }).bind('tcp://*:54321'); +const procedure = new Procedure(x => { ... }, { optionalParameterSupport: false }) + .bind('tcp://*:54321'); ``` ```js @@ -139,13 +141,8 @@ This operation adds some overhead, and any code that relies on the presence of a To disable this behavior, you can set the `stripUndefinedProperties` option to `false` for either procedure definitions or calls, or both: ```js -const procedure = new Procedure( - 'tcp://*:54321', - x => { - ... - }, - { stripUndefinedProperties: false } -); +const procedure = new Procedure(x => { ... }, { stripUndefinedProperties: false } + .bind('tcp://*:54321'); ``` ```js @@ -261,7 +258,8 @@ If you do need to make breaking changes to a procedure, it is recommended to eit case 2: return myFunctionV2(options.x); default: return myFunction(options.x); } - }).bind('tcp://*:33000'); + }); + procedure.bind('tcp://*:33000'); ``` ```js From 59e579ec9bfd600001a127c82ddcf06db4cd1b47 Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Mon, 8 Aug 2022 19:38:26 +0100 Subject: [PATCH 10/18] JSDoc updates --- src/procedure.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/procedure.ts b/src/procedure.ts index 2cdbae0..daa8b3d 100644 --- a/src/procedure.ts +++ b/src/procedure.ts @@ -58,8 +58,8 @@ export default class Procedure} callback The callback function powering the procedure itself. The callback may be asynchronous. + * Initializes a new Procedure. + * @param {Callback} callback The underlying callback function powering the procedure itself. The callback may be asynchronous. * @param {Partial} [options={}] An options bag defining how the procedure should be run. Defaults to `{}`. */ constructor(protected callback: Callback, options: Partial = {}) { @@ -77,9 +77,9 @@ export default class Procedure Date: Mon, 8 Aug 2022 20:17:23 +0100 Subject: [PATCH 11/18] Update bind() doc --- src/procedure.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/procedure.ts b/src/procedure.ts index daa8b3d..9adfba3 100644 --- a/src/procedure.ts +++ b/src/procedure.ts @@ -79,7 +79,7 @@ export default class Procedure Date: Mon, 8 Aug 2022 20:18:13 +0100 Subject: [PATCH 12/18] Add `typedoc` devDependency --- package-lock.json | 169 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 3 +- 2 files changed, 168 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 02d6b7b..9b5f130 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { "name": "@toebeann/procedure.js", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@toebeann/procedure.js", - "version": "0.2.0", - "license": "UNLICENSED", + "version": "0.3.0", + "license": "MIT", "dependencies": { "@msgpack/msgpack": "^2.7.2", "nanomsg": "^4.2.0", @@ -35,6 +35,7 @@ "source-map-support": "^0.5.21", "ts-node": "^10.9.1", "typed-emitter": "^2.1.0", + "typedoc": "^0.23.10", "typescript": "^4.7.4" }, "funding": { @@ -2506,6 +2507,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.1.0.tgz", + "integrity": "sha512-DRf0QjnNeCUds3xTjKlQQ3DpJD51GvDjJfnxUVWg6PZTo2otSm+slzNAxU/35hF8/oJIKoG9slq30JYOsF2azg==", + "dev": true + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2583,6 +2590,12 @@ "node": ">=10" } }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -2613,6 +2626,18 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "node_modules/marked": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.0.18.tgz", + "integrity": "sha512-wbLDJ7Zh0sqA0Vdg6aqlbT+yPxqLblpAZh1mK2+AO2twQkPywvvqQNfEPVwSSRjZ7dZcdeVBIAgiO7MMp3Dszw==", + "dev": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3493,6 +3518,17 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.10.1.tgz", + "integrity": "sha512-VsY7QJVzU51j5o1+DguUd+6vmCmZ5v/6gYu4vyYAhzjuNQU6P/vmSy4uQaOhvje031qQMiW0d2BwgMH52vqMng==", + "dev": true, + "dependencies": { + "jsonc-parser": "^3.0.0", + "vscode-oniguruma": "^1.6.1", + "vscode-textmate": "5.2.0" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -3781,6 +3817,48 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/typedoc": { + "version": "0.23.10", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.23.10.tgz", + "integrity": "sha512-03EUiu/ZuScUBMnY6p0lY+HTH8SwhzvRE3gImoemdPDWXPXlks83UGTx++lyquWeB1MTwm9D9Ca8RIjkK3AFfQ==", + "dev": true, + "dependencies": { + "lunr": "^2.3.9", + "marked": "^4.0.18", + "minimatch": "^5.1.0", + "shiki": "^0.10.1" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 14.14" + }, + "peerDependencies": { + "typescript": "4.6.x || 4.7.x" + } + }, + "node_modules/typedoc/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typedoc/node_modules/minimatch": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz", + "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/typescript": { "version": "4.7.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", @@ -3849,6 +3927,18 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "dev": true }, + "node_modules/vscode-oniguruma": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.6.2.tgz", + "integrity": "sha512-KH8+KKov5eS/9WhofZR8M8dMHWN2gTxjMsG4jd04YhpbPR91fUj7rYQ2/XjeHCJWbg7X++ApRIU9NUwM2vTvLA==", + "dev": true + }, + "node_modules/vscode-textmate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-5.2.0.tgz", + "integrity": "sha512-Uw5ooOQxRASHgu6C7GVvUxisKXfSgW4oFlO+aa+PAkgmH89O3CXxEEzNRNtHSqtXFTl0nAC1uYj0GMSH27uwtQ==", + "dev": true + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5818,6 +5908,12 @@ "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", "dev": true }, + "jsonc-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.1.0.tgz", + "integrity": "sha512-DRf0QjnNeCUds3xTjKlQQ3DpJD51GvDjJfnxUVWg6PZTo2otSm+slzNAxU/35hF8/oJIKoG9slq30JYOsF2azg==", + "dev": true + }, "levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5877,6 +5973,12 @@ "yallist": "^4.0.0" } }, + "lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true + }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -5900,6 +6002,12 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "marked": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.0.18.tgz", + "integrity": "sha512-wbLDJ7Zh0sqA0Vdg6aqlbT+yPxqLblpAZh1mK2+AO2twQkPywvvqQNfEPVwSSRjZ7dZcdeVBIAgiO7MMp3Dszw==", + "dev": true + }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6543,6 +6651,17 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, + "shiki": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.10.1.tgz", + "integrity": "sha512-VsY7QJVzU51j5o1+DguUd+6vmCmZ5v/6gYu4vyYAhzjuNQU6P/vmSy4uQaOhvje031qQMiW0d2BwgMH52vqMng==", + "dev": true, + "requires": { + "jsonc-parser": "^3.0.0", + "vscode-oniguruma": "^1.6.1", + "vscode-textmate": "5.2.0" + } + }, "signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -6756,6 +6875,38 @@ "is-typedarray": "^1.0.0" } }, + "typedoc": { + "version": "0.23.10", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.23.10.tgz", + "integrity": "sha512-03EUiu/ZuScUBMnY6p0lY+HTH8SwhzvRE3gImoemdPDWXPXlks83UGTx++lyquWeB1MTwm9D9Ca8RIjkK3AFfQ==", + "dev": true, + "requires": { + "lunr": "^2.3.9", + "marked": "^4.0.18", + "minimatch": "^5.1.0", + "shiki": "^0.10.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz", + "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, "typescript": { "version": "4.7.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", @@ -6798,6 +6949,18 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "dev": true }, + "vscode-oniguruma": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.6.2.tgz", + "integrity": "sha512-KH8+KKov5eS/9WhofZR8M8dMHWN2gTxjMsG4jd04YhpbPR91fUj7rYQ2/XjeHCJWbg7X++ApRIU9NUwM2vTvLA==", + "dev": true + }, + "vscode-textmate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-5.2.0.tgz", + "integrity": "sha512-Uw5ooOQxRASHgu6C7GVvUxisKXfSgW4oFlO+aa+PAkgmH89O3CXxEEzNRNtHSqtXFTl0nAC1uYj0GMSH27uwtQ==", + "dev": true + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 311aedc..ed4de2a 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "build": "tsc", "lint": "eslint . --ext .ts", "test": "mocha -r ts-node/register -r source-map-support/register ./test/**/*.ts", - "test:coverage": "npm run build & nyc npm run test" + "test:coverage": "npm run build & nyc npm test" }, "dependencies": { "@msgpack/msgpack": "^2.7.2", @@ -44,6 +44,7 @@ "source-map-support": "^0.5.21", "ts-node": "^10.9.1", "typed-emitter": "^2.1.0", + "typedoc": "^0.23.10", "typescript": "^4.7.4" }, "files": [ From a8747c831d415fd359b0b7c15b8705012192362a Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Tue, 9 Aug 2022 15:57:20 +0100 Subject: [PATCH 13/18] Reorganise codebase --- src/aggregate-signal.ts | 36 -------------- src/signals.ts | 62 ++++++++++++++++++++++++ src/timeout-signal.ts | 26 ---------- test/{aggregate-signal.ts => signals.ts} | 35 ++++++++++++- test/timeout-signal.ts | 35 ------------- 5 files changed, 95 insertions(+), 99 deletions(-) delete mode 100644 src/aggregate-signal.ts create mode 100644 src/signals.ts delete mode 100644 src/timeout-signal.ts rename test/{aggregate-signal.ts => signals.ts} (68%) delete mode 100644 test/timeout-signal.ts diff --git a/src/aggregate-signal.ts b/src/aggregate-signal.ts deleted file mode 100644 index 808a31d..0000000 --- a/src/aggregate-signal.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { isSignal } from './utils'; - -/** - * A helper class to create an AbortSignal which aborts as soon as any of the signals passed to its constructor do. - */ -export default class AggregateSignal { - /** The aggregate AbortSignal. */ - public readonly signal?: AbortSignal; - - /** - * Initializes a new AggregateSignal. - * @param {(AbortSignal | undefined)[]} abortSignals The AbortSignals to aggregate. - */ - constructor(...abortSignals: (AbortSignal | undefined)[]) { - const signals = abortSignals.filter(isSignal); - - if (signals.length === 1) { - this.signal = signals[0]; - } else if (signals.filter(s => s.aborted).length > 0) { - this.signal = signals.filter(s => s.aborted)[0]; - } else if (signals.length > 1) { - const ac = new AbortController(); - this.signal = ac.signal; - - for (const signal of signals) { - signal.addEventListener('abort', () => { - for (const signal of signals) { - signal.removeEventListener('abort'); - } - - ac.abort(); - }); - } - } - } -} diff --git a/src/signals.ts b/src/signals.ts new file mode 100644 index 0000000..a9d1d3a --- /dev/null +++ b/src/signals.ts @@ -0,0 +1,62 @@ +import { isSignal } from './utils'; + +/** + * A helper class to create an {@link AbortSignal} which aborts as soon as any of the signals passed to its constructor do. + */ +export class AggregateSignal { + /** The aggregate {@link AbortSignal}. */ + public readonly signal?: AbortSignal; + + /** + * Initializes a new {@link AggregateSignal}. + * @param {(AbortSignal | undefined)[]} abortSignals The {@link AbortSignal AbortSignals} to aggregate. + */ + constructor(...abortSignals: (AbortSignal | undefined)[]) { + const signals = abortSignals.filter(isSignal); + + if (signals.length === 1) { + this.signal = signals[0]; + } else if (signals.filter(s => s.aborted).length > 0) { + this.signal = signals.filter(s => s.aborted)[0]; + } else if (signals.length > 1) { + const ac = new AbortController(); + this.signal = ac.signal; + + for (const signal of signals) { + signal.addEventListener('abort', () => { + for (const signal of signals) { + signal.removeEventListener('abort'); + } + + ac.abort(); + }); + } + } + } +} + +/** + * A helper class to create an {@link AbortSignal} based on a timeout. + */ +export class TimeoutSignal { + /** The underlying {@link AbortSignal}. */ + public readonly signal?: AbortSignal; + /** If defined, the ID of a timeout which will signal abortion. */ + public readonly timeout?: ReturnType; + + /** + * Initializes a new {@link TimeoutSignal}. + * @param {number} [timeout] The number of milliseconds after which the {@link signal} should be aborted. + * `undefined`, {@link Infinity infinite} or {@link NaN} values will result in {@link signal} being `undefined`. + * Finite values will be clamped between `0` and {@link Number.MAX_SAFE_INTEGER} inclusive. + */ + constructor(timeout?: number) { + if (timeout !== undefined && isFinite(timeout) && !isNaN(timeout)) { + timeout = Math.min(Math.max(timeout, 0), Number.MAX_SAFE_INTEGER); // clamp the timeout to a sensible range + + const ac = new AbortController(); + this.signal = ac.signal; // wrap the AbortController's signal + this.timeout = setTimeout(() => ac.abort(), timeout); // abort after the given number of milliseconds + } + } +} diff --git a/src/timeout-signal.ts b/src/timeout-signal.ts deleted file mode 100644 index 93523d3..0000000 --- a/src/timeout-signal.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * A helper class to create an AbortSignal based on a timeout. - */ -export default class TimeoutSignal { - /** The underlying AbortSignal. */ - public readonly signal?: AbortSignal; - /** If defined, the ID of a timeout which will signal abortion. */ - public readonly timeout?: ReturnType; - - /** - * Initializes a new TimeoutSignal. - * @param {number} [timeout] Constructs an AbortController and sets a timeout which will call the AbortController's `abort` - * method after the given number of milliseconds, exposing its signal via the `signal` property. - * Undefined, infinite or NaN values will result in the `signal` property being `undefined`. - * Finite values will be clamped between `0` and `Number.MAX_SAFE_INTEGER` inclusive. - */ - constructor(timeout?: number) { - if (timeout !== undefined && isFinite(timeout) && !isNaN(timeout)) { - timeout = Math.min(Math.max(timeout, 0), Number.MAX_SAFE_INTEGER); // clamp the timeout to a sensible range - - const ac = new AbortController(); - this.signal = ac.signal; // wrap the AbortController's signal - this.timeout = setTimeout(() => ac.abort(), timeout); // abort after the given number of milliseconds - } - } -} diff --git a/test/aggregate-signal.ts b/test/signals.ts similarity index 68% rename from test/aggregate-signal.ts rename to test/signals.ts index 14ddbdc..230e512 100644 --- a/test/aggregate-signal.ts +++ b/test/signals.ts @@ -2,9 +2,8 @@ import 'mocha'; import chai, { expect } from 'chai'; import spies from 'chai-spies'; -import AggregateSignal from '../src/aggregate-signal'; +import { AggregateSignal, TimeoutSignal } from '../src/signals'; import { Signal, isSignal } from '../src/utils'; -import TimeoutSignal from '../src/timeout-signal'; chai.use(spies); @@ -90,3 +89,35 @@ describe('AggregateSignal', () => { }); }); }); + +describe('TimeoutSignal', () => { + context('when timeout: undefined', () => { + const instance = new TimeoutSignal(); + describe('signal', () => it('should be: undefined', () => expect(instance.signal).to.be.undefined)); + describe('timeout', () => it('should be: undefined', () => expect(instance.timeout).to.be.undefined)); + }); + + context('when timeout: NaN', () => { + const instance = new TimeoutSignal(NaN); + describe('signal', () => it('should be: undefined', () => expect(instance.signal).to.be.undefined)); + describe('timeout', () => it('should be: undefined', () => expect(instance.timeout).to.be.undefined)); + }); + + context('when timeout: Infinity', () => { + const instance = new TimeoutSignal(Infinity); + describe('signal', () => it('should be: undefined', () => expect(instance.signal).to.be.undefined)); + describe('timeout', () => it('should be: undefined', () => expect(instance.timeout).to.be.undefined)); + }); + + context('when timeout: < 0', () => { + const instance = new TimeoutSignal(-1); + describe('signal', () => it('should not be: undefined', () => expect(instance.signal).to.not.be.undefined)); + describe('timeout', () => it('should not be: undefined', () => expect(instance.timeout).to.not.be.undefined)); + }); + + context('when timeout: 1000', () => { + const instance = new TimeoutSignal(1000); + describe('signal', () => it('should not be: undefined', () => expect(instance.signal).to.not.be.undefined)); + describe('timeout', () => it('should not be: undefined', () => expect(instance.timeout).to.not.be.undefined)); + }); +}); diff --git a/test/timeout-signal.ts b/test/timeout-signal.ts deleted file mode 100644 index bf2486a..0000000 --- a/test/timeout-signal.ts +++ /dev/null @@ -1,35 +0,0 @@ -import 'mocha'; -import { expect } from 'chai'; -import TimeoutSignal from '../src/timeout-signal'; - -describe('TimeoutSignal', () => { - context('when timeout: undefined', () => { - const instance = new TimeoutSignal(); - describe('signal', () => it('should be: undefined', () => expect(instance.signal).to.be.undefined)); - describe('timeout', () => it('should be: undefined', () => expect(instance.timeout).to.be.undefined)); - }); - - context('when timeout: NaN', () => { - const instance = new TimeoutSignal(NaN); - describe('signal', () => it('should be: undefined', () => expect(instance.signal).to.be.undefined)); - describe('timeout', () => it('should be: undefined', () => expect(instance.timeout).to.be.undefined)); - }); - - context('when timeout: Infinity', () => { - const instance = new TimeoutSignal(Infinity); - describe('signal', () => it('should be: undefined', () => expect(instance.signal).to.be.undefined)); - describe('timeout', () => it('should be: undefined', () => expect(instance.timeout).to.be.undefined)); - }); - - context('when timeout: < 0', () => { - const instance = new TimeoutSignal(-1); - describe('signal', () => it('should not be: undefined', () => expect(instance.signal).to.not.be.undefined)); - describe('timeout', () => it('should not be: undefined', () => expect(instance.timeout).to.not.be.undefined)); - }); - - context('when timeout: 1000', () => { - const instance = new TimeoutSignal(1000); - describe('signal', () => it('should not be: undefined', () => expect(instance.signal).to.not.be.undefined)); - describe('timeout', () => it('should not be: undefined', () => expect(instance.timeout).to.not.be.undefined)); - }); -}); From 8bcc85e76111cb711b2ad772ce1f92f60473bda1 Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Tue, 9 Aug 2022 15:59:41 +0100 Subject: [PATCH 14/18] Update TSDocs --- src/utils.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 8c9b6a5..8324d80 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,14 +1,14 @@ /** - * Type guard for determining whether a given object is an `AbortSignal` instance. + * Type guard for determining whether a given object is an {@link AbortSignal} instance. * @param {unknown} object The object. - * @returns {object is AbortSignal} `true` if the object is determined to be an `AbortSignal`, otherwise false. + * @returns {object is AbortSignal} `true` if {@link object} is determined to be an {@link AbortSignal}, otherwise `false`. */ export function isAbortSignal(object: unknown): object is AbortSignal { return object instanceof AbortSignal; } /** - * A helpful interface to allow use of AbortSignal EventTarget interface when TypeScript hates us. + * A helpful interface to allow use of {@link AbortSignal AbortSignal's} {@link EventTarget} interface when TypeScript hates us. */ export interface Signal { addEventListener: (event: 'abort', callback: () => void) => void; @@ -17,9 +17,9 @@ export interface Signal { } /** - * Type guard for determining whether a given object conforms to the `Signal` interface. + * Type guard for determining whether a given object conforms to the {@link Signal} interface. * @param {unknown} object The object. - * @returns {object is Signal} `true` if the object conforms to the `Signal` interface, otherwise `false`. + * @returns {object is Signal} `true` if {@link object} conforms to the {@link Signal} interface, otherwise `false`. */ export function isSignal(object: unknown): object is Signal { return isAbortSignal(object) && 'addEventListener' in object && 'removeEventListener' in object @@ -27,7 +27,8 @@ export function isSignal(object: unknown): object is Signal { } /** - * A simple Ping interface for internal use. + * A simple interface representing a ping. + * @internal */ export interface Ping { ping: string; From 17b517a5c3eef1be2e66dae2220b3efce9ab385c Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Tue, 9 Aug 2022 16:00:11 +0100 Subject: [PATCH 15/18] Update tsdocs and refactor --- src/index.ts | 2 +- src/procedure.ts | 353 +++++++++++++++++++++++++++++----------------- test/procedure.ts | 7 +- 3 files changed, 229 insertions(+), 133 deletions(-) diff --git a/src/index.ts b/src/index.ts index e2b7be1..d8c70a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ export * from './procedure'; -export { default } from './procedure'; +export { Procedure as default } from './procedure'; diff --git a/src/procedure.ts b/src/procedure.ts index 9adfba3..0967263 100644 --- a/src/procedure.ts +++ b/src/procedure.ts @@ -3,8 +3,7 @@ /// import { Ping, isPing, isErrorLike, cloneError } from './utils'; -import AggregateSignal from './aggregate-signal'; -import TimeoutSignal from './timeout-signal' +import { AggregateSignal, TimeoutSignal } from './signals'; import { createSocket, Socket } from 'nanomsg'; import { encode, decode, ExtensionCodec } from '@msgpack/msgpack' import { once, EventEmitter } from 'events' @@ -17,50 +16,75 @@ const uuidNamespace = uuidv5(homepage, uuidv5.URL); /** * A simple abstraction of a procedure (the P in RPC). - * Allows you to turn any generic function or callback into a procedure, which can be called via the transport specified. - * Includes the functionality to ping procedures to check whether they are available. + * Allows you to turn a function or callback into a procedure, which can be called via the transport specified. + * @template Input Type of input parameter the procedure accepts. Defaults to `undefined`. + * @template Output Type of output value the procedure returns. Defaults to `undefined`. */ -export default class Procedure extends (EventEmitter as { new (): TypedEmitter> }) implements ProcedureDefinitionOptions { - [key: keyof ProcedureDefinitionOptions]: ProcedureDefinitionOptions[keyof ProcedureDefinitionOptions]; - +export class Procedure extends (EventEmitter as { new (): TypedEmitter> }) implements ProcedureDefinitionOptions { #endpoint?: string; - /** The endpoint at which the procedure, when bound, can be called. */ + /** + * The endpoint at which the {@link Procedure}, when {@link bind bound}, can be {@link Procedure.call called}. + */ get endpoint() { return this.#endpoint; } protected set endpoint(value) { this.#endpoint = value; } - /** The options in use by the procedure, including defaults. */ - protected options: ProcedureDefinitionOptions; - /** The underlying nanomsg socket used for data transmission. */ - protected sockets: Socket[] = []; - #uuid?: string; - /** A v5 uuid generated for this endpoint, used for checking whether a Procedure is available and ready to respond to requests. */ + /** + * A v5 uuid generated from {@link endpoint}, used to identify ping requests. + */ protected get uuid() { return this.#uuid; } protected set uuid(value) { this.#uuid = value; } + /** + * The options in use by the {@link Procedure}, including defaults. + */ + protected options: ProcedureDefinitionOptions; + + /** + * The underlying nanomsg {@link Socket sockets} used for data transmission. + */ + protected sockets: Socket[] = []; + + /** + * @inheritDoc ProcedureDefinitionOptions.verbose + */ get verbose() { return this.options.verbose; } set verbose(value) { this.options.verbose = value; } + /** + * @inheritDoc ProcedureDefinitionOptions.workers + */ get workers() { return this.options.workers; } - protected set workers(value) { + set workers(value) { this.options.workers = !isNaN(value) && isFinite(value) ? Math.min(Math.max(value, 1), Number.MAX_SAFE_INTEGER) : 1; } + /** + * @inheritDoc ProcedureDefinitionOptions.extensionCodec + */ get extensionCodec() { return this.options.extensionCodec; } - protected set extensionCodec(value) { this.options.extensionCodec = value; } + set extensionCodec(value) { this.options.extensionCodec = value; } + /** + * @inheritDoc ProcedureOptions.optionalParameterSupport + */ get optionalParameterSupport() { return this.options.optionalParameterSupport; } - protected set optionalParameterSupport(value) { this.options.optionalParameterSupport = value; } + set optionalParameterSupport(value) { this.options.optionalParameterSupport = value; } - get stripUndefinedProperties() { return this.options.stripUndefinedProperties; } - protected set stripUndefinedProperties(value) { this.options.stripUndefinedProperties = value; } + /** + * @inheritDoc ProcedureOptions.ignoreUndefinedProperties + */ + get ignoreUndefinedProperties() { return this.options.ignoreUndefinedProperties; } + set ignoreUndefinedProperties(value) { this.options.ignoreUndefinedProperties = value; } /** - * Initializes a new Procedure. + * Initializes a new {@link Procedure}. * @param {Callback} callback The underlying callback function powering the procedure itself. The callback may be asynchronous. - * @param {Partial} [options={}] An options bag defining how the procedure should be run. Defaults to `{}`. + * @param {Partial} [options={}] Options for a {@link Procedure}. Defaults to `{}`. + * @template Input Type of input parameter the procedure accepts. Defaults to `undefined`. + * @template Output Type of output value the procedure returns. Defaults to `undefined`. */ constructor(protected callback: Callback, options: Partial = {}) { super(); @@ -69,32 +93,33 @@ export default class Procedure this.#onRepSocketData(data, socket)) - .on('error', (error: unknown) => this.#onRepSocketError(error)) - .once('close', () => this.#onRepSocketClose()) - .bind(this.endpoint); // bind the socket to the endpoint + .on('data', (data: Buffer) => this.#onRepSocketData(data, socket)) + .on('error', (error: unknown) => this.#onRepSocketError(error)) + .once('close', () => this.#onRepSocketClose()) + .bind(endpoint); // bind the socket to the endpoint } } @@ -102,8 +127,9 @@ export default class Procedure 0) { @@ -117,20 +143,22 @@ export default class Procedure} A promise which when resolved passes the output value to the promise's `then` handler(s). + * Asynchronously calls a {@link Procedure} at a given {@link endpoint} with given a {@link input}. + * @param {string} endpoint The endpoint at which the {@link Procedure} is {@link Procedure.bind bound}. + * @param {Nullable} [input] An input parameter to pass to the {@link Procedure}. Defaults to `undefined`. + * @param {ProcedureCallOptions} [options={}] Options for calling a {@link Procedure}. Defaults to `{}`. + * @returns {Promise} A {@link Promise} which when resolved passes the output value to the {@link Promise.then then} handler(s). + * @template Output The type of output value expected to be returned from the {@link Procedure}. Defaults to `unknown`. + * @see {@link Procedure.endpoint} + * @see {@link Procedure.ping} */ - static async call(endpoint: string, input?: Nullable, options: Partial = {}): Promise { + static async call(endpoint: string, input?: Nullable, options: Partial = {}): Promise { const socket = createSocket('req'); const opts: ProcedureCallOptions = { ...{ timeout: 1000, - ping: false, optionalParameterSupport: true, - stripUndefinedProperties: true + ignoreUndefinedProperties: true }, ...options }; @@ -142,12 +170,12 @@ export default class Procedure>(buffer, opts.extensionCodec); // decode the response @@ -169,18 +197,18 @@ export default class Procedure} A promise which, when resolved, indicates whether the endpoint correctly responded to the ping. + * @returns {Promise} A {@link Promise} which when resolved indicates whether the {@link endpoint} correctly responded to the ping. */ - static async ping(endpoint: string, timeout: number | undefined = 100, signal?: AbortSignal): Promise { + static async ping(endpoint: string, timeout = 100, signal?: AbortSignal): Promise { if (signal?.aborted) { throw new Error('signal was aborted'); } else { @@ -211,31 +239,32 @@ export default class Procedure(buffer: Buffer, extensionCodec?: ExtensionCodec): T { return decode(buffer, { extensionCodec }) as T; } /** - * Attempts to decode the given buffer into a usable input value for the Procedure. - * @param {Buffer} buffer The buffer to decode. - * @returns {{ input: Input, error?: never } | { input?: never, error: unknown }} If successful, an object of shape `{ input: Input }`, otherwise `{ error: unknown }`. + * Attempts to decode the given {@link Buffer}. + * @param {Buffer} buffer The {@link Buffer} to decode. + * @returns {{ input: Input, error?: never } | { input?: never, error: unknown }} If successful, an object of shape `{ input: Input | Ping }`, otherwise `{ error: unknown }`. */ #tryDecodeInput(buffer: Buffer): { input: Input | Ping, error?: never } | { input?: never, error: unknown } { try { @@ -247,9 +276,9 @@ export default class Procedure>} A promise which when resolved passes the response to the promise's `then` handler. + * Attempts to asynchronously call the {@link Procedure Procedure's} {@link callback} and return a response containing its output value. + * @param {Input} input An input parameter to pass to the {@link callback}. + * @returns {Promise>} A {@link Promise} which when resolved passes the response to the {@link Promise.then then} handler(s). */ async #tryGetCallbackResponse(input: Input): Promise> { try { @@ -266,16 +295,16 @@ export default class Procedure} response The response to encode. - * @returns {Buffer} A buffer containing the encoded response. + * @returns {Buffer} A {@link Buffer} containing the encoded response. */ #tryEncodeResponse(response: Response): Buffer { try { if (isErrorLike(response.error)) { // clone the error so that it can be encoded for transmission response.error = cloneError(response.error); } - return Procedure.#encode(response, this.extensionCodec, this.stripUndefinedProperties); + return Procedure.#encode(response, this.extensionCodec, this.ignoreUndefinedProperties); } catch (error) { this.#emitAndLogError('Procedure response could not be encoded for transmission', error); return this.#tryEncodeResponse({ // As the response could not be encoded, encode and return a new response containing the thrown error @@ -287,10 +316,10 @@ export default class Procedure { const decoded = this.#tryDecodeInput(data); - if (this.#tryHandlePing(decoded.input, socket)) { // input was a ping of valid uuid & pong was successfully sent - if (this.verbose) { - console.log(`PONG sent at endpoint ${this.endpoint}`); - } - } else { + if (!this.#tryHandlePing(decoded.input, socket)) { // input was not a ping, handle it if ('input' in decoded) { this.#emitAndLogData(decoded.input as Input); } @@ -334,32 +360,41 @@ export default class Procedurethis.endpoint, object.ping) }), socket); + + if (this.#trySendBuffer(this.#tryEncodeResponse({ pong: uuidv5(this.endpoint, data.ping) }), socket)) { + if (this.verbose) { + console.log(`PONG sent at endpoint ${this.endpoint}`); + } + } + + return true; } else { return false; } } /** - * Handles the socket's error event. - * @param {unknown} error The error data passed by the socket. + * Handles the error event for the underlying {@link sockets} of the {@link Procedure}. + * @param {unknown} error The error data passed by the {@link Socket}. + * @see {@link Socket} */ #onRepSocketError(error: unknown): void { this.#emitAndLogError('Socket encountered an error', error); } /** - * Handles the socket's close event. + * Handles the close event for the underlying {@link sockets} of the {@link Procedure}. + * @see {@link Socket} */ #onRepSocketClose(): void { this.#logSocketClose(); @@ -370,7 +405,7 @@ export default class Procedure = (input: Input) => Output; /** - * A response from a procedure call. If the call returned successfully, the response will be of shape `{ output: Output }`, otherwise `{ error: unknown }`. + * A response from a {@link Procedure.call Procedure call}. If the call returned successfully, the response will be of shape `{ output: Output }`, otherwise `{ error: unknown }`. */ export type Response = { output: Output, error?: never, pong?: never } | { output?: never, error: unknown, pong?: never } | { output?: never, error?: never, pong: string }; -export interface ProcedureCommonOptions { +/** + * Options for defining or calling a {@link Procedure}. + * @see {@link ProcedureDefinitionOptions} + * @see {@link ProcedureCallOptions} + */ +export interface ProcedureOptions { + /** + * Whether or not to enable optional parameter support. Defaults to `true`. + * When `true` on a {@link Procedure Procedure definition}, a `null` input parameter will be coerced to `undefined`. + * When `true` for a {@link Procedure.call Procedure call}, a `null` return value will be coerced to `undefined`. + * + * @remarks + * The {@link https://github.com/toebeann/procedure.js procedure.js} library uses the {@link https://github.com/msgpack/msgpack-javascript msgpack} serialization + * format for encoding JavaScript objects and values for transmission to and from remote {@link Procedure procedures}. + * The JavaScript implementation of msgpack {@link https://github.com/msgpack/msgpack-javascript#messagepack-mapping-table maps undefined to null}. + * For procedures which accept optional parameters, this is problematic. + * It could also be an issue if you depend on the return value of a procedure to conditionally be `undefined`, + * for the convenience of passing the return value into an optional parameter of another function call. + * {@link optionalParameterSupport} aims to alleviate these issues by mapping `null` to `undefined` + * for the input and output of your {@link Procedure} calls. + * + * @see {@link ignoreUndefinedProperties} + * @see {@link https://github.com/toebeann/procedure.js#optional-parameter-support Optional parameter support} + */ optionalParameterSupport: boolean; - stripUndefinedProperties: boolean; + /** + * Whether or not to ignore `undefined` properties of objects passed to or from a {@link Procedure}. Defaults to `true`. + * When `true` on a {@link Procedure Procedure definition}, only affects properties of input parameters. + * When `true` on a {@link Procedure.call Procedure call}, only affects properties of the return value. + * + * @remarks + * The {@link https://github.com/toebeann/procedure.js procedure.js} library uses the {@link https://github.com/msgpack/msgpack-javascript msgpack} serialization + * format for encoding JavaScript objects and values for transmission to and from remote {@link Procedure procedures}. + * The JavaScript implementation of msgpack {@link https://github.com/msgpack/msgpack-javascript#messagepack-mapping-table maps undefined to null}. + * This means that when passing objects in or out of a {@link Procedure} (i.e. as a parameter or return value), any properties defined as `undefined` + * will evaluate to `null` on receipt. + * {@link ignoreUndefinedProperties} aims to alleviate this by signalling msgpack to ignore undefined properties from objects before they are encoded, + * allowing `undefined` to be evaluated as `undefined` and `null` to be evaluated as `null`. + * This operation incurs some overhead, and means that code relying on the presence of a property to infer meaning + * may not operate as expected. + * + * @see {@link https://github.com/toebeann/procedure.js#null-and-undefined-properties null and undefined properties} + */ + ignoreUndefinedProperties: boolean; } /** - * Options for defining a Procedure. + * Options for defining a {@link Procedure}. */ -export interface ProcedureDefinitionOptions extends ProcedureCommonOptions { - [key: string]: unknown; - /** The number of socket workers to spin up for the Procedure. Useful for Procedures which may take a long time to complete. - * Will be clamped between `1` and `Number.MAX_SAFE_INTEGER` inclusive. +export interface ProcedureDefinitionOptions extends ProcedureOptions { + /** + * The number of workers to spin up for the {@link Procedure}. Useful for procedures which may take a long time to complete. + * Will be clamped between `1` and {@link Number.MAX_SAFE_INTEGER} inclusive. * Defaults to `1`. */ workers: number; - /** A boolean indicating whether to output errors and events to the console. Defaults to `false`. */ + /** Whether or not to output errors and events to the console. Defaults to `false`. */ verbose: boolean; - /** The msgpack `ExtensionCodec` to use for encoding and decoding messages. Defaults to `undefined`. */ + /** An optional msgpack {@link ExtensionCodec} to use for encoding and decoding messages. */ extensionCodec?: ExtensionCodec | undefined; } /** - * Options for calling a Procedure. + * Options for {@link Procedure.call calling} a {@link Procedure}. */ -export interface ProcedureCallOptions extends ProcedureCommonOptions { - /** The number of milliseconds after which the Procedure call will automatically be aborted. - * Set to `Infinity` or `NaN` to never timeout. - * Non-NaN, finite values will be clamped between `0` and `Number.MAX_SAFE_INTEGER` inclusive. - * Defaults to `1000`. */ +export interface ProcedureCallOptions extends ProcedureOptions { + /** + * The number of milliseconds after which the {@link Procedure.call Procedure call} will automatically be aborted. + * Set to {@link Infinity} or {@link NaN} to never timeout. + * Non-{@link NaN}, finite values will be clamped between `0` and {@link Number.MAX_SAFE_INTEGER} inclusive. + * Defaults to `1000`. + */ timeout: number; - /** The number of millisceonds to wait for a ping-pong from the endpoint before calling the remote procedure. - * Set to `false` to skip pinging the endpoint. - * Defaults to `false`. */ - ping: number | false; - /** An optional msgpack `ExtensionCodec` to use for encoding and decoding messages. - * Defaults to `undefined`. */ + /** + * The number of milliseconds to wait for a ping-pong from the endpoint before calling the remote procedure. + * When set, if a ping-pong is not received in the given time, the {@link Procedure.call Procedure call} will be aborted. + * {@link NaN} or {@link Infinity infinite} numbers will result in the ping never timing out if no response is received, unless + * {@link signal} is a valid {@link AbortSignal} and gets aborted. + * Non-{@link NaN}, finite values will be clamped between `0` and {@link Number.MAX_SAFE_INTEGER} inclusive. + */ + ping?: number | undefined; + /** An optional msgpack {@link ExtensionCodec} to use for encoding and decoding messages. */ extensionCodec?: ExtensionCodec | undefined; - /** An optional `AbortSignal` which will be used to abort the Procedure call. - * Defaults to `undefined`. */ + /** An optional {@link AbortSignal} which will be used to abort the Procedure call. */ signal?: AbortSignal | undefined; } /** - * A map of the names of events emitted by Procedures and their function signatures. + * A map of the names of events emitted by {@link Procedure Procedures} and their function signatures. + * @template Input The type of input parameter passed to the data event. + * @see {@link TypedEmitter} */ -type ProcedureEvents = { +export type ProcedureEvents = { + /** + * Signature for the data event. + * @param {Input} data The input parameter which was passed to the {@link Procedure}. + */ data: (data: Input) => void; + /** + * Signature for the error event. + * @param {unknown} error The error data which was thrown by the {@link Procedure}. + */ error: (error: unknown) => void; + /** Signature for the unbind event. */ unbind: () => void; } diff --git a/test/procedure.ts b/test/procedure.ts index fd15651..3b0dd8c 100644 --- a/test/procedure.ts +++ b/test/procedure.ts @@ -78,12 +78,12 @@ describe('Procedure', () => { }); }); - // TODO: test workers, extensionCodic, optionalParameterSupport & stripUndefinedProperties accessors + // TODO: test workers, extensionCodic, optionalParameterSupport & ignoreUndefinedProperties accessors describe('bind(): this', () => { let instance: Procedure; beforeEach(() => instance = new Procedure(x => x)); - afterEach(() => { instance.unbind().removeAllListeners() }); + afterEach(() => { instance.unbind().removeAllListeners(); }); it('should return: this', () => expect(instance.bind()).to.equal(instance)); @@ -99,6 +99,7 @@ describe('Procedure', () => { context('when verbose: true', () => { const sandbox = chai.spy.sandbox(); beforeEach(() => { + instance = new Procedure(x => x); instance.verbose = true; sandbox.on(console, 'error', () => { return }) }); @@ -321,7 +322,7 @@ describe('Procedure.call(endpoint: string, input: Input | null, options: Partial }); // TODO: test optionalParameterSupport option works as intended - // TODO: test stripUndefinedProperties option works as intended + // TODO: test ignoreUndefinedProperties option works as intended // TODO: when callback asynchronous (completes normally, times out, throws error, infinite timeout, abortion signaled during execution, abortion signaled before execution) }); From dc326d8bc5e395325150ca88e1b4266bdafc0022 Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Tue, 9 Aug 2022 16:00:43 +0100 Subject: [PATCH 16/18] Update readme for refactorings --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dd68430..e70b38d 100644 --- a/README.md +++ b/README.md @@ -135,18 +135,18 @@ await Procedure.call('tcp://*:54321', x, { optionalParameterSupport: false }); Note that disabling at the definition will not affect the return value, and disabling at the call will not affect the input parameter. ##### `null` and `undefined` properties -For objects, we do not coerce `null` properties to `undefined`. Instead, we leave them as is, but strip any properties with the value of `undefined` from the object prior to transmission, thereby allowing those properties to be evaluated as `undefined` at the other end, while `null` properties remain `null`. +For objects, we do not coerce `null` properties to `undefined`. Instead, we leave them as is, but properties with the value of `undefined` are ignored, thereby allowing those properties to be evaluated as `undefined` at the other end, while `null` properties remain `null`. This operation adds some overhead, and any code that relies on the presence of a property to infer meaning may not work as expected, e.g. `if ('prop' in obj)`. -To disable this behavior, you can set the `stripUndefinedProperties` option to `false` for either procedure definitions or calls, or both: +To disable this behavior, you can set the `ignoreUndefinedProperties` option to `false` for either procedure definitions or calls, or both: ```js -const procedure = new Procedure(x => { ... }, { stripUndefinedProperties: false } +const procedure = new Procedure(x => { ... }, { ignoreUndefinedProperties: false } .bind('tcp://*:54321'); ``` ```js -await Procedure.call('tcp://*:54321', x, { stripUndefinedProperties: false }); +await Procedure.call('tcp://*:54321', x, { ignoreUndefinedProperties: false }); ``` Note that disabling at the definition will not affect the return value, and disabling at the call will not affect the input parameter. From 2b33da3948c4f399447cbc0c0a0e8cb5a4ee187a Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Tue, 9 Aug 2022 16:01:05 +0100 Subject: [PATCH 17/18] Update nyc minimum coverages --- .nycrc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.nycrc b/.nycrc index 66ec151..d44394d 100644 --- a/.nycrc +++ b/.nycrc @@ -9,8 +9,8 @@ ], "report-dir": "coverage", "statements": "91", - "branches": "82", - "functions": "90", - "lines": "90", + "branches": "85", + "functions": "94", + "lines": "91", "include": "src" } From a4f7fb8ea1c9908680c87f78351b01375c68b4f7 Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Tue, 9 Aug 2022 16:01:24 +0100 Subject: [PATCH 18/18] Preliminary API ref workflow --- .github/workflows/publish-api-reference.yml | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/publish-api-reference.yml diff --git a/.github/workflows/publish-api-reference.yml b/.github/workflows/publish-api-reference.yml new file mode 100644 index 0000000..0be1725 --- /dev/null +++ b/.github/workflows/publish-api-reference.yml @@ -0,0 +1,37 @@ +name: Publish API Reference + +on: + release: + types: [created] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Setup node.js @ lts + uses: actions/setup-node@v3 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Test build + run: npm test + + publish-api-reference: + needs: test + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Setup node.js @ lts + uses: actions/setup-node@v3 + - name: Install dependencies + run: npm ci + - name: Generate API reference + run: npx typedoc src + - name: Publish to GitHub Pages 🚀 + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: docs