Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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.
14 changes: 9 additions & 5 deletions docs/API.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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<void>`

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.
Expand All @@ -49,7 +51,7 @@ Store these credentials in the calling application's secure storage. InputKit do

### `authenticate(credentials: AuthorizationCredentials): Promise<void>`

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

Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down
11 changes: 10 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand Down
14 changes: 6 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -17,7 +17,8 @@
"websocket",
"client",
"hardware",
"macropad"
"macropad",
"zero-dependency"
],
"type": "module",
"main": "./dist/cjs/index.js",
Expand All @@ -44,7 +45,7 @@
],
"sideEffects": false,
"engines": {
"node": ">=22"
"node": ">=22.4.0"
},
"packageManager": "yarn@1.22.22",
"publishConfig": {
Expand All @@ -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"
}
}
70 changes: 70 additions & 0 deletions src/event-emitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
type Listener<T> = (event: T) => void;

interface Registration<T> {
listener: Listener<T>;
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<Events extends object> {
private readonly listeners = new Map<keyof Events, Registration<any>[]>();

/** Adds a listener for every future `eventName` event. */
public on<K extends keyof Events>(eventName: K, listener: Listener<Events[K]>): this {
return this.add(eventName, listener, false);
}

/** Adds a listener that is removed after the next `eventName` event. */
public once<K extends keyof Events>(eventName: K, listener: Listener<Events[K]>): this {
return this.add(eventName, listener, true);
}

/** Removes the most recently added registration of `listener` for `eventName`. */
public off<K extends keyof Events>(eventName: K, listener: Listener<Events[K]>): 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<K extends keyof Events>(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<K extends keyof Events>(eventName: K, listener: Listener<Events[K]>, 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<unknown>): 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);
}
}
12 changes: 2 additions & 10 deletions src/input-client.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<InputClientEvents> {
private readonly applicationName: string;
private readonly addLayerTimeoutMs: number;
private readonly transport: JsonRpcTransport;
Expand Down Expand Up @@ -79,14 +79,6 @@ export class InputClient extends EventEmitter {
return this.transport.connected;
}

public on<K extends keyof InputClientEvents>(
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<void> {
return this.transport.connect();
}
Expand Down
Loading
Loading