diff --git a/README.md b/README.md index 80c535d..173b890 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,9 @@ const myApiOnClient = ## API -### `const { close } = createServer(api, channel, [options])` +### `const { close, detachHandler, ensureHandler } = createServer(api, channel, [options])` -`api` can be any object with any properties, methods and events that you want reflected in the client API. +`api` can be any object with any properties, methods and events that you want reflected in the client API. It can also be a factory function that returns (or resolves to) such an object — see [Late-bound handlers](#late-bound-handlers). `channel` can be a browser [MessagePort](http://developer.mozilla.org/en-US/docs/Web/API/MessagePort), a Node [Worker MessagePort](https://nodejs.org/api/worker_threads.html#worker_threads_class_messageport) or a MessagePort-like object that defines a `postMessage()` method and `addEventListener('message', ...)` / `removeEventListener('message', ...)` methods. The listener is called with a `MessageEvent`-like object, i.e. an object with the message on its `data` property. @@ -80,7 +80,24 @@ If `channel` is a MessagePort you will need to manually call [`port.start()`](ht - `logger`: An instance of Pino Logger or a compatible logger. If not provided, no logging will be done. - `onRequestHook: (request: MsgRequestObj, next: (request: MsgRequestObj) => Promise) => void` Optional hook to observe and modify a request and its metadata, and to await the response. -`close()` is used to remove event listeners from the channel. It will not close or destroy the MessagePort used as the `channel`. +`close()` is used to remove event listeners from the channel. It will not close or destroy the MessagePort used as the `channel`. For a server created with a handler factory it also detaches from the current handler and clears the subscription registry. + +`detachHandler()` and `ensureHandler()` are no-ops unless the server was created with a handler factory — see [Late-bound handlers](#late-bound-handlers). + +### Late-bound handlers + +Instead of a handler object, `createServer` accepts a factory function `() => api | Promise`. The server then treats the channel and its event subscriptions as durable, and the handler as a replaceable plug-in: clients keep calling methods and stay subscribed to events on a stable channel, while the object that actually serves them can be released and recreated behind it (e.g. a backend that is torn down when idle and rebuilt on demand). + +The factory is invoked lazily: when the first message that needs a handler arrives — a method call, or an event subscription — or when `ensureHandler()` is called. Concurrent triggers share a single factory invocation. Messages that arrive while no handler is bound wait for the bind, and the server re-attaches every existing event subscription to the new handler _before_ dispatching the waiting messages, so an event caused by the very first call on a fresh handler cannot be missed. Unsubscribing from an event never invokes the factory. If the factory rejects, each waiting call rejects with that error (its `code` is preserved) and the failure is not cached — the next call retries the factory; a waiting subscription stays in the registry and is attached on the next successful bind. Across a detach/re-bind cycle — even when the factory returns the same object again — listeners are removed on detach and re-attached on bind, always in pairs, so they never accumulate. The factory should always settle: while it neither resolves nor rejects, the server retains the messages awaiting the bind indefinitely — clients will time out, but the server-side closures persist until the factory settles. + +The server object has two methods for managing the handler lifecycle (on a server created with a static handler object they are a no-op and an immediate resolve, respectively): + +- `detachHandler()`: removes every listener the server attached to the current handler's emitters and releases the handler reference so it can be garbage collected. The subscription registry is kept, so when a handler is next bound the same subscriptions are re-attached to it. Idempotent. +- `ensureHandler()`: returns a promise that resolves once a handler is bound and the subscription registry is attached to it, invoking the factory if needed; rejects if the factory rejects. Resolves immediately if a handler is already bound. + +A streamed response that is in flight when `detachHandler()` is called runs to completion (or error) against the old handler — it is not cancelled, so the old handler is only released once its in-flight streams end. + +A call still awaiting a bind when the server is closed is answered with an error response (code `RPC_CHANNEL_CLOSED`) once the pending factory invocation settles, rather than being left to time out. ### `const clientApi = createClient(channel, [options])` diff --git a/lib/types.ts b/lib/types.ts index 88ac648..c280237 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,5 +1,5 @@ import type { ErrorObject } from 'serialize-error' -import type { EventEmitter } from 'events' +import type { EventEmitter } from 'node:events' import type { Readable } from 'stream' import type { msgType } from './constants.js' diff --git a/package.json b/package.json index 440e7b5..ac3e716 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "type": "tsc", "format": "prettier --write .", "lint": "eslint --cache .", - "prepare": "husky", + "prepare": "husky && npm run build:types", "build:types": "rimraf \"dist/\" && tsc -p tsconfig.publish.json", "prepack": "npm run build:types" }, diff --git a/server.js b/server.js index 8cac560..72b1baf 100644 --- a/server.js +++ b/server.js @@ -7,9 +7,10 @@ import { validateMetadata, validateRequestMsg } from './lib/validate-message.js' import { parse, stringify } from './lib/prop-array-utils.js' import { MessageStream } from './lib/message-stream.js' import { isMessagePortLike } from './lib/is-message-port-like.js' -import { EventEmitter } from 'events' +import { EventEmitter } from 'node:events' import ensureError from 'ensure-error' import { isMessageEvent } from './lib/is-message-event.js' +import { ChannelClosedError } from './lib/errors.js' /** @import {MsgRequestObj, Result, Metadata, MsgId} from './lib/types.js'*/ /** @typedef {import('./lib/types.js').MsgRequest} MsgRequest */ @@ -23,37 +24,61 @@ import { isMessageEvent } from './lib/is-message-event.js' /** @typedef {import('./lib/types.js').MessageEvent} MessageEvent */ /** @typedef {(request: MsgRequestObj, next: (request: Omit) => Result) => void} OnRequestHook */ /** @typedef {import('./lib/types.js').Logger} Logger */ +/** @typedef {{[method: string]: any}} Handler */ +/** @typedef {() => Handler | Promise} HandlerFactory */ /** * @typedef {object} ServerOptions * @property {false | Logger} [logger = false] options.logger Set to `false` to disable logging, or pass a logger (e.g. a pino instance or the global `console`) to enable it * @property {OnRequestHook} [onRequestHook] Optional hook to observe and modify a request and its metadata, and to await the response. */ +/** + * @typedef {object} Server + * @property {() => void} close Stop the server listening to and sending any more messages. For a server created with a handler factory this also detaches from the current handler and clears the subscription registry. + * @property {() => void} detachHandler Remove every listener the server attached to the current handler's emitters and release the handler reference, keeping the subscription registry so a later bind re-attaches it. Idempotent; no-op for a server created with a static handler. + * @property {() => Promise} ensureHandler Resolves once a handler is bound and the subscription registry is attached to it, invoking the handler factory if needed; rejects if the factory rejects. Resolves immediately for a server created with a static handler. + */ /** * @public * Create an RPC server that will receive messages via `receiver`, call the * matching method on `handler`, and send the reply via `send`. * - * @param {{[method: string]: any}} handler Any method called on the client + * @param {Handler | HandlerFactory} handler Any method called on the client * object will be called on this object. Methods can return a value, a Promise, * or a ReadableStream. Your transport stream must be able to encode/decode any - * values that your handler returns + * values that your handler returns. Pass a function to bind the handler + * lazily: it is invoked (once, shared across concurrent triggers) when the + * first message needing a handler arrives, or when `ensureHandler()` is + * called, and may return the handler or a promise of it. * @param {MessagePortLike} messagePort A MessagePort-like object that must implement an `.addEventListener('message', (event: MessageEvent) => void)` event handler and a `.postMessage()` method. * @param {ServerOptions} [options] Options object - * @returns {{ close: () => void }} An object with a single method `close()` that will stop the server listening to and sending any more messages + * @returns {Server} */ export function createServer( handler, messagePort, { logger = false, onRequestHook } = {}, ) { - invariant(typeof handler === 'object', 'Missing handler object.') + invariant( + typeof handler === 'object' || typeof handler === 'function', + 'Missing handler object or factory.', + ) const log = logger || nullLogger invariant( isMessagePortLike(messagePort), 'Must pass a MessagePort-like object', ) + const createHandler = typeof handler === 'function' ? handler : null + /** @type {Handler | null} */ + let boundHandler = typeof handler === 'function' ? null : handler + /** @type {Promise | null} */ + let bindPromise = null + // Bumped by detachHandler() and close(); a bind that completes under a stale + // epoch discards its result so it cannot resurrect a detached handler. + let bindEpoch = 0 + let closed = false + /** @type {Map void>} */ let subscriptions = new Map() @@ -155,10 +180,40 @@ export function createServer( * @param {MsgRequestObj} request * @returns {Result} */ - function handleRequest({ msgId, method, args }) { + function handleRequest(request) { + const { msgId, method, args } = request + if (!boundHandler) { + const resultPromise = awaitBind().then( + () => { + if (closed) { + // The server closed while this request awaited the bind: respond + // with an error rather than leaving the client to time out. + send([ + msgType.RESPONSE, + msgId, + serializeError(new ChannelClosedError()), + ]) + return + } + // Re-checks the bound state, so if the bind completed under a stale + // epoch (detached mid-bind) this triggers a fresh bind. + return handleRequest(request) + }, + (bindError) => { + send([ + msgType.RESPONSE, + msgId, + serializeError(ensureError(bindError)), + ]) + throw bindError + }, + ) + resultPromise.catch(noop) + return resultPromise + } let syncResult try { - syncResult = applyNestedMethod(handler, method, args) + syncResult = applyNestedMethod(boundHandler, method, args) } catch (error) { send([msgType.RESPONSE, msgId, serializeError(ensureError(error))]) const resultPromise = Promise.reject(error) @@ -206,16 +261,6 @@ export function createServer( /** @param {MsgOn} msg */ function handleOn([, eventName, propArray]) { - let emitter - try { - emitter = getNestedEventEmitter(handler, propArray) - } catch (err) { - log.warn( - { err, eventName, propArray }, - 'Error subscribing to event (ignored)', - ) - return - } const encodedEventName = stringify(propArray, eventName) // If we are already emitting for this event, we can ignore @@ -229,16 +274,51 @@ export function createServer( send([msgType.EMIT, eventName, propArray, null, args]) } } + // Registry-first: the listener captures nothing from the handler, so it + // can be registered while unbound and attached by the bind's registry + // walk. This keeps ON/OFF ordering exact while a bind is in flight, and + // subscription intent survives a factory rejection. subscriptions.set(encodedEventName, listener) - emitter.on(eventName, listener) + + if (!boundHandler) { + awaitBind().catch((err) => { + log.warn( + { err, eventName, propArray }, + 'Error binding handler (subscription retained)', + ) + }) + return + } + try { + getNestedEventEmitter(boundHandler, propArray).on(eventName, listener) + } catch (err) { + log.warn( + { err, eventName, propArray }, + 'Error subscribing to event (ignored)', + ) + return + } log.debug({ eventName, propArray }, 'Subscribed to handler event') } /** @param {MsgOff} msg */ function handleOff([, eventName, propArray]) { - let emitter + const encodedEventName = stringify(propArray, eventName) + + // Fail silently if there is nothing to unsubscribe + const listener = subscriptions.get(encodedEventName) + if (!listener) return + // Delete before the emitter lookup: the unsubscribe must stick even when + // the current handler lacks the emitter, or a later rebind would + // resurrect the subscription. Unsubscribing must not invoke the handler + // factory, so while unbound this is registry-only. + subscriptions.delete(encodedEventName) + if (!boundHandler) return try { - emitter = getNestedEventEmitter(handler, propArray) + getNestedEventEmitter(boundHandler, propArray).removeListener( + eventName, + listener, + ) } catch (err) { log.warn( { err, eventName, propArray }, @@ -246,34 +326,112 @@ export function createServer( ) return } + log.debug({ eventName, propArray }, 'Unsubscribed from handler event') + } - const encodedEventName = stringify(propArray, eventName) + /** + * Single-flight bind: invoke the handler factory (sharing one invocation + * across concurrent triggers) and bind its result. A rejection is not + * cached — the next trigger retries the factory. + * + * @returns {Promise} + */ + function awaitBind() { + if (bindPromise) return bindPromise + const epoch = bindEpoch + bindPromise = Promise.resolve() + .then(/** @type {HandlerFactory} */ (createHandler)) + .then( + (nextHandler) => { + bindPromise = null + if (epoch !== bindEpoch) return + invariant( + typeof nextHandler === 'object' && nextHandler !== null, + 'Handler factory must return an object.', + ) + bindHandler(nextHandler) + }, + (err) => { + bindPromise = null + throw err + }, + ) + return bindPromise + } - // Fail silently if there is nothing to unsubscribe - if (!subscriptions.has(encodedEventName)) return + /** + * Only ever called while unbound: binds start only when no handler is bound + * (single-flight), and a detach mid-bind bumps the epoch so the stale bind + * never reaches here. + * + * @param {Handler} nextHandler + */ + function bindHandler(nextHandler) { + boundHandler = nextHandler + // Attach the subscription registry before any awaited message is + // dispatched, so an event caused by the first call on a fresh handler + // cannot be missed. + for (const [encodedEventName, listener] of subscriptions.entries()) { + const [propArray, eventName] = parse(encodedEventName) + try { + getNestedEventEmitter(nextHandler, propArray).on(eventName, listener) + } catch (err) { + log.warn( + { err, eventName, propArray }, + 'Error subscribing to event (ignored)', + ) + } + } + log.debug( + { subscriptionCount: subscriptions.size }, + 'RPC server bound to handler', + ) + } - const listener = subscriptions.get(encodedEventName) - listener && emitter.removeListener(eventName, listener) - subscriptions.delete(encodedEventName) - log.debug({ eventName, propArray }, 'Unsubscribed from handler event') + /** @param {Handler} fromHandler */ + function detachAllListeners(fromHandler) { + for (const [encodedEventName, listener] of subscriptions.entries()) { + const [propArray, eventName] = parse(encodedEventName) + try { + const emitter = getNestedEventEmitter(fromHandler, propArray) + emitter.removeListener(eventName, listener) + } catch { + // No-op if error removing event listener + } + } + } + + function detachHandler() { + if (!createHandler) return + bindEpoch++ + if (!boundHandler) return + detachAllListeners(boundHandler) + boundHandler = null + log.debug('RPC server detached from handler') + } + + async function ensureHandler() { + if (!createHandler || closed) return + // Loop because a bind can complete under a stale epoch (detached + // mid-bind), leaving the server unbound. + while (!boundHandler && !closed) { + await awaitBind() + } } return { close: () => { + closed = true + bindEpoch++ messagePort.removeEventListener('message', handleMessageEvent) const subscriptionCount = subscriptions.size - for (const [encodedEventName, listener] of subscriptions.entries()) { - const [propArray, eventName] = parse(encodedEventName) - try { - const emitter = getNestedEventEmitter(handler, propArray) - emitter.removeListener(eventName, listener) - } catch { - // No-op if error removing event listener - } - } + if (boundHandler) detachAllListeners(boundHandler) + if (createHandler) boundHandler = null subscriptions = new Map() log.info({ subscriptionCount }, 'RPC server closed') }, + detachHandler, + ensureHandler, } } @@ -330,7 +488,7 @@ function getNestedEventEmitter(target, propArray) { } nested = nested[propertyKey] } - if (!(nested instanceof EventEmitter)) { + if (!isEventEmitterLike(nested)) { throw new TypeError( `${ propArray.length === 0 ? '[target]' : propArray[propArray.length - 1] @@ -339,4 +497,23 @@ function getNestedEventEmitter(target, propArray) { } return nested } + +/** + * A handler built against a different copy of the events module (e.g. the npm + * `events` shim pulled in by a bundler, or a second node_modules tree) fails + * `instanceof`, so fall back to duck-typing the methods the server uses. + * + * @param {unknown} candidate + * @returns {candidate is EventEmitter} + */ +function isEventEmitterLike(candidate) { + if (candidate instanceof EventEmitter) return true + if (typeof candidate !== 'object' || candidate === null) return false + const emitter = /** @type {{[propertyKey: string]: unknown}} */ (candidate) + return ( + typeof emitter.on === 'function' && + typeof emitter.removeListener === 'function' && + typeof emitter.emit === 'function' + ) +} function noop() {} diff --git a/test/e2e.test.js b/test/e2e.test.js index 27c1e3f..fb3f53b 100644 --- a/test/e2e.test.js +++ b/test/e2e.test.js @@ -1,7 +1,7 @@ // @ts-check import test from 'tape' import { createClient, createServer, TimeoutError } from '../index.js' -import { EventEmitter } from 'events' +import { EventEmitter } from 'node:events' import { EventEmitter as EventEmitter3 } from 'eventemitter3' import { readFileSync, createReadStream } from 'fs' import { join } from 'path' diff --git a/test/late-bound-handler.test.js b/test/late-bound-handler.test.js new file mode 100644 index 0000000..484fa33 --- /dev/null +++ b/test/late-bound-handler.test.js @@ -0,0 +1,640 @@ +// @ts-check +import test from 'tape' +import { EventEmitter } from 'node:events' + +import { createClient, createServer } from '../index.js' +import { msgType } from '../lib/constants.js' +import { + MessagePortLike, + MessagePortLikePair, + createTestLogger, +} from './helpers.js' + +/** + * @template {{}} ApiType + * @typedef {import('../lib/types.js').ClientApi} ClientApi + */ + +/** @param {number} ms */ +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** @param {string} name */ +function createEmitterApi(name) { + const api = Object.assign(new EventEmitter(), { + whoami: () => name, + /** @param {string} value */ + mutate: (value) => { + api.emit('changed', value) + return name + }, + }) + return api +} + +/** + * @param {import('tape').Test} t + * @param {import('../server.js').HandlerFactory} factory + */ +function setup(t, factory) { + const { port1: serverPort, port2: clientPort } = new MessagePortLikePair() + const client = /** @type {ClientApi>} */ ( + createClient(clientPort) + ) + const server = createServer(factory, serverPort) + t.teardown(() => { + createClient.close(client) + server.close() + }) + return { client, server, serverPort, clientPort } +} + +test('Late-bound: handler swap round-trip', async (t) => { + const apiA = createEmitterApi('A') + const apiB = createEmitterApi('B') + let current = apiA + let factoryCalls = 0 + const { client, server } = setup(t, () => { + factoryCalls++ + return current + }) + + /** @type {string[]} */ + const received = [] + client.on('changed', (value) => received.push(value)) + t.equal(await client.whoami(), 'A', 'Call works against handler A') + apiA.emit('changed', 'from A') + await delay(0) + t.deepEqual(received, ['from A'], 'Events flow from handler A') + + server.detachHandler() + t.equal( + apiA.listenerCount('changed'), + 0, + 'Detach removes forwarding listeners from A', + ) + current = apiB + + t.equal(await client.whoami(), 'B', 'Next call works against handler B') + t.equal( + apiB.listenerCount('changed'), + 1, + 'Subscription registry re-attached to B', + ) + apiB.emit('changed', 'from B') + await delay(0) + t.deepEqual( + received, + ['from A', 'from B'], + 'Previously-subscribed events from B are delivered', + ) + t.equal(factoryCalls, 2, 'Factory invoked once per bind') + + server.close() + t.equal( + apiB.listenerCount('changed'), + 0, + 'close() detaches from the current handler', + ) + t.end() +}) + +test('Late-bound: concurrent calls while unbound share one factory invocation', async (t) => { + let factoryCalls = 0 + const { client } = setup(t, async () => { + factoryCalls++ + await delay(10) + return createEmitterApi('A') + }) + + const results = await Promise.all( + Array.from({ length: 5 }, () => client.whoami()), + ) + t.deepEqual( + results, + ['A', 'A', 'A', 'A', 'A'], + 'All concurrent calls resolve', + ) + t.equal(factoryCalls, 1, 'Factory invoked exactly once') + t.end() +}) + +test('Late-bound: registry is attached before awaited calls are dispatched', async (t) => { + const { client } = setup(t, async () => { + await delay(10) + return createEmitterApi('A') + }) + + /** @type {string[]} */ + const received = [] + // Subscribe and call in the same tick against an unbound server. The event + // is emitted synchronously during the call's handler execution, so it is + // only delivered if the subscription was attached before dispatch. + client.on('changed', (value) => received.push(value)) + await client.mutate('sync emit') + await delay(0) + t.deepEqual( + received, + ['sync emit'], + 'Event emitted synchronously during the first call is delivered', + ) + t.end() +}) + +test('Late-bound: ensureHandler() resolves after registry attach', async (t) => { + const api = createEmitterApi('A') + let factoryCalls = 0 + const { client, server, clientPort } = setup(t, () => { + factoryCalls++ + return api + }) + + /** @type {string[]} */ + const received = [] + client.on('changed', (value) => received.push(value)) + await client.whoami() + t.equal(factoryCalls, 1, 'Bound after first call') + + await server.ensureHandler() + t.equal(factoryCalls, 1, 'ensureHandler() resolves immediately when bound') + + server.detachHandler() + /** @type {unknown[]} */ + const frames = [] + clientPort.addEventListener('message', (event) => frames.push(event.data)) + await server.ensureHandler() + api.emit('changed', 'after ensure') + await delay(0) + t.equal( + factoryCalls, + 2, + 'ensureHandler() re-invokes the factory when unbound', + ) + t.deepEqual(received, ['after ensure'], 'Event after resolve is delivered') + t.deepEqual( + frames, + [[msgType.EMIT, 'changed', [], null, ['after ensure']]], + 'Only the EMIT frame was sent — no other frames needed', + ) + t.end() +}) + +test('Late-bound: ensureHandler() rejects on factory rejection without caching it', async (t) => { + let shouldFail = true + let factoryCalls = 0 + const { server } = setup(t, () => { + factoryCalls++ + if (shouldFail) throw new Error('FactoryError') + return createEmitterApi('A') + }) + + try { + await server.ensureHandler() + t.fail('Expected rejection') + } catch (err) { + t.equal( + /** @type {Error} */ (err).message, + 'FactoryError', + 'ensureHandler() rejects with the factory error', + ) + } + shouldFail = false + await server.ensureHandler() + t.equal(factoryCalls, 2, 'The failure is not cached — the factory is retried') + t.end() +}) + +test('Late-bound: factory rejection rejects awaited calls and is retried', async (t) => { + let shouldFail = true + let factoryCalls = 0 + const { client } = setup(t, async () => { + factoryCalls++ + if (shouldFail) { + throw Object.assign(new Error('BackendGone'), { code: 'EBACKENDGONE' }) + } + return createEmitterApi('A') + }) + + try { + await client.whoami() + t.fail('Expected rejection') + } catch (err) { + t.equal( + /** @type {Error} */ (err).message, + 'BackendGone', + 'Pending call rejects with the serialized factory error', + ) + t.equal( + /** @type {any} */ (err).code, + 'EBACKENDGONE', + 'Error code is preserved', + ) + } + + shouldFail = false + t.equal(await client.whoami(), 'A', 'Next call retries the factory and works') + t.equal(factoryCalls, 2, 'Factory invoked again on the next trigger') + t.end() +}) + +test('Late-bound: subscription is retained when the factory rejects', async (t) => { + const api = createEmitterApi('A') + let shouldFail = true + let factoryCalls = 0 + /** @type {unknown[]} */ + const warnings = [] + const logger = createTestLogger({ + warn: (_obj, msg) => warnings.push(msg), + }) + const serverPort = new MessagePortLike(() => {}) + const server = createServer( + async () => { + factoryCalls++ + if (shouldFail) throw new Error('FactoryError') + return api + }, + serverPort, + { logger }, + ) + t.teardown(() => server.close()) + + serverPort.dispatchEvent( + new MessageEvent('message', { data: [msgType.ON, 'changed', []] }), + ) + await delay(10) + t.equal(factoryCalls, 1, 'Factory was invoked by the subscribe') + t.deepEqual( + warnings, + ['Error binding handler (subscription retained)'], + 'Failed bind is logged', + ) + + shouldFail = false + await server.ensureHandler() + t.equal( + api.listenerCount('changed'), + 1, + 'Subscription intent survives the rejection and attaches on the next successful bind', + ) + t.end() +}) + +test('Late-bound: subscribe then unsubscribe while unbound leaves no subscription', async (t) => { + const api = createEmitterApi('A') + let factoryCalls = 0 + const { client, server } = setup(t, async () => { + factoryCalls++ + await delay(10) + return api + }) + + const listener = () => {} + client.on('changed', listener) + client.off('changed', listener) + await server.ensureHandler() + t.equal(factoryCalls, 1, 'Factory invoked once (by the subscribe)') + t.equal( + api.listenerCount('changed'), + 0, + 'ON then OFF within the unbound window attaches nothing after bind', + ) + t.end() +}) + +test('Late-bound: re-attach failure on the new handler is logged and ignored', async (t) => { + const apiA = createEmitterApi('A') + const notAnEmitter = { whoami: () => 'B' } + /** @type {import('../server.js').Handler} */ + let current = apiA + /** @type {unknown[]} */ + const warnings = [] + const logger = createTestLogger({ + warn: (...args) => warnings.push(args), + }) + const { port1: serverPort, port2: clientPort } = new MessagePortLikePair() + const client = /** @type {ClientApi} */ ( + createClient(clientPort) + ) + const server = createServer(() => current, serverPort, { logger }) + t.teardown(() => { + createClient.close(client) + server.close() + }) + + client.on('changed', () => {}) + await client.whoami() + t.equal(apiA.listenerCount('changed'), 1, 'Subscribed on handler A') + + server.detachHandler() + current = notAnEmitter + await server.ensureHandler() + t.equal(warnings.length, 1, 'Failed re-attach is logged') + t.equal( + await client.whoami(), + 'B', + 'Calls still work on a handler without the emitter', + ) + t.end() +}) + +test('Late-bound: unsubscribe sticks when the current handler lacks the emitter', async (t) => { + const apiA = createEmitterApi('A') + const notAnEmitter = { whoami: () => 'B' } + /** @type {import('../server.js').Handler} */ + let current = apiA + const { client, server } = setup(t, () => current) + + const listener = () => {} + client.on('changed', listener) + await client.whoami() + t.equal(apiA.listenerCount('changed'), 1, 'Subscribed on handler A') + + server.detachHandler() + current = notAnEmitter + await server.ensureHandler() + // The unsubscribe arrives while the bound handler has no emitter for the + // event: it must still remove the registry entry, or a later rebind to A + // would resurrect a subscription the client no longer has. + client.off('changed', listener) + + server.detachHandler() + current = apiA + await server.ensureHandler() + t.equal( + apiA.listenerCount('changed'), + 0, + 'Unsubscribed event is not resurrected on a later rebind', + ) + t.end() +}) + +test('Late-bound: registry re-attach lands before the first call on a fresh handler', async (t) => { + const api = createEmitterApi('A') + const { client, server } = setup(t, async () => { + await delay(10) + return api + }) + + /** @type {string[]} */ + const received = [] + client.on('changed', (value) => received.push(value)) + await client.whoami() + server.detachHandler() + + // The first call on the fresh handler emits synchronously during its + // execution: it is only delivered if the bind's registry walk re-attached + // the subscription before dispatching the awaited call. + await client.mutate('after rebind') + await delay(0) + t.deepEqual( + received, + ['after rebind'], + 'Event emitted synchronously during the first call after rebind is delivered', + ) + t.end() +}) + +test('Late-bound: unsubscribing while unbound does not invoke the factory', async (t) => { + let factoryCalls = 0 + const api = createEmitterApi('A') + const serverPort = new MessagePortLike(() => {}) + const server = createServer(() => { + factoryCalls++ + return api + }, serverPort) + t.teardown(() => server.close()) + + serverPort.dispatchEvent( + new MessageEvent('message', { data: [msgType.OFF, 'changed', []] }), + ) + await delay(10) + t.equal(factoryCalls, 0, 'OFF with an empty registry does not bind') + + // A subscription left in the registry from a previous bind is also removed + // registry-only: after a later bind it must not be re-attached. + serverPort.dispatchEvent( + new MessageEvent('message', { data: [msgType.ON, 'changed', []] }), + ) + await delay(10) + t.equal(factoryCalls, 1, 'Subscribing binds a handler') + t.equal(api.listenerCount('changed'), 1, 'Subscribed on the handler') + + server.detachHandler() + serverPort.dispatchEvent( + new MessageEvent('message', { data: [msgType.OFF, 'changed', []] }), + ) + await delay(10) + t.equal(factoryCalls, 1, 'OFF while unbound does not invoke the factory') + + await server.ensureHandler() + t.equal( + api.listenerCount('changed'), + 0, + 'Registry-only removal: subscription is not re-attached on the next bind', + ) + t.end() +}) + +test('Late-bound: detach during a pending bind discards the stale bind', async (t) => { + const apiA = createEmitterApi('A') + const apiB = createEmitterApi('B') + /** @type {(api: ReturnType) => void} */ + let resolveFactory = () => {} + let factoryCalls = 0 + const { client, server } = setup(t, () => { + factoryCalls++ + if (factoryCalls === 1) { + return new Promise((resolve) => { + resolveFactory = resolve + }) + } + return apiB + }) + + /** @type {string[]} */ + const received = [] + client.on('changed', (value) => received.push(value)) + const pendingCall = client.whoami() + // The factory is invoked in a microtask, so wait a tick for the bind to be + // in flight before detaching mid-bind. + await delay(0) + t.equal(factoryCalls, 1, 'Factory invoked by the awaited frames') + + server.detachHandler() + resolveFactory(apiA) + + t.equal(await pendingCall, 'B', 'Awaited call re-triggers a fresh bind') + t.equal(factoryCalls, 2, 'Fresh bind invoked the factory again') + t.equal(apiA.listenerCount('changed'), 0, 'Stale bind did not attach to A') + t.equal(apiB.listenerCount('changed'), 1, 'Fresh bind attached to B') + apiB.emit('changed', 'from B') + await delay(0) + t.deepEqual( + received, + ['from B'], + 'Events flow from the freshly bound handler', + ) + t.end() +}) + +test('Late-bound: close() during a pending bind responds to awaited calls', async (t) => { + /** @type {(api: ReturnType) => void} */ + let resolveFactory = () => {} + const { client, server } = setup( + t, + () => + new Promise((resolve) => { + resolveFactory = resolve + }), + ) + + const pendingCall = client.whoami() + await delay(0) + server.close() + resolveFactory(createEmitterApi('A')) + + try { + await pendingCall + t.fail('Expected rejection') + } catch (err) { + t.equal( + /** @type {any} */ (err).code, + 'RPC_CHANNEL_CLOSED', + 'Awaited call rejects with RPC_CHANNEL_CLOSED instead of timing out', + ) + } + t.end() +}) + +test('Late-bound: close() during a pending bind that rejects responds with the factory error', async (t) => { + /** @type {(err: Error) => void} */ + let rejectFactory = () => {} + const { client, server } = setup( + t, + () => + new Promise((_resolve, reject) => { + rejectFactory = reject + }), + ) + + const pendingCall = client.whoami() + await delay(0) + server.close() + rejectFactory(Object.assign(new Error('FactoryError'), { code: 'EFACTORY' })) + + try { + await pendingCall + t.fail('Expected rejection') + } catch (err) { + t.equal( + /** @type {any} */ (err).code, + 'EFACTORY', + 'Awaited call rejects with the factory error', + ) + } + t.end() +}) + +test('Late-bound: rebinding the same object pairs every attach with a detach', async (t) => { + const api = createEmitterApi('A') + let onCalls = 0 + const originalOn = api.on.bind(api) + api.on = (eventName, listener) => { + onCalls++ + return originalOn(eventName, listener) + } + let factoryCalls = 0 + const { client, server } = setup(t, () => { + factoryCalls++ + return api + }) + + client.on('changed', () => {}) + await client.whoami() + t.equal(onCalls, 1, 'Subscription attached once on first bind') + + await server.ensureHandler() + await server.ensureHandler() + await client.whoami() + t.equal(factoryCalls, 1, 'Factory not re-invoked while bound') + t.equal(onCalls, 1, 'No re-attach while the same handler stays bound') + + // Each detach removes the listeners the previous bind attached, so + // re-binding the same object never accumulates listeners. + for (let i = 0; i < 3; i++) { + server.detachHandler() + await server.ensureHandler() + t.equal(api.listenerCount('changed'), 1, 'Listener count never exceeds 1') + } + t.equal(factoryCalls, 4, 'Factory invoked once per re-bind') + t.end() +}) + +test('Late-bound: no reserved names on static handlers', async (t) => { + const staticApi = { + resolveHandler: () => 'a', + detachHandler: () => 'b', + ensureHandler: () => 'c', + } + const { port1: serverPort, port2: clientPort } = new MessagePortLikePair() + const client = /** @type {ClientApi} */ ( + createClient(clientPort) + ) + const server = createServer(staticApi, serverPort) + t.teardown(() => { + createClient.close(client) + server.close() + }) + + t.equal(await client.resolveHandler(), 'a', 'resolveHandler reflects') + t.equal(await client.detachHandler(), 'b', 'detachHandler reflects') + t.equal(await client.ensureHandler(), 'c', 'ensureHandler reflects') + + server.detachHandler() + await server.ensureHandler() + t.equal( + await client.resolveHandler(), + 'a', + 'Server detachHandler()/ensureHandler() are no-ops for a static handler', + ) + t.end() +}) + +test('Late-bound: detachHandler() releases the handler reference', async (t) => { + /** @type {WeakRef | undefined} */ + let handlerRef + let strongApi = createEmitterApi('A') + const { client, server } = setup(t, () => { + handlerRef = new WeakRef(strongApi) + return strongApi + }) + + client.on('changed', () => {}) + t.equal(await client.whoami(), 'A', 'Call works while bound') + t.equal(strongApi.listenerCount('changed'), 1, 'Listener attached') + + server.detachHandler() + server.detachHandler() + t.equal( + strongApi.listenerCount('changed'), + 0, + 'No listeners left on the old handler (detach is idempotent)', + ) + + // Only the WeakRef should be left holding the old handler. + strongApi = createEmitterApi('B') + if (typeof global.gc === 'function') { + global.gc() + await delay(10) + global.gc() + t.equal( + handlerRef && handlerRef.deref(), + undefined, + 'Old handler is garbage collected after detach', + ) + } else { + t.pass('global.gc not available (run with --expose-gc for the GC check)') + } + t.end() +}) diff --git a/test/server.test.js b/test/server.test.js index 98661c0..23aeeed 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -1,9 +1,13 @@ //@ts-nocheck import test from 'tape' -import { createServer } from '../index.js' +import { createClient, createServer } from '../index.js' import invalidMessages from './fixtures/invalid-messages.js' -import { MessagePortLike, createTestLogger } from './helpers.js' +import { + MessagePortLike, + MessagePortLikePair, + createTestLogger, +} from './helpers.js' test('Ignores invalid messages', (t) => { t.plan(1) @@ -37,6 +41,55 @@ test('Ignores invalid messages', (t) => { setTimeout(() => t.pass('Ignored all invalid messages'), 100) }) +test('Subscribes to a duck-typed emitter that is not instanceof EventEmitter', async (t) => { + // Simulates a handler built against a different copy of the events module + // (e.g. the npm `events` shim under a bundler): a minimal emitter that + // implements on/removeListener/emit but is not instanceof EventEmitter. + const listeners = new Map() + const plainEmitter = { + on(eventName, fn) { + let set = listeners.get(eventName) + if (!set) listeners.set(eventName, (set = new Set())) + set.add(fn) + }, + removeListener(eventName, fn) { + listeners.get(eventName)?.delete(fn) + }, + emit(eventName, ...args) { + for (const fn of listeners.get(eventName) ?? []) fn(...args) + }, + } + const { port1, port2 } = new MessagePortLikePair() + const client = createClient(port2) + const server = createServer(plainEmitter, port1) + t.teardown(() => { + createClient.close(client) + server.close() + }) + + const received = [] + const listener = (value) => received.push(value) + client.on('myEvent', listener) + await delay(10) + t.equal(listeners.get('myEvent')?.size, 1, 'Server subscribed on the emitter') + + plainEmitter.emit('myEvent', 'carrot') + await delay(10) + t.deepEqual(received, ['carrot'], 'Event flows to the client') + + client.off('myEvent', listener) + await delay(10) + t.equal(listeners.get('myEvent')?.size, 0, 'Server unsubscribed') + plainEmitter.emit('myEvent', 'ignored') + await delay(10) + t.deepEqual(received, ['carrot'], 'No event after unsubscribe') + t.end() +}) + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + test('Ignores a message that is not a MessageEvent', (t) => { t.plan(1) const port = new MessagePortLike(() => {})