diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index abbe608..6791bbd 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,3 +1,3 @@ # GitHub Copilot instructions -Before proposing or making changes, read the root `AGENTS.md` and follow its project purpose, naming, package, protocol, and contribution guidance. Keep changes suitable for a public Node.js package. +Before proposing or making changes, read the root `AGENTS.md` and follow its project purpose, naming, package, protocol, and contribution guidance. Keep changes suitable for a public, dependency-free JavaScript package. diff --git a/AGENTS.md b/AGENTS.md index 617aaff..e02e067 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,13 +2,13 @@ ## Project purpose -InputKit is a public Node.js client library for connecting JavaScript and TypeScript projects to Work Louder Input through Input's WebSocket interface. It is maintained separately from the Input desktop application and the app-building SDK. +InputKit is a public JavaScript client library for connecting JavaScript and TypeScript projects to Work Louder Input through Input's WebSocket interface. It runs on Node.js 22.4 and later and on any runtime with a standard global `WebSocket`. It is maintained separately from the Input desktop application and the app-building SDK. The intended npm package name is `@worklouder/input-kit`. ## Naming and documentation -- Call this project **InputKit** and describe it as a **Node.js client library**. +- Call this project **InputKit** and describe it as a **JavaScript client library**. - Call the remote interface the **Input WebSocket API**. - Use **WebSocket API** for the wire-level contract and **InputKit** for this package. - Avoid using “Input SDK” by itself because it can be confused with the app-building SDK. @@ -17,7 +17,7 @@ The intended npm package name is `@worklouder/input-kit`. - Use Yarn for dependency management and project scripts. Keep the Yarn lockfile committed once package metadata is added; do not introduce npm or pnpm lockfiles. - Treat this as an independently installable public package. Do not depend on private modules, build output, or Electron internals from the Input application. -- Keep the package focused on Node.js consumers. Avoid adding runtime dependencies unless they materially improve the public client experience. +- Runtime dependencies are not allowed. Use only standard JavaScript and web platform globals (`WebSocket`, `TextEncoder`, timers) in `src`; do not import `node:` modules or use Node.js-only globals such as `Buffer`. Development dependencies, such as the `ws` test server, are fine. - Do not invent or silently change the WebSocket message format. Confirm protocol behavior against its authoritative implementation or specification, and document compatibility assumptions. - Treat WebSocket messages and connection failures as external input: validate data at the boundary and provide actionable errors to library users. - Keep examples safe to publish. Never include credentials, private endpoints, personal data, or machine-specific paths; use clearly fake placeholders. diff --git a/README.md b/README.md index 66ae485..d2376b7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # InputKit -InputKit is a Node.js client library for JavaScript and TypeScript applications that connect to the local Input WebSocket API. It supports ESM and CommonJS on Node.js 22 and later. +InputKit is a JavaScript client library for JavaScript and TypeScript applications that connect to the local Input WebSocket API. It has no runtime dependencies and uses the standard global `WebSocket`, so it runs on Node.js 22.4 and later and on any runtime with a standard `WebSocket`. It ships ESM and CommonJS builds. Input must be running for a client to connect. On the first connection, call `requestAuthorization()` to ask the user to approve the application. Store the returned credentials in your application's secure storage. @@ -14,11 +14,11 @@ Or with npm: npm install @worklouder/input-kit -InputKit requires Node.js 22 or later. +On Node.js, InputKit requires version 22.4 or later, the first release with a stable global `WebSocket`. Other runtimes need a standard global `WebSocket` and `TextEncoder`; `connect()` rejects with `InputConnectionError` if `WebSocket` is missing. ## Local development -From this repository, run `yarn install` followed by `yarn build`. Run `yarn test` to build the package and test it against a local WebSocket server. +From this repository, run `yarn install` followed by `yarn build`. Run `yarn test` to build the package and test it against a local WebSocket server. The `ws` package is a development dependency used only by that test server. ## Quick start @@ -51,6 +51,8 @@ const { InputClient } = require('@worklouder/input-kit'); ESM and CommonJS load separate copies of the exported classes. When using `instanceof`, get the constructor from the same entry point as the value being checked. +`InputClient` uses a small built-in typed event emitter with `on`, `once`, `off`, and `emit`. It is not a Node.js `EventEmitter`, so helpers such as `events.once()` from `node:events` do not work with it; wrap `input.once()` in a promise instead. + ## Example CLI With Input running, run `yarn example` to request authorization or authenticate, print device and active profile/layer information, and watch events. Run `yarn example devices` to print the current state and exit. The CLI saves its demo token as a local plaintext JSON file; production integrations should use operating system secure credential storage. @@ -69,4 +71,6 @@ InputKit sends one request at a time with at least 400 ms between sends and caps This proof of concept follows the current pre-production Input WebSocket API. It connects only to loopback hosts, at `ws://127.0.0.1:19147/input` by default. Input supports the WebSocket API for Creator Micro V2 and Codex Micro when the device is connected and its keymap is ready. The protocol can change before production. -Authorization returns a bearer token once. Do not log it or commit it. Authenticate with the same connection origin you used for authorization. Node.js clients connect without an `Origin` header by default. +Authorization returns a bearer token once. Do not log it or commit it. Authenticate with the same connection origin you used for authorization. Node.js clients connect without an `Origin` header. Browsers always send the page's origin, and Input ties authorization to the origin, so a browser application needs its own pairing; credentials from a Node.js pairing do not carry over. + +The standard `WebSocket` client offers `permessage-deflate` compression and cannot be configured not to. Input declines the offer, so messages are sent uncompressed. diff --git a/docs/API.md b/docs/API.md index d13e548..b491d63 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # InputKit API Reference -InputKit is a Node.js wrapper around the current Input WebSocket API. Input must be running, and each client must request authorization once before it can authenticate on later connections. The API is pre-production and follows the protocol documented by the Input application. +InputKit is a dependency-free JavaScript client for the current Input WebSocket API. It runs on Node.js 22.4 and later and on any runtime with a standard global `WebSocket`. Input must be running, and each client must request authorization once before it can authenticate on later connections. The API is pre-production and follows the protocol documented by the Input application. ## Create a client @@ -24,6 +24,8 @@ const input = new InputClient({ Opens the WebSocket connection. It does not request authorization or authenticate automatically. +`connect()` rejects with `InputConnectionError` when Input is not listening, when Input closes the connection before it opens, when the WebSocket handshake does not complete within 5 seconds, or when the runtime has no global `WebSocket`. Calling `close()` while connecting rejects the pending `connect()`. + ### `close(): Promise` Closes the connection and rejects queued and in-flight requests with `InputConnectionError`. A disconnect also rejects outstanding requests. A new connection starts with an empty queue. @@ -49,7 +51,7 @@ Store these credentials in the calling application's secure storage. InputKit do ### `authenticate(credentials: AuthorizationCredentials): Promise` -Authenticates a previously authorized application. The saved permission must allow the connection origin used by the client. +Authenticates a previously authorized application. The saved permission must allow the connection origin used by the client. Node.js clients send no `Origin` header. Browsers always send the page's origin, so a browser application needs its own authorization rather than reusing credentials from a Node.js pairing. ## Requests @@ -145,6 +147,8 @@ To select a profile and layer after adding a layer, call `setActiveProfileLayer` The server broadcasts events to all authenticated clients; there is no subscription request. Filter by `deviceId` in the application when needed. +`InputClient` provides `on(event, listener)`, `once(event, listener)`, `off(event, listener)`, and `emit(event, payload)`. Each event has a single typed payload, and each method except `emit` returns the client for chaining. `InputClient` is not a Node.js `EventEmitter`: `events.once()` and `removeListener` are not supported, and an emitted error event with no listener does not throw. + ### `devicesUpdated` Emitted with `{ devices: DeviceSummary[] }` when the ready-device list changes. @@ -163,9 +167,9 @@ Emitted with `{ method, params }` for every valid server notification. This incl ### Connection and protocol events -- `disconnected` provides `{ code, reason }` when the current connection closes. -- `transportError`: a WebSocket transport `Error`. -- `protocolError`: an `InputProtocolError` when Input sends malformed JSON-RPC or event data. +- `disconnected` provides `{ code, reason }` when the current connection closes. A connection that fails or is aborted before it opens reports code `1006`. +- `transportError`: a WebSocket transport `Error`. Standard WebSocket error events carry no detail, so the error message can be generic. +- `protocolError`: an `InputProtocolError` when Input sends malformed JSON-RPC or event data. If Input sends a binary frame, InputKit also closes the connection with application close code `4003` and reason `Text messages only`. ## Errors diff --git a/docs/architecture.md b/docs/architecture.md index a25a2f3..0f00207 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,9 +1,10 @@ # Package Architecture -InputKit has two boundaries: the public Node.js client API and the Input WebSocket protocol. The package contains no Electron or private Input application imports. +InputKit has two boundaries: the public JavaScript client API and the Input WebSocket protocol. The package contains no Electron or private Input application imports and has no runtime dependencies. Source files use only standard JavaScript and web platform globals, so the package runs on Node.js 22.4 and later and on any runtime with a standard global `WebSocket`. ## Module responsibilities +- `event-emitter.ts` provides the small typed emitter (`on`, `once`, `off`, `emit`) used by `InputClient` and the transport in place of `node:events`. - `input-client.ts` exposes the stable, user-facing `InputClient` methods. It translates typed method calls into protocol requests, validates API results, and emits the public event names. - `transport/json-rpc-transport.ts` owns the WebSocket connection, JSON-RPC request IDs, UTF-8 framing, request queue, response timeouts, response correlation, and transport failures. - `notifications.ts` validates and decodes server notification payloads into typed event data. @@ -12,6 +13,14 @@ InputKit has two boundaries: the public Node.js client API and the Input WebSock - `errors.ts` defines errors consumers can catch without depending on transport internals. - `index.ts` is the package export barrel. Internal transport details stay private. +## Transport + +The transport uses `globalThis.WebSocket`, the standard client built into Node.js 22.4+ and browsers, and fails `connect()` with a clear `InputConnectionError` when it is missing. Because the standard client has no handshake timeout option, the transport runs its own 5-second timer. When the timer fires, or `close()` is called while connecting, the transport rejects `connect()`, detaches the socket, reports `disconnected` with code `1006`, and closes the socket without waiting for a `close` event, because some runtimes never deliver one for a socket closed while connecting. + +The socket uses `binaryType = 'arraybuffer'`. Text frames arrive as strings; any other message is treated as binary, reported as a `protocolError`, and the connection is closed with application code `4003`. The standard `close()` accepts only `1000` or `3000`–`4999`, so the protocol-level `1003` code cannot be used. Request size is measured with `TextEncoder`. + +The standard client always offers `permessage-deflate`; Input declines it. Node.js sends no `Origin` header, while browsers always send one. Input ties authorization to the origin, so a browser needs its own pairing. + ## Request lifecycle The transport checks the serialized frame against Input's 512 KiB limit before queueing it. It holds one active request per connection and spaces sends by at least 400 ms to stay under Input's 30-request limit per rolling 10 seconds. The response timer starts at send time. `InputClient` supplies the 29-second authorization timeout and the configurable 120-second `addLayer` default. The ordinary response timeout defaults to 10 seconds. diff --git a/package.json b/package.json index 595560b..094a08a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@worklouder/input-kit", "version": "0.1.0", - "description": "Node.js client library for Work Louder Input", + "description": "Dependency-free JavaScript client library for Work Louder Input", "license": "Apache-2.0", "repository": { "type": "git", @@ -17,7 +17,8 @@ "websocket", "client", "hardware", - "macropad" + "macropad", + "zero-dependency" ], "type": "module", "main": "./dist/cjs/index.js", @@ -44,7 +45,7 @@ ], "sideEffects": false, "engines": { - "node": ">=22" + "node": ">=22.4.0" }, "packageManager": "yarn@1.22.22", "publishConfig": { @@ -57,12 +58,9 @@ "release": "node scripts/release.mjs", "prepack": "yarn build" }, - "dependencies": { - "ws": "^8.21.3" - }, "devDependencies": { "@types/node": "^24.9.0", - "@types/ws": "^8.18.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "ws": "^8.21.3" } } diff --git a/src/event-emitter.ts b/src/event-emitter.ts new file mode 100644 index 0000000..98d6960 --- /dev/null +++ b/src/event-emitter.ts @@ -0,0 +1,70 @@ +type Listener = (event: T) => void; + +interface Registration { + listener: Listener; + once: boolean; +} + +/** + * Minimal typed event emitter with no runtime dependencies. + * + * Each event carries a single payload. Listeners run synchronously in + * registration order; a listener that throws stops later listeners for that + * emit and the error propagates to the emitter. + */ +export class TypedEventEmitter { + private readonly listeners = new Map[]>(); + + /** Adds a listener for every future `eventName` event. */ + public on(eventName: K, listener: Listener): this { + return this.add(eventName, listener, false); + } + + /** Adds a listener that is removed after the next `eventName` event. */ + public once(eventName: K, listener: Listener): this { + return this.add(eventName, listener, true); + } + + /** Removes the most recently added registration of `listener` for `eventName`. */ + public off(eventName: K, listener: Listener): this { + const registrations = this.listeners.get(eventName); + if (!registrations) return this; + for (let index = registrations.length - 1; index >= 0; index -= 1) { + if (registrations[index].listener === listener) { + registrations.splice(index, 1); + break; + } + } + if (registrations.length === 0) this.listeners.delete(eventName); + return this; + } + + /** Calls the listeners for `eventName`. Returns whether any listener was called. */ + public emit(eventName: K, event: Events[K]): boolean { + const registrations = this.listeners.get(eventName); + if (!registrations) return false; + for (const registration of [...registrations]) { + if (registration.once) this.remove(eventName, registration); + registration.listener(event); + } + return true; + } + + private add(eventName: K, listener: Listener, once: boolean): this { + if (typeof listener !== 'function') { + throw new TypeError('listener must be a function'); + } + const registrations = this.listeners.get(eventName); + if (registrations) registrations.push({ listener, once }); + else this.listeners.set(eventName, [{ listener, once }]); + return this; + } + + private remove(eventName: keyof Events, registration: Registration): void { + const registrations = this.listeners.get(eventName); + if (!registrations) return; + const index = registrations.indexOf(registration); + if (index !== -1) registrations.splice(index, 1); + if (registrations.length === 0) this.listeners.delete(eventName); + } +} diff --git a/src/input-client.ts b/src/input-client.ts index 19d5854..cc4ee87 100644 --- a/src/input-client.ts +++ b/src/input-client.ts @@ -1,4 +1,4 @@ -import { EventEmitter } from 'node:events'; +import { TypedEventEmitter } from './event-emitter.js'; import { decodeInputNotification } from './notifications.js'; import { JsonRpcTransport } from './transport/json-rpc-transport.js'; import { InputProtocolError } from './errors.js'; @@ -31,7 +31,7 @@ const MAX_APPLICATION_NAME_LENGTH = 80; const DISALLOWED_APPLICATION_NAME_CHARACTERS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u; /** Public client for Input's local WebSocket API. */ -export class InputClient extends EventEmitter { +export class InputClient extends TypedEventEmitter { private readonly applicationName: string; private readonly addLayerTimeoutMs: number; private readonly transport: JsonRpcTransport; @@ -79,14 +79,6 @@ export class InputClient extends EventEmitter { return this.transport.connected; } - public on( - eventName: K, - listener: (event: InputClientEvents[K]) => void, - ): this; - public override on(eventName: string | symbol, listener: (...args: any[]) => void): this { - return super.on(eventName, listener); - } - public connect(): Promise { return this.transport.connect(); } diff --git a/src/transport/json-rpc-transport.ts b/src/transport/json-rpc-transport.ts index 84c28b3..b22c47d 100644 --- a/src/transport/json-rpc-transport.ts +++ b/src/transport/json-rpc-transport.ts @@ -1,5 +1,4 @@ -import { EventEmitter } from 'node:events'; -import WebSocket, { type RawData } from 'ws'; +import { TypedEventEmitter } from '../event-emitter.js'; import { InputApiError, InputConnectionError, @@ -17,6 +16,18 @@ interface TransportEvents { } const MAX_REQUEST_BYTES = 512 * 1024; +// The standard WebSocket client has no handshake timeout option. +const HANDSHAKE_TIMEOUT_MS = 5_000; +// Application close code sent when Input sends a binary frame. The standard +// close() only accepts 1000 or 3000-4999, so 1003 cannot be used. +const UNSUPPORTED_DATA_CLOSE_CODE = 4003; +// WebSocket readyState values, from the WebSocket standard. +const CONNECTING = 0; +const OPEN = 1; +const CLOSING = 2; +const CLOSED = 3; + +const utf8Encoder = new TextEncoder(); // Input allows 30 requests per connection in a rolling 10-second window. const REQUEST_SPACING_MS = 400; @@ -45,11 +56,12 @@ export interface JsonRpcTransportOptions { } /** Owns the WebSocket connection and JSON-RPC framing/request lifecycle. */ -export class JsonRpcTransport extends EventEmitter { +export class JsonRpcTransport extends TypedEventEmitter { public readonly url: string; private socket?: WebSocket; private connectPromise?: Promise; + private abortHandshake?: (error: InputConnectionError) => void; private nextRequestId = 1; private readonly queue: QueuedRequest[] = []; private pending?: PendingRequest; @@ -65,56 +77,89 @@ export class JsonRpcTransport extends EventEmitter { } public get connected(): boolean { - return this.socket?.readyState === WebSocket.OPEN; - } - - public on( - eventName: K, - listener: (event: TransportEvents[K]) => void, - ): this; - public override on(eventName: string | symbol, listener: (...args: any[]) => void): this { - return super.on(eventName, listener); + return this.socket?.readyState === OPEN; } public async connect(): Promise { if (this.connected) return; if (this.connectPromise) return this.connectPromise; + const WebSocketConstructor = globalThis.WebSocket; + if (typeof WebSocketConstructor !== 'function') { + throw new InputConnectionError( + 'This JavaScript runtime has no global WebSocket. InputKit requires Node.js 22.4 or later, ' + + 'or another runtime with a standard WebSocket.', + ); + } + if (this.socket) { this.rejectOutstanding(new InputConnectionError('Input connection was replaced')); } - const socket = new WebSocket(this.url, { - handshakeTimeout: 5_000, - perMessageDeflate: false, - }); + // The standard client always offers permessage-deflate; Input declines it. + // Node.js sends no Origin header; browsers always send one. + const socket = new WebSocketConstructor(this.url); + socket.binaryType = 'arraybuffer'; this.socket = socket; let opened = false; const connection = new Promise((resolve, reject) => { - socket.on('open', () => { + const abort = (error: InputConnectionError): void => { + clearTimeout(handshakeTimer); + if (this.abortHandshake === abort) this.abortHandshake = undefined; + reject(error); + // Some runtimes (including Node.js 22's undici) never fire 'close' after + // close() on a connecting socket, so detach it instead of waiting. + this.handleClose(socket, 1006, ''); + socket.close(); + }; + const handshakeTimer = setTimeout(() => { + abort( + new InputConnectionError( + 'Timed out after ' + HANDSHAKE_TIMEOUT_MS / 1000 + ' seconds connecting to Input at ' + + this.url + '. Check that Input is open and its WebSocket API is enabled.', + ), + ); + }, HANDSHAKE_TIMEOUT_MS); + this.abortHandshake = abort; + + socket.addEventListener('open', () => { + clearTimeout(handshakeTimer); + if (this.abortHandshake === abort) this.abortHandshake = undefined; opened = true; resolve(); }); - socket.on('message', (data, isBinary) => { - if (this.socket === socket && socket.readyState === WebSocket.OPEN) { - this.handleMessage(socket, data, isBinary); + socket.addEventListener('message', (event) => { + if (this.socket === socket && socket.readyState === OPEN) { + this.handleMessage(socket, event.data); } }); - socket.on('error', (error) => { + socket.addEventListener('error', (event) => { + const cause = errorCause(event); if (!opened) { - reject(new InputConnectionError('Could not connect to Input at ' + this.url, error)); + clearTimeout(handshakeTimer); + reject( + new InputConnectionError( + 'Could not connect to Input at ' + this.url + + '. Check that Input is open and its WebSocket API is enabled.', + cause, + ), + ); } else if (this.socket === socket) { - this.emit('transportError', error); + this.emit( + 'transportError', + cause instanceof Error ? cause : new Error('Input WebSocket connection error'), + ); } }); - socket.on('close', (code, reason) => { - const message = reason.toString('utf8'); - this.handleClose(socket, code, message); + socket.addEventListener('close', (event) => { + clearTimeout(handshakeTimer); + if (this.abortHandshake === abort) this.abortHandshake = undefined; + this.handleClose(socket, event.code, event.reason); if (!opened) { reject( new InputConnectionError( 'Input closed the connection before the WebSocket opened' + - (message ? ': ' + message : ''), + (event.reason ? ': ' + event.reason : ''), ), ); } @@ -131,13 +176,22 @@ export class JsonRpcTransport extends EventEmitter { public async close(): Promise { const socket = this.socket; - if (!socket || socket.readyState === WebSocket.CLOSED) return; + if (!socket || socket.readyState === CLOSED) return; + + if (socket.readyState === CONNECTING) { + const error = new InputConnectionError('Input connection was closed before it opened'); + if (this.abortHandshake) { + this.abortHandshake(error); + } else { + this.handleClose(socket, 1006, ''); + socket.close(); + } + return; + } const closed = new Promise((resolve) => { - socket.once('close', () => resolve()); - if (socket.readyState === WebSocket.CONNECTING) { - socket.terminate(); - } else if (socket.readyState !== WebSocket.CLOSING) { + socket.addEventListener('close', () => resolve(), { once: true }); + if (socket.readyState !== CLOSING) { socket.close(1000, 'Client closed connection'); } }); @@ -147,17 +201,17 @@ export class JsonRpcTransport extends EventEmitter { public async request(method: string, params?: object, timeoutMs = this.requestTimeoutMs): Promise { const socket = this.socket; - if (!socket || socket.readyState !== WebSocket.OPEN) { + if (!socket || socket.readyState !== OPEN) { throw new InputConnectionError('Connect to Input before making requests'); } const id = this.nextRequestId; this.nextRequestId = id >= Number.MAX_SAFE_INTEGER ? 1 : id + 1; const frame = JSON.stringify({ jsonrpc: '2.0', id, method, params }); - if (Buffer.byteLength(frame, 'utf8') > MAX_REQUEST_BYTES) { + if (utf8Encoder.encode(frame).byteLength > MAX_REQUEST_BYTES) { throw new RangeError('Input request ' + method + ' exceeds the 512 KiB WebSocket message limit'); } - if (this.socket !== socket || socket.readyState !== WebSocket.OPEN) { + if (this.socket !== socket || socket.readyState !== OPEN) { throw new InputConnectionError('Input connection changed while preparing the request'); } @@ -177,7 +231,7 @@ export class JsonRpcTransport extends EventEmitter { private sendNext(): void { const socket = this.socket; const request = this.queue[0]; - if (!socket || socket.readyState !== WebSocket.OPEN || this.pending || this.sendTimer || !request) { + if (!socket || socket.readyState !== OPEN || this.pending || this.sendTimer || !request) { return; } @@ -205,24 +259,23 @@ export class JsonRpcTransport extends EventEmitter { ); }; try { - socket.send(request.frame, (error) => { - if (error) onSendError(error); - }); + socket.send(request.frame); } catch (error) { onSendError(error instanceof Error ? error : new Error(String(error))); } } - private handleMessage(socket: WebSocket, data: RawData, isBinary: boolean): void { - if (isBinary) { - socket.close(1003, 'Text messages only'); + private handleMessage(socket: WebSocket, data: unknown): void { + // Text frames arrive as strings; with binaryType 'arraybuffer' anything else is binary. + if (typeof data !== 'string') { + socket.close(UNSUPPORTED_DATA_CLOSE_CODE, 'Text messages only'); this.emit('protocolError', new InputProtocolError('Input sent an unexpected binary frame')); return; } let parsed: unknown; try { - parsed = JSON.parse(rawDataToString(data)); + parsed = JSON.parse(data); } catch { this.emit('protocolError', new InputProtocolError('Input sent invalid JSON')); return; @@ -305,8 +358,8 @@ export class JsonRpcTransport extends EventEmitter { } } -function rawDataToString(data: RawData): string { - if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); - if (Buffer.isBuffer(data)) return data.toString('utf8'); - return Buffer.from(data).toString('utf8'); +function errorCause(event: Event): Error | undefined { + // Standard WebSocket error events carry no detail; some runtimes attach one. + const error = (event as { error?: unknown }).error; + return error instanceof Error ? error : undefined; } diff --git a/test/input-client.test.mjs b/test/input-client.test.mjs index 7226495..97047c3 100644 --- a/test/input-client.test.mjs +++ b/test/input-client.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { once } from 'node:events'; +import { createServer } from 'node:net'; import test from 'node:test'; import { setTimeout as delay } from 'node:timers/promises'; import { WebSocketServer } from 'ws'; @@ -54,7 +55,9 @@ async function connect(t, options = {}) { await once(server, 'listening'); const client = new InputClient({ applicationName: 'Example Integration', port: server.address().port, ...options }); const requests = []; - server.on('connection', (peer) => { + const upgradeRequests = []; + server.on('connection', (peer, request) => { + upgradeRequests.push(request); peer.on('message', (data, binary) => { const frame = { peer, bytes: data.length, binary, request: JSON.parse(data), receivedAt: performance.now() }; requests.push(frame); @@ -66,7 +69,12 @@ async function connect(t, options = {}) { await Promise.all([client.close(), new Promise((resolve) => server.close(resolve))]); }); const [peer] = await Promise.all([once(server, 'connection').then(([socket]) => socket), client.connect()]); - return { client, server, peer, requests }; + return { client, server, peer, requests, upgradeRequests }; +} + +// InputClient is not a Node.js EventEmitter, so node:events once() cannot be used with it. +function nextEvent(emitter, eventName) { + return new Promise((resolve) => emitter.once(eventName, (event) => resolve([event]))); } async function nextRequest(server) { @@ -144,8 +152,8 @@ test('normalizes application names and rejects unsupported Unicode characters', test('decodes typed notifications and forwards valid wire notifications', { timeout: 5_000 }, async (t) => { const { client, peer } = await connect(t); async function notify(method, params, eventName) { - const generic = once(client, 'notification'); - const typed = eventName && once(client, eventName); + const generic = nextEvent(client, 'notification'); + const typed = eventName && nextEvent(client, eventName); peer.send(JSON.stringify({ jsonrpc: '2.0', method, params })); assert.deepEqual(await generic, [{ method, params }]); if (typed) assert.deepEqual(await typed, [params]); @@ -169,7 +177,7 @@ test('decodes typed notifications and forwards valid wire notifications', { time ]), ]; for (const [method, params] of invalid) { - const protocolError = once(client, 'protocolError'); + const protocolError = nextEvent(client, 'protocolError'); peer.send(JSON.stringify({ jsonrpc: '2.0', method, params })); const [error] = await protocolError; assert.ok(error instanceof InputProtocolError); @@ -358,7 +366,7 @@ test('rejects malformed DeviceInfo results and notifications with InputProtocolE await failure; } - const protocolError = once(client, 'protocolError'); + const protocolError = nextEvent(client, 'protocolError'); peer.send(JSON.stringify({ jsonrpc: '2.0', method: 'deviceInfoChanged', @@ -388,7 +396,7 @@ test('rejects in-flight and queued requests on disconnect', { timeout: 5_000 }, const inFlight = assert.rejects(client.getDevices(), InputConnectionError); const queued = assert.rejects(client.setKeyLighting(lighting), InputConnectionError); await incoming; - const disconnected = once(client, 'disconnected'); + const disconnected = nextEvent(client, 'disconnected'); peer.close(1008, 'Test disconnect'); assert.deepEqual(await disconnected, [{ code: 1008, reason: 'Test disconnect' }]); await Promise.all([inFlight, queued]); @@ -423,3 +431,138 @@ test('discards a scheduled send on close and isolates requests on the replacemen await delay(450); assert.deepEqual(requests.map(({ request }) => request.method), ['getDevices', 'getDeviceInfo']); }); + +// Returns a loopback port that was free a moment ago, so tests never reach a running Input. +async function unusedPort() { + const probe = createServer(); + await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve)); + const { port } = probe.address(); + await new Promise((resolve) => probe.close(resolve)); + return port; +} + +test('rejects with an actionable error when Input is not listening', { timeout: 5_000 }, async () => { + const port = await unusedPort(); + const client = new InputClient({ applicationName: 'Example Integration', port }); + await assert.rejects(client.connect(), (error) => { + assert.ok(error instanceof InputConnectionError); + assert.match(error.message, /Could not connect to Input at ws:\/\/127\.0\.0\.1:\d+\/input/); + assert.match(error.message, /Check that Input is open/); + return true; + }); + assert.equal(client.connected, false); +}); + +// A TCP server that accepts connections but never completes the WebSocket upgrade. +async function stalledServer(t) { + const sockets = new Set(); + const server = createServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(resolve)); + }); + return server; +} + +test('times out the WebSocket handshake after 5 seconds', { timeout: 10_000 }, async (t) => { + const server = await stalledServer(t); + const client = new InputClient({ applicationName: 'Example Integration', port: server.address().port }); + const startedAt = performance.now(); + await assert.rejects(client.connect(), (error) => { + assert.ok(error instanceof InputConnectionError); + assert.match(error.message, /Timed out after 5 seconds connecting to Input/); + return true; + }); + const elapsed = performance.now() - startedAt; + assert.ok(elapsed >= 4_900 && elapsed < 7_000, 'handshake should time out after about 5 seconds'); + assert.equal(client.connected, false); + await client.close(); +}); + +test('close() during a stalled handshake rejects connect() and resolves promptly', { timeout: 5_000 }, async (t) => { + const server = await stalledServer(t); + const client = new InputClient({ applicationName: 'Example Integration', port: server.address().port }); + const disconnected = nextEvent(client, 'disconnected'); + const connecting = assert.rejects(client.connect(), /closed before it opened/); + await delay(100); + await client.close(); + await connecting; + assert.deepEqual(await disconnected, [{ code: 1006, reason: '' }]); + assert.equal(client.connected, false); + + // A fresh connect() starts a new handshake instead of reusing the aborted one. + const retry = assert.rejects(client.connect(), /closed before it opened/); + await client.close(); + await retry; +}); + +test('connects without an Origin header', { timeout: 5_000 }, async (t) => { + const { upgradeRequests } = await connect(t); + assert.equal(upgradeRequests.length, 1); + assert.equal(upgradeRequests[0].headers.origin, undefined); +}); + +test('closes the connection with a protocolError when Input sends a binary frame', { timeout: 5_000 }, async (t) => { + const { client, peer } = await connect(t); + const protocolError = nextEvent(client, 'protocolError'); + const disconnected = nextEvent(client, 'disconnected'); + const peerClosed = once(peer, 'close'); + peer.send(Buffer.from(JSON.stringify({ jsonrpc: '2.0', method: 'devicesUpdated', params: { devices: [] } }))); + + const [error] = await protocolError; + assert.ok(error instanceof InputProtocolError); + assert.match(error.message, /binary frame/); + const [code, reason] = await peerClosed; + assert.equal(code, 4003); + assert.equal(reason.toString('utf8'), 'Text messages only'); + assert.deepEqual(await disconnected, [{ code: 4003, reason: 'Text messages only' }]); + assert.equal(client.connected, false); +}); + +test('reports the close code and reason in disconnected', { timeout: 5_000 }, async (t) => { + const { client, peer } = await connect(t); + const serverClose = nextEvent(client, 'disconnected'); + peer.close(4321, 'Input is shutting down \u2014 caf\u00e9'); + assert.deepEqual(await serverClose, [{ code: 4321, reason: 'Input is shutting down \u2014 caf\u00e9' }]); + + await client.connect(); + const clientClose = nextEvent(client, 'disconnected'); + await client.close(); + assert.deepEqual(await clientClose, [{ code: 1000, reason: 'Client closed connection' }]); +}); + +test('rejects with a clear error when the runtime has no global WebSocket', { timeout: 5_000 }, async (t) => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'WebSocket'); + t.after(() => { + if (descriptor) Object.defineProperty(globalThis, 'WebSocket', descriptor); + }); + const port = await unusedPort(); + delete globalThis.WebSocket; + + const client = new InputClient({ applicationName: 'Example Integration', port }); + await assert.rejects(client.connect(), (error) => { + assert.ok(error instanceof InputConnectionError); + assert.match(error.message, /no global WebSocket/); + return true; + }); + assert.equal(client.connected, false); +}); + +test('supports on, once, off, and emit on the typed event emitter', async () => { + const client = new InputClient({ applicationName: 'Example Integration', port: await unusedPort() }); + const seen = []; + const persistent = (event) => seen.push(['on', event]); + const single = (event) => seen.push(['once', event]); + assert.equal(client.on('notification', persistent), client); + assert.equal(client.once('notification', single), client); + assert.equal(client.emit('notification', 1), true); + assert.equal(client.emit('notification', 2), true); + assert.equal(client.off('notification', persistent), client); + assert.equal(client.emit('notification', 3), false); + assert.deepEqual(seen, [['on', 1], ['once', 1], ['on', 2]]); + assert.throws(() => client.on('notification', null), TypeError); +}); diff --git a/yarn.lock b/yarn.lock index cb970a1..d4258ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,13 +2,6 @@ # yarn lockfile v1 -"@types/node@*": - version "26.6.5" - resolved "https://registry.yarnpkg.com/@types/node/-/node-26.6.5.tgz#cb35209e7767c503551b5fa42a48231c7441d979" - integrity sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg== - dependencies: - undici-types "~8.9.0" - "@types/node@^24.9.0": version "24.13.6" resolved "https://registry.yarnpkg.com/@types/node/-/node-24.13.6.tgz#30ba2d8aae3c321fd170fe52a915231a67a4875c" @@ -16,13 +9,6 @@ dependencies: undici-types "~7.18.0" -"@types/ws@^8.18.1": - version "8.18.1" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" - integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== - dependencies: - "@types/node" "*" - typescript@^5.9.3: version "5.9.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" @@ -33,11 +19,6 @@ undici-types@~7.18.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== -undici-types@~8.9.0: - version "8.9.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.9.0.tgz#e240d97c8b5d85e5347ce73d25865c7906c1ec9f" - integrity sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg== - ws@^8.21.3: version "8.21.3" resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.3.tgz#660b4faddb6a3e575c86e078126919961f4de4fc"