diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 741bd8b..3207b3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,10 @@ jobs: test: runs-on: ubuntu-latest + # A leaked native listen socket (a uws engine that a failing test never + # tore down) wedges `node --test` instead of failing it — cap the job + # rather than let a hang burn the six-hour default. + timeout-minutes: 20 strategy: matrix: node-version: [22, 24] diff --git a/.gitignore b/.gitignore index c1c7475..e381e9a 100644 --- a/.gitignore +++ b/.gitignore @@ -124,3 +124,9 @@ dist docs/.vitepress/dist docs/.vitepress/cache /.claude/launch.json + +# Autobahn testsuite output +scripts/autobahn/reports/ + +# examples/chat's build.js output — regenerate with `node build.js` +examples/chat/public/wrpc.browser.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d5629f..9c3ea95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,30 +5,622 @@ All notable changes to **`@alexify/wrpc`** are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +Semver here versions the **JavaScript API**. The wire protocol carries its own, +narrower promise — see +[Stability](./docs/reference/protocol.md#stability). + +## [1.0.0] - 2026-08-23 + +This package has not been published to npm yet — there is no `[1.0.0]` +release section until the first publish; everything below lands there +verbatim on release day. ### Added -- Initial project scaffolding: package metadata, oxlint/oxfmt tooling, - CI (lint/test/docs), `node --test` + c8 coverage setup, `tsd` type-test - setup, bundle-size reporting (`pnpm size`), and a minimal VitePress docs - site skeleton. -- WebSocket RPC protocol implementation: `WrpcClient`/`WrpcClientProxy`, - `Server`/`Client`/`Context`/`Session`, chunked binary streams - (`WrpcReadable`/`WrpcWritable`), and a from-scratch WebSocket server - (`WebsocketServer`, `Connection`, `Frame`, `FrameParser`), all wired into - the public `src/index.js` barrel and typed in `index.d.ts`. A slimmer - `src/index.browser.js` excludes the Node-only server/transport modules. +**2026-08 re-review batch (resilience, security, scale, types)** +- Client resilience: `connectTimeout` (default 30 s — a handshake that never + answers no longer parks the reconnect ladder), `reconnect.stableAfter` + (the attempt counter resets only after the connection SURVIVES the window, + so an accept-then-drop peer climbs the backoff instead of pinning at + `minDelay`), coded rejections everywhere (`callTimeout` → 408 `WrpcError`, + dead-transport sends and batched flush failures → 503), and the SSE POST + settling the exact calls it carried. +- Refresh hardening: a call made BY the refresh handler surfaces its refusal + instead of deadlocking the single-flight run; a refused **re-subscribe** + now runs the same refresh and re-opens once (feeds heal like calls after a + long outage); a THROWING refresh clears the `bearerAuth` store; failures + log `refresh.failed`, emit `'refresh-failed'` and count on + `wrpc.client.refreshes`. +- Auth carriers: the `TokenTransport` port now receives the core's parsed + `declared`/`meta` bags (strategies can no longer drift from the wire + parser — `payloadTransport` reads both `x-wrpc-meta` spellings on every + carrier); on browser ws a Bearer credential rides a `wrpc.bearer.` + subprotocol offer instead of the connect URL; `cookieStorage` stamps + `Secure` by default; the ws connect-URL query is client-capped with a + `meta.oversize` warning. +- Per-call deadlines and retry: `CallOptions.timeout` (rides the packet; the + server SHORTENS the procedure budget to match) and the opt-in client + `retry` policy (`{ attempts, on: [503], … }`, jittered backoff, fresh + packet id per attempt, never an offline buffer). +- Typed events: reserved contract keys `events` (server → client; narrows + the unit emitter and types `client.respond`) and `sends` (client → server; + types `client.sendEvent`) — declarations only. The router's inbound `on` + handlers and the new declaration-only `emits` key now travel through + introspection, and `wrpc types` generates both blocks. +- `wrpc types --openapi `: an OpenAPI 3 document projected from every + procedure with an `http` mapping (path/query parameters and request body + from the fastify-shaped schema parts, wire error as the default response). +- Query bindings: `infiniteQueryOptions(path, args, { cursorKey, ... })` — + the tRPC-v11 paging factory, cursor merged over args, lazy resolution and + AbortSignal forwarding as ever. +- Cluster at scale: presence's periodic corrective message is now a + **digest** (hash) with an addressed `sync`/`state` exchange only on + drift; `cluster.rooms` replication filter; `cluster.maxFetch` (loud + truncation, `clients.truncated`); opt-in `cluster.secret` HMAC envelope + authentication; honest `cluster: false`; `healthy` getters with + `'degraded'`/`'recovered'` events and backplane subscribe RETRY (rooms + and cluster channels); `rooms: { linger }` grace window on emptied room + channels; the SSE per-address cap gained an injected `clientAddress` + seam (and the express adapter reports `req.ip`). +- Observability of the newest subsystems: the mapped REST leg traces on + both ends (real `traceparent` headers ↔ the synthetic packet), delegated + fastify routes emit the same spans/metrics as the packet path + (`RpcServer#otel`, `@experimental`), cluster envelopes carry trace + context and three `wrpc.cluster.*` instruments, early HTTP/SSE refusals + log and count (`http.refused`/`sse.refused`/`cors.refused` on + `wrpc.server.calls` under ``), SSE gaps/expiries are logged and + counted (`wrpc.server.sse.events`), reconnects count every attempt. +- The HTTP side's version marker: every response echoes `wrpc-version: 1` + (requests may send one; revision 1 accepts and ignores it) — the ws + subprotocol ladder's counterpart, reserved inside the freeze. +- Guard tests for every hand-synced pair (VitePress keywords/nav label, + `RPC_OPTION_KEYS`, `scripts/size.js` ENTRIES, runtime-exports ⊆ d.ts, + d.ts cross-references), a structural client-transport contract + (`isClientTransport` + `tests/client/transportContract.js`), and docs: + a dedicated [Authentication](https://wrpc.vercel.app/guide/auth) page, + [Stability & deprecation](https://wrpc.vercel.app/reference/stability) + reference, refreshed homepage grid/README/why.md (REST finally on the + front door; honest tRPC bench footnote — the sequential number is a + client flush-timer artifact). ### Changed +- **Breaking (nothing released yet):** `Procedure#invoke` takes an optional + 5th `budget` argument; the introspection unit object carries reserved + `on`/`emits` keys (clients skip them; older generated artifacts are + unaffected); `fetchClients` remote replies changed shape internally + (`{ list, truncated }`); presence's periodic full `state` broadcast was + replaced by the digest flow; `bearerTransport` no longer parses the raw + `wrpc_h` URL itself (the core hands it the parsed bag); + `maxChannelsPerAddress` keys on the injected `clientAddress`. +- Performance: `Allow-Headers` memoized per cors object + (bench/cors-headers.js), `runValidator` synchronous fast path + (bench/validate.js), ring-buffer replay logs (bench/replay-buffer.js), + `sanitizeMeta` upper-bound walk and the `wrpc_meta` substring gate + (bench/meta.js), batch flush skips the meta aggregate off-HTTP. +- Internal layout: `src/rpc/core.js` split (`client.js` — Context/Client; + `meta.js` — the connection-metadata parser), the REST trie moved to + `src/rpc/rest.js`, the wire names centralized in `src/wire.js`. -- Removed the `metautil` dependency introduced while porting the protocol - from `metarhia/metacom`: the handful of functions actually used - (`Emitter`, `jsonParse`, `generateUUID`, and a few single-file helpers) - are now copied directly into `src/`, keeping the package - zero-dependency. `generateUUID` is split into `src/runtime/node.js` / - `src/runtime/browser.js`, swapped via `package.json#browser`, matching - the existing `chunks.js`/`chunks.browser.js` split. -This package has not been published to npm yet — there is no `[1.0.0]` -release section until the first publish. +**Core RPC** +- `defineRouter`/`procedure`/`Router`/`Procedure`: units declared with versions + as `'unit.vN'` keys, bare-function shorthand, per-procedure `access` + (default `'session'`), `input`/`output` validators (plain functions or + Standard Schema objects; failures map to 400/500), `timeout` (408), `queue` + concurrency limits backed by a `Semaphore` (503), `meta`/`signature` + descriptors, and lifecycle **hooks**: `onRequest`, `preValidation`, + `preHandler`, `preSerialization`, `onSend`, `onResponse`, `onError`, + `onTimeout`, `onSubscribe`, `onUnsubscribe`, plus router-level + `onConnect`/`onDisconnect({ rooms })` (a pre-teardown snapshot of the + client's rooms). Registered at three levels — `defineRouter({ hooks })`, a + unit's reserved `hooks` key, `procedure({ preHandler })` — flattened once + at router build, plus `router.addHook(name, fn)`. See the + [Hooks guide](https://wrpc.vercel.app/guide/hooks). +- `system/introspect` is auto-registered from the router (`client.load()` + needs no hand-rolled introspection); `introspection` option (`true` + default / `'session'` / `false`). +- `context.method`/`context.procedure` set at every context creation site + (calls, subscriptions, inbound events, fastify's delegated REST routes). +- `RpcServer`: engine-agnostic core, no `node:http` on the request path — + `attachSocket(socket, meta)`, `handleHttpCall(call)` (abstract + `{ method, url, headers, body, respond }`), `attachPort`. `Server` composes + it with `node:http(s)` + a WebSocket engine. +- Sessions: `SessionManager` over a structural `SessionStore` (`{ get, set, + delete }`, `MemorySessionStore` built in, anything store-shaped injects via + `sessions.store`). Cookies restore automatically on HTTP calls and WS + upgrades (`client.sessionReady`), default + `HttpOnly; Secure; SameSite=Lax; Path=/`, survive disconnects. + `MemorySessionStore` is bounded (LRU `maxSessions`, default 10000; `ttl`, + default 24h) and gained `touch(token)` to slide the TTL on restore. + CSRF: safe methods (`GET`/`HEAD`) dispatch without the cookie session + unless Fetch metadata (`Sec-Fetch-Site`) proves same-origin intent. +- `basePath` (default `/api`): packet endpoint `POST `, REST at + `/unit/method`, WS upgrades gated to `/` and basePath paths. + REST version strategy: `defineRouter(units, { rest: { version: 'path' } })` + maps a versioned unit under a `/vN` prefix (function form for full + control); survives `merge()`. +- `cors: { origins, credentials, headers, methods }` — per-request origin + echo, `Vary: Origin`, credentials only for allowed origins, origin gate on + both HTTP calls and WS upgrades. +- `codec.rest`: an optional `rest` section on the wire codec + (`{ encode, decode, contentType? }`) frames REST bodies (binary allowed) on + both REST modes; rest-only codecs are valid. `RpcServer.codec` getter. + Refused next to fastify's delegated REST routes. +- `maxCalls` (1000, 429 past cap), `maxBodySize` on the built-in `Server`, + `logger`/`console` normalization (below), graceful shutdown: + `server.close({ drain: ms })` stops intake, refuses new calls with 503, + waits for in-flight calls, sends every peer a 1001 close frame; `drain()` + and `draining` are public. + +**Engine & transport** +- `@alexify/wrpc/engine` subpath: replaceable engine port (`WrpcSocket`, + `Engine`, capability flags), `createNodeEngine()`. A **standalone** engine + kind (`standalone: true`, uWebSockets.js) owns the whole network stack + including `node:http`; `Server.address()` reports the bound address + regardless of which side owns the listener (`Server.httpServer` is `null` + under a standalone engine). +- WebSocket engine hardening: write backpressure (`send`/`sendText`/ + `sendBinary` return `false` above the high-water mark, `'drain'`, + `bufferedAmount`, `maxBackpressure` terminates non-draining peers — control + frames respect the cap too); end-to-end stream flow control (`Connection` + gains `pause()`/`resume()`/`isPaused`, a slow stream consumer propagates + backpressure through TCP); O(n) receive path (`SegmentQueue`, incremental + header parsing, word-wise unmasking); subprotocol negotiation + (`protocols`/`handleProtocols`, exposed as `connection.protocol`) — the + client offers `wrpc.v1` by default and both engines echo it back; + outgoing fragmentation (`fragmentThreshold`); permessage-deflate (RFC 7692, + off by default, no-context-takeover, `maxOutputLength` capped by + `maxBuffer`); inbound pings surfaced as a `'ping'` event; graceful + `close({ code, reason })`; `wss.connections` snapshot. `maxPayload` + (default 16 MiB) on the engine is a dedicated inflated-size cap for + permessage-deflate, separate from `maxBuffer`. +- `WebsocketServer`'s `server` option is optional — an unbound server drives + upgrades by hand through the new public `handleUpgrade(req, socket, head)`, + which is what lets a middleware adapter perform the upgrade from a + listener it does not own. +- `@alexify/wrpc/ws` subpath (`ws.js` + hand-maintained `ws.d.ts`), the + engine's own entry point with full typings. + +**Adapters** (`@alexify/wrpc/uws`, `/fastify`, `/express`) — each following +the root-shim + `.d.ts` + `exports` + `tests/*.test-d.ts` convention, and +each taking its host framework strictly by injection (devDependency used +only by adapter tests; never a runtime dependency). +- **uws engine adapter**: uws' tri-state `send()` collapses to a boolean — + `SUCCESS`/`BACKPRESSURE` map to `true`/`false`, `DROPPED` fails loudly + (`'error'` + terminate, since a silently dropped frame corrupts the + protocol); every payload is copied at the callback boundary; a + poisoned-handle guard latches `closed` once uws invalidates the handle; + `listen()` tags a refused bind as `EADDRINUSE`. Capability flags reflect + real behavior differences: `ping: false` (uws owns liveness via + `idleTimeout`), `pause: false` (no receive-side flow control under uws). +- **fastify plugin** (`wrpcFastify`): two backends by feature detection — a + plain `fastify()` gets the node engine on its `'upgrade'` event, + `fastify({ serverFactory })` from `fastify-uws` gets a uws engine over the + same uws app. HTTP calls run through real fastify routes, so hooks/auth + run before wrpc sees the call; logging adapts to fastify's own logger. +- **express (and bare `node:http`) adapter**: `createWrpc({ ... })` returns + `{ rpc, engine, wsServer, handler, upgrade, close }` and owns no listener + — a request outside `basePath` passes to `next()` instead of a `404`, so + wrpc composes with the rest of the app. +- Shared engine contract suite (`tests/engine/engineContract.js`) run against + both hosted and standalone engines via a harness, plus a **swap test** + running one behavioral spec against all five ways of standing wrpc up. + +**Rooms & realtime** (`src/rpc/rooms.js`) +- `ctx.client.join(room)`/`.leave(room)`/`.rooms`/`.in(room)`; + `server.to(room).emit(name, data)`, `.to(a, b)` (union), `.except()`, + `.local()`, `server.broadcast()`. `emit()` returns how many clients + received it on this instance. The chainable `Broadcast` is immutable — + every modifier returns a new target. `to()` with no rooms reaches + **nobody**, never everybody. `Context.server`/`Client.server` reach rooms + from a handler. +- Client → server events: a unit's reserved **`on`** key declares inbound + handlers, reachable with `client.sendEvent('unit/name', data)`; ordinary + procedures (`access`/`input`/`timeout`/`queue` apply); `on` cannot be used + as a method name and is not introspected. +- Encode-once fan-out: `Broadcast.emit` serializes the packet once and hands + every recipient the same text through `Client.sendRaw`. +- `@alexify/wrpc/scaling` subpath: structural backplane contract + (`publish`/`subscribe`/`close`), `MemoryBackplane`, and an ioredis-shaped + `createRedisAdapter({ pub, sub, prefix })` (`sub` defaults to + `pub.duplicate()`; the injected Redis clients are never `quit()`ed, only + a self-opened `sub` is). `new Server({ backplane })` publishes every + non-local emit as `{ v, instance, rooms, name, data }` with echo + suppression by instance id; a room-targeted emit uses that room's + channel, everything else the shared `broadcast` channel. Delivery is + documented as **at-most-once**; backplane failures are isolated and local + delivery continues. +- **Cluster layer**: `server.cluster` — presence, introspection and + node-to-node messaging across every instance sharing a backplane, on two + channels (`cluster`, `inst:`); every operation degrades to a + local-only view without a backplane. + - `cluster.count/presence/instances` are local reads kept warm by + join/leave deltas plus a periodic snapshot (`presenceInterval`, + default 5 s) that heals lost deltas; a restart's fresh epoch replaces + counters instead of doubling them. + - `cluster.fetchClients({ room }?)` collects descriptors + (`{ id, instance, rooms, data, transport, session }`) from every node, + resolving as soon as the last live node answers (`incomplete: true` on + timeout, never silently). + - `cluster.join/leave/disconnect(target, ...rooms)` — client ids are now + instance-prefixed (`.`, so `instanceId` may + not contain `'.'`), letting an id-addressed command travel as one + message to one node. `client.data` is the app's bag, carried by + descriptors; `rpc.getClient(id)` looks a local client up. + - `cluster.sendEvent(name, data)` fans out to every other node's + `cluster.on(name, fn)`; `cluster.ask(name, data)` collects one answer + per node via `cluster.respond(name, fn)`. +- **Acks**: a server → client event may carry an `id`, making it a question + the client answers with an ordinary `callback` packet. + `client.ask(name, data, { timeout })` resolves with the answer (408/503/501 + on timeout/disconnect/no-responder); the client registers exactly one + responder per name with `client.respond(name, fn)`/`unrespond(name)`. + `server.to(room).ask(...)` aggregates + `{ answers, errors, expected, incomplete }` cluster-wide via two-phase + accounting, and serializes the payload once for the whole fan-out. + +**Subscriptions, batching & SSE** — wire protocol v2: `subscribe`/`data`/ +`end`/`unsubscribe`/`cancel` packets, plus a JSON array as a batch frame +(`docs/reference/protocol.md`). +- Subscription procedures — an async-generator handler answers with a + stream of values (`procedure.subscription({ handler })` only needed for a + plain function returning an async iterable). `queue`/`timeout` are + refused on a subscription. The pump respects transport backpressure and + always answers `end` exactly once (completion, throw, unsubscribe, or + disconnect), closing the generator so its `finally` runs. +- Resume: `tracked(eventId, data)` labels a value; the client sends the + last one back as `lastEventId` after reconnect. `createEventLog({ size })` + is the ring buffer behind it — ids are `.` with a random epoch + per instance, so `since()` answers `null` (an honest "cannot resume") for + a foreign epoch or a gap past the buffer; persisted logs pass a stable + `epoch`. +- `createEventStream()` (also on the browser entry): the push→pull adapter + between "something calls me with a value" and a `for await` consumer. + Bounded — an outrun producer drops the oldest value and reports it via + `dropped`. +- Cancellation: `client.api.unit.method(args, { signal })` sends + `{ type: 'cancel' }`; the caller rejects with **499** and `ctx.signal` + aborts. Best-effort — a handler that ignores its signal keeps running, but + its late result is dropped. A call cancelled while still queued is + removed rather than racing the `cancel` ahead of its `call`. Cancel and + unsubscribe are registered synchronously (a same-turn cancel is never + missed). A disconnect aborts everything running for that peer. +- Batching: `batch: { flush: 'microtask' | ms, maxSize, maxBytes }` + coalesces same-tick calls into one frame (a ping/cancel/unsubscribe never + batches; a batch of one is sent bare). HTTP answers come back as one array + in request order. `maxBatch` (128) caps a frame; `maxSubscriptions` (256) + caps concurrent generators per client. A malformed element keeps its own + id, preserving the array's positional guarantee; one unroutable answer no + longer strands the rest of a batch. +- `@alexify/wrpc/sse` subpath: SSE as a full duplex transport. A channel is + `GET {basePath}/events?channel=` plus `POST {basePath}` with + `x-wrpc-channel`, both bound to one server-side `Client` (the POST answers + `202`; every reply travels on the stream). The channel id is + **server-minted** (never client-proposed) and bound to the cookie identity + that created it — a different identity gets 403, an unknown id gets 409. + A dropped stream is held for `retention` (30 s) and a reconnect with + `Last-Event-ID` re-attaches, replays missed frames, and **resumes** + (rather than orphans) live subscriptions; `replayBytes` (1 MiB) bounds the + replay buffer by bytes, and an id older than the buffer answers + `event: gap` instead of a truncated replay. `maxChannels` (10000) and + `maxChannelsPerAddress` (100) cap creation with 503/429. Comment frames + and `X-Accel-Buffering: no` keep proxies from buffering/timing it out. + Cross-origin requests carry full CORS headers including the + channel/resume headers in the preflight allowlist. The session cookie + from the opening GET restores on the channel exactly as `attachSocket` + does for an upgrade. Text-only — binary streams are refused. Client half + is browser-safe (`fetch` + a hand-written incremental parser, not + `EventSource`), registered as `WrpcClient.transport.sse`; + `connect(url, { transport: 'sse' })` selects it. +- `HttpCall` gained an optional `stream({ status, headers })` — the node + shell, express and fastify implement it; a host that cannot answers the + events endpoint with 501. +- `Context.signal`, `Client.calls`, `Client.subscriptions`, `Client.drain()`, + `Client.binary`; `RpcServer.eventsPath`/`RpcServer.sse`. +- `client.close()` ends every live subscription uniformly (not just + `iterate()` consumers) and delivers the same terminal callback an `end` + packet does, exactly once; each listener is contained so one throwing + handler cannot rob the next of its signal. `unsubscribe()` stays silent on + purpose. `close()` also releases an `iterate()`'s `AbortSignal` listener. + +**Typed client, codegen & Query bindings** — no TypeScript at runtime. +- Contract-first typed client: declare the API as an interface and thread + it through `connect(url)` (a one-line alias of + `WrpcClient.connect`). A call keeps its declared arguments plus a + trailing `CallOptions` (`{ signal }`); a member typed + `SubscriptionContract` becomes `subscribe`/`iterate` instead + of callable; `load()` only accepts declared unit keys. Utilities: + `TypedApi`, `TypedUnit`, `TypedMethod`, `TypedParams`, + `TypedSubscriptionMethod`, `InferArgs`, `InferResult`, `FirstArg`, + `UntypedApi`, `IsAny`, `InvalidContractMember`. Without a contract + everything stays exactly as loose as before. A zero-argument member keeps + its args slot (`ping(undefined, { signal })`); a two-parameter/rest/ + non-function member maps to `InvalidContractMember`; a contract key named + `on` is not mapped (a unit is an `Emitter` at runtime). +- `wrpc types --out api.d.ts [--units ...] [--interface Api] + [--package ...] [--schema [--format cjs|esm]]` generates that + interface (or a raw introspection artifact) from `system/introspect`; + output is sorted for byte-identical re-runs. `client.use(introspection)` + scaffolds units from that artifact synchronously, before `open()`, with + zero wire traffic — a `load()`ed unit still wins and reloads on reconnect. +- The `signature` descriptor is specified (`docs/reference/protocol.md`): a + type name, a field map (keys may end in `?`), or a one-element array + meaning "array of". A closed, depth-capped format — names are JSON-quoted, + types matched against an allowlist, `__proto__` is data, unrecognized + types become `unknown`. `Signature`/`SignatureShape` type it. +- `@alexify/wrpc/query` subpath: `createQueryUtils(client, { prefix, + queryClient })` → `queryKey`/`queryOptions`/`mutationOptions`/ + `subscriptionHandler`, as option **factories** (tRPC v11 style, not + hooks) serving React/Solid/Svelte/Vue Query and query-core alike. + Requires nothing (1 KB min+gzip); `queryFn` resolves `['unit','method']` + lazily via `Object.hasOwn` on both hops (so `['chat','on']` cannot reach + `Emitter.prototype.on`); TanStack's `AbortSignal` forwards into the call; + `subscriptionHandler` writes through `setQueryData` and needs nothing on + reconnect. +- Bundle-size budgets: `scripts/size.js` fails CI when a browser-reachable + entry exceeds its min+gzip budget; an esbuild resolve plugin fails the + build naming any non-relative import in a browser entry (catches a + devDependency getting silently inlined, not just a bare `node:*`). +- Package consistency tests (`tests/package/consistency.test.js`): every + exports target exists and ships, every subpath has an ordered `types` + condition and a tsd file, shims resolve, plus the `./package.json` export. +- Browser type conditions: the surface split into `client.d.ts` + (browser-safe) re-exported by `index.d.ts`/`browser.d.ts`/ + `sse.browser.d.ts` under the `browser` condition — importing a server name + in a browser bundle is a compile error, and a project without + `@types/node` compiles clean (checked by a tsc fixture compiled with + `types: []`). +- A real Redis integration test (`tests/scaling/redis.integration.test.js`, + manual/local, `REDIS_URL`-gated) exercises two independent + `createRedisAdapter` instances over separate connections. + `socket.io`/`tRPC` (`wsLink`) joined `bench/rpc-comparison.js`, each + measured sequential and pipelined (64 in flight). + +**Client resilience** +- Reconnect: truncated exponential backoff with full jitter + (`reconnect: { minDelay, maxDelay, factor, jitter, retries }`; the + `reconnectTimeout` shorthand now clamps the same way), `'reconnecting'`/ + `'reconnect-failed'` events, `client.attempt`. A rejected `open()` now + reschedules the next attempt instead of stalling the loop; the reconnect + timer is not `unref`'d (a live socket already holds one). `'reconnect'` + fires after the api is rebuilt: loaded units reload, removed server + methods drop, `api` unit objects are reused so their listeners survive. +- App-level heartbeat (`{type:'ping'}`/`{type:'pong'}`, + `heartbeat: { interval, timeout }`) — the only defense against a dead + `WebSocket` that never sent a close frame; only the WS transport starts + one, both sides answer an inbound ping. +- An event reaching no listener surfaces as `'unhandled-event'`; a + background failure with no `'error'` listener is logged, not thrown. +- In-flight calls reject with a coded 503 the moment the connection dies; + client streams terminate on disconnect; reconnect-restore re-opens + subscriptions before (and independently of) `load()` — a failing reload + emits `'restore-failed'` and forces a clean reconnect rather than + silently killing every subscription. + +**Authentication & metadata** +- Client `authenticate` hook: awaited inside `open()` on the first connect + (so `connect()` resolves an already-authenticated client) and on every + reconnect **before** the subscriptions re-open and the units reload — the + window an `'open'` listener structurally cannot reach. A throw terminates + the transport, emits `'authenticate-failed'` and walks the normal backoff; + `client.close()` inside the hook stops the cycle. With a hook configured, + `'open'` fires after a successful authentication. +- Client `refresh` hook (`fn` or `{ on, handler }`, default trigger `[401]`): + single-flight credential refresh — N concurrent refusals produce one + handler run — with each refused call re-issued exactly once under a fresh + packet id, on both the packet and the REST leg; on failure the original + refusal surfaces. Never fires for calls made inside `authenticate`. +- Public `client.call(target, args, options)` — one call by wire target with + no scaffolding: the escape hatch a first-connect hook needs, since `api` + is built by `load()`. +- `client.meta` / `context.meta`: a frozen snapshot of what the peer + presented — request/upgrade `headers`, `url` (every attach site used to + drop `req.url`), `remoteAddress`, negotiated ws `protocol`, and declared + `data`. +- Client `headers` option (connection-phase, re-evaluated per open): real + request headers on http/sse/worker, one `wrpc_h` query parameter on + browser ws (the WHATWG constructor takes no headers; observed headers win + the merge, reserved names are dropped from the query path). Validated when + a procedure declares `schema.headers` — the part was previously accepted + and silently ignored; delegated fastify routes still validate in fastify, + not twice. +- Client `meta` option and per-call `meta` (`client.call(..., { meta })`, + `method.withMeta({...})(args)`): an optional additive `meta` field on + `call`/`subscribe`/`event` packets, surfaced as `context.callMeta` + (frozen-empty default) and `client.meta.data`; deliberately outside the + `validation` option. Two wire spellings, both emitted by the client and + both accepted from plain HTTP callers: the `x-wrpc-meta` header + (percent-encoded JSON, the default — type-faithful, one CORS entry) and + the per-key `x-wrpc-meta-` form (the S3 `x-amz-meta-*` idiom; string + values, one CORS entry per key), chosen with the client's + `metaFormat: 'json' | 'prefixed'`. The JSON header wins a key collision. + Both channels share one sanitizer: `metaMaxBytes` cap (default 2048) on + the encoded input, plain-object check, `__proto__` drop, freeze. +- **Keys of both declared bags are normalized to kebab-case** (`userId` -> + `user-id`, `xAppVersion` -> `x-app-version`) on every transport and on + both ends, so `schema.headers` has one casing to validate and the two meta + spellings collide on the same key instead of sitting side by side as + lookalikes. Underscores are left alone; keys differing only in acronym + casing merge (last write wins); an external caller must write kebab itself + because HTTP lowercases header names before the server observes them. +- Per-call `meta` now reaches the client's **mapped REST leg**, which + silently dropped it: with no packet to ride, it merges over the connection + bag and travels as request headers, the per-call half winning. Under + `batch: true` the request headers carry the batch's **aggregate** + (last write wins) as a summary for gateways and access logs — each call's + exact meta still rides its own packet and is what `context.callMeta` + reports. An oversize aggregate is refused client-side with a + `meta.oversize` warning rather than left to the server's whole-bag drop. +- `cors.metaHeaders: string[]` names the per-key meta headers CORS cannot + wildcard (`['userId']` grants `x-wrpc-meta-user-id`, normalized with the + same rule the client uses); appended to `cors.headers`, which now also + accepts an array. `ClientTransport.request` takes `{ rest, meta }` in + place of its trailing `rest` argument. +- Pluggable session token carrier (`sessions: { transport }`, structural via + `isTokenTransport`): the cookie default is byte-identical; a non-ambient + carrier (`ambient: false`) is exempt from the safe-method CSRF rule it + never needed, and SSE channel identity keys on whatever the carrier reads. +- New **`@alexify/wrpc/auth`** subpath (browser-safe, own 2 KB budget): + token stores (`memoryStore`/`webStorage`/`cookieStorage` — a `Map` + already satisfies the contract), `bearerAuth()` composing + `headers`+`authenticate`+`refresh` around a store, and the server halves + `bearerTransport()` / `payloadTransport()`. +- Server dispatch now gates on `client.ready` — session restore **plus** + settled `onConnect` hooks — so a subscribe racing the hooks can no longer + miss a room broadcast; a hook stalled past 5 s logs `onConnect.stalled`. + +**Observability** +- Structured `logger` option (replacing `console`): one injected writer + (`src/logging.js`, zero-import) normalizing a structured logger (pino/ + bunyan/winston, `(entry, message)`), a `Console` (`(message)`), or + nothing. `false` silences a server outright; a shape matching neither + disables logging rather than throwing. wrpc binds children per subsystem/ + connection/call, reachable as `context.log`; a throwing sink is contained + to the line, not the call. Client `logger` is **off by default** — when + on, an error is logged *and* emitted as `'error'`. + `WrpcClientProxy` forwards the option. +- Three silent failure paths now report: a server-side subscription death + used to answer `end` and log nothing; an aborted subscription's generator + error was dropped entirely; `jsonParse(data) || {}` conflated "malformed" + with "empty", hiding every unparseable packet behind one `||`. +- OpenTelemetry: `telemetry` accepts the `@opentelemetry/api` module or a + custom `{ tracer, meter }` (tracer-only and meter-only both work; the + module itself is never imported). Spans follow the OTel `rpc.*` + convention and bracket the whole invocation. Fourteen instruments cover + calls, durations, connections, subscriptions, broadcasts/fan-out size, + stream bytes, backpressure, sessions and SSE channels. + `includeIdentity: false` drops the peer address; a session token is never + recorded at any setting. Every recording path contains its own failures. +- W3C trace context: `call`/`subscribe`/`event` packets may carry optional + `tp`/`ts` fields, propagated per *packet* (not per connection) so each + call in a batch keeps its own parent. wrpc hands the field to your + propagator rather than parsing it, so propagation needs `{ api }` (or an + explicit `propagation`). `trustRemoteContext` defaults to `true`; set it + `false` when peers are untrusted. +- Telemetry discipline: unresolved method/event names collapse into an + `` bucket (never minted from peer-controlled text); metric + attributes match span attributes per RPC semconv; the server dispatch + path skips telemetry allocations entirely when disabled. +- Hot path: UTF-8 validation delegates to `node:buffer.isUtf8` above the + native-call threshold (up to 70x on large frames vs `ws`); the frame + parser stopped copying the 4-byte mask and allocating a `Result` per + not-enough-bytes attempt; fragmented sends cork once per message; client + batching serializes each packet once; error logging materializes + `error.stack` only when a logger is enabled. +- Pluggable `generateId: () => string` on client (packet/subscription/ + stream ids) and server (context uuids, server-side stream ids, synthetic + REST packet ids); a stream id is validated against the 255-byte + chunk-header limit at the source. The browser runtime gained a + `Math.random`-based uuid fallback for plain-http pages. +- Robustness against malformed/adversarial input: a malformed packet + target is type-checked rather than trusted (an un-awaited dispatch that + threw used to take the process down); every fire-and-forget dispatch has + a terminal catch; a wire event or contract key named after an + `Object.prototype` key cannot resolve up the prototype chain + (`load()` defines units instead of assigning them); an `event`/`pong` + packet over HTTP requires a persistent transport (400) instead of hanging + the request; a socket abandoned by `terminate()` cannot close its + reconnected replacement (scoped by socket identity); `createRedisAdapter` + quits the subscriber it opened itself (never an injected one). +- A graceful WebSocket close now completes in milliseconds instead of ~1 s: + the side *answering* a Close writes the echo, half-closes, and destroys + after a short grace, per RFC 6455 5.5.1/5.5.2. +- A real `SECURITY.md` (scope, acknowledgement/fix windows, supported + versions) and a stability/deprecation policy in `CONTRIBUTING.md`, with + `@experimental` markers on the telemetry shapes and the engine-port + `capabilities`. + +### Fixed + +- `client.sessionReady` is assigned **before** the `onConnect` hooks run — + the documented `await client.sessionReady` recipe used to await the + constructor's resolved default and see `session === null`; the packet-POST + and delegated paths now share the same restore promise instead of + discarding it. +- A restore (or authenticate) failing **after** the socket opened restores + the attempt count before terminating, so the backoff grows and `retries` + exhausts instead of hammering `minDelay` forever — which also means the + transport-fallback list is actually reached from a post-open failure. +- A rejected `WrpcClient.connect()` closes the half-born client instead of + leaking it into `WrpcClient.connections` for `online()` to revive; a + throwing `'restore-failed'` listener is escalated rather than becoming an + unhandled rejection; `WrpcClient.online()` no longer aborts its re-open + loop on the first client without an `'error'` listener. + +### Changed (breaking) + +- Unit version keys must be `unit.vN` (`auth.v1`, not `auth.1`) — the wrong + spelling throws a `TypeError` at router build. The token is stored + verbatim, which is what lets the REST `/vN` prefix be a plain + concatenation. +- `new Server(application, options)` is gone: the server takes one options + object with `router` (`new Server({ router, host, port, protocol, + sessions, cors, basePath, engine, ws, logger })`); the metarhia-style + `application.getMethod()` coupling is fully removed, procedures come from + `defineRouter`, handlers receive `(context, args)`. +- `console` is gone (not deprecated) in favor of `logger` — the fastify + adapter's `toConsole` shim went with it, `fastify.log` (a pino) now goes + in directly. +- `Client.emit` is the local `Emitter` emit again, not a wire send for + every name but `'close'` — use `client.sendEvent(name, data)` to send. +- `instanceId` must not contain `'.'` (it prefixes every client id as + `.`). +- 5xx error messages no longer travel to the caller — only the status line + does, details stay in the server log correlated by packet id; a 4xx + message still travels verbatim. `error.expose = true` opts a message in. +- `cors.origins` is enforced on HTTP calls too, not only the WS upgrade — + a disallowed origin gets 403 instead of running with the grant withheld. +- Unknown `access` values throw at router construction instead of silently + meaning "any session". +- The per-call success log line moved to `debug`, and a Console sink drops + `debug` outright. +- `maxBackpressure` defaults to `maxBuffer` (was unbounded) — `0` opts back + into unbounded. +- `ServerWsTransport` is constructed as `(connection, meta)`; + `ServerHttpTransport` wraps an abstract call description instead of node + `req`/`res`. +- `Client.restoreSession`/`finalizeSession` are async (store-backed); + dropping a connection no longer deletes the session. +- The `Emitter` warns instead of throwing when `maxListeners` is exceeded + (fan-out to many stalled streams is legitimate); duplicate-listener and + unhandled-`'error'` throws remain. +- `websocketPath` option removed — path gating follows `basePath` (or pass + `ws: { path }`/`ws: { verifyClient }` through to the engine). +- Data-send methods' boolean now means "accepted without exceeding the + buffer", not "accepted for send" — wait for `'drain'` after `false` + (check `closed`, since a closed transport also reports `false`). +- The WebSocket engine internals (`WebsocketServer`, `Connection`, `Frame`, + `FrameParser`, `ParseError`, `PARSE_ERR_CODES`, `OPCODES`, `CLOSE_CODES`, + `CLOSE_TIMEOUT`, `MAGIC`) live at `@alexify/wrpc/ws`, not the main barrel + — the main entry keeps only the RPC-level API. + +### Changed + +- Server transports carry a `kind` — `ws`, `http`, `sse` or `event` — as a + metric attribute and log field. +- Every shell and adapter funnels its option bag through `rpcOptions()` + rather than re-listing the core's options by hand. +- `ServerTransport.send()` returns the transport's backpressure signal, so + a producer can wait for `'drain'` instead of buffering without limit. +- `src/telemetry/` is three files (shared/server/client) instead of one, to + keep the browser bundle under its size budget. + +### Documentation + +- **The wire protocol is frozen as 1.0** (`docs/reference/protocol.md`): + packet types, fields and meanings do not change within the major version; + a new optional field may be added, and an unknown packet type still + answers a `callback` with code 500, which is what makes an additive + change safe for an older peer. +- The documentation site is the full structure, not a skeleton: a guide + track (getting started, server, router, sessions, rooms, subscriptions, + streams, scaling, client, typed client, CLI, TanStack Query, SSE, hooks, + logging, OpenTelemetry, one page per adapter) plus a reference track + (wire protocol, wire format, engine port) with grouped nav/sidebar. +- README rewritten: badges, a positioning table against tRPC and + Socket.IO, an honest *When NOT to use wrpc*, the feature matrix, an + exports table with one row per subpath, and the `pnpm size` bundle-size + table. +- `CONTRIBUTING.md` added: development workflow, house rules not visible in + the code (zero dependencies, `.d.ts`/`tsd` pairing, the uws teardown trap + that wedges `node --test`), and the manual release checklist. diff --git a/CLAUDE.md b/CLAUDE.md index afc1d19..5752d9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,22 +4,36 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -wrpc (`@alexify/wrpc`) is a fast, low-overhead, zero-dependency, WebSocket-based RPC protocol for Node.js and browsers. - -**Protocol architecture.** The wire protocol is JSON packets (`{ type: 'call' | 'callback' | 'event' | 'stream', ... }`) over WebSocket or HTTP, plus a small binary framing (`chunkEncode`/`chunkDecode`) for streamed uploads/downloads. Module layout under `src/`: - -- `src/client.js` — `WrpcClient`/`WrpcClientProxy`: the RPC client (WS/HTTP/ServiceWorker-event transports, call scaffolding via `#scaffold`, reconnect handling). -- `src/server.js` — `Server`/`Client`/`Context`/`Session`: the RPC server (HTTP + WS + worker request handling, method dispatch, sessions). -- `src/transport.js` — `ServerTransport` and its http/ws/event subclasses; CORS headers, session cookies. +wrpc (`@alexify/wrpc`) is a fast, zero-dependency, WebSocket-based RPC protocol for Node.js and browsers. + +**Protocol architecture.** The wire protocol is JSON packets (`{ type: 'call' | 'callback' | 'event' | 'stream' | 'ping' | 'pong', ... }`) over WebSocket or HTTP, plus a small binary framing (`chunkEncode`/`chunkDecode`) for streamed uploads/downloads. It is documented in `docs/reference/protocol.md`, which is updated per phase and frozen at 1.0. Module layout under `src/`: + +- `src/client.js` (barrel) + `src/client/` — `WrpcClient`/`WrpcClientProxy`: the RPC client (WS/HTTP/ServiceWorker-event transports, call scaffolding via `#scaffold`, reconnect with exponential backoff + full jitter, app-level `ping`/`pong` heartbeat, automatic re-`load()` of loaded units on `'reconnect'`), plus `connect` — a one-line alias of `WrpcClient.connect` that exists so the contract type has a natural home (`connect(url)`). The `authenticate` hook is awaited inside `open()` and again on every reconnect BEFORE `#restore` re-sends its subscribe packets (the window an `'open'` listener cannot reach); `refresh` is a single-flight one-shot-retry credential refresh; `client.call(target, args, options)` is the unscaffolded escape hatch those hooks use; the `headers`/`meta` options are connection-phase declarations re-resolved per open (real headers on http/sse, `wrpc_h`/`wrpc_meta` query parameters on browser ws). +- `src/rpc/` — the engine-agnostic server core (never imports `src/websocket/`; node built-ins are fine — `client.js` uses `node:http`'s STATUS_CODES): `router.js` (`defineRouter`/`procedure`, versions as `'unit.vN'` keys, input/output validators, timeout/queue, the reserved `on` key holding a unit's inbound event handlers and the declaration-only `emits` key describing outbound ones), `rest.js` (the declarative REST bridge split out of router: `normalizeHttp`, the `rest.version` strategy, the per-verb segment trie — all cold paths), `core.js` (`RpcServer` with `attachSocket`/`attachPort`/`handleHttpCall`/`to`/`except`/`broadcast`), `client.js` (`Client`/`Context`, split out of core — core requires it, never the reverse), `meta.js` (the connection-metadata parser: `declaredHeaders`, `declaredData` — token transports receive its OUTPUT, never re-run it), `dispatcher.js` (packet routing over a `Router`), `sessions.js` (`SessionManager`, structural `SessionStore`, `MemorySessionStore`, cookie building, the widened `TokenTransport` port `read({ headers, url, declared, meta })`), `rooms.js` (`RoomRegistry`, the immutable chainable `Broadcast`, and `RoomsBackplane` binding a room registry to an injected backplane — with subscribe retry, a `linger` window on emptied channels and a `healthy` getter), `cluster.js` (presence with digest+sync corrective flow, a `rooms` replication filter, `fetchClients` with a loud `maxFetch` cap, addressed commands, node-to-node asks, opt-in HMAC envelope auth via `cluster.secret`, `instanceOfClientId`). The SSE channel registry is a core BUILT-IN with an `sse: false` opt-out, not an injected port — `SseChannels` needs `addClient` and the session identity, both core-owned; the shared `x-wrpc-channel` string comes from `src/sse/constants.js`, never from `sse/server.js`. +- `src/wire.js` — the import-free wire-name constants (`wrpc_h`, `wrpc_meta`, `x-wrpc-meta`, `x-wrpc-meta-`, re-exported `x-wrpc-channel`), required by the five modules that speak them; a spelling change is one file, not a five-file grep. +- `src/logging.js` — the structured logger writer (Console- and pino-shaped loggers normalized to one `child()`/level facade; the frozen writer table lives here). +- `src/telemetry/` — the injected-OpenTelemetry writers (`shared.js` with the `tp`/`ts` W3C propagation fields, `server.js`, `client.js`); everything duck-typed, `@opentelemetry/*` only ever a devDependency. +- `src/rpc/subscriptions.js` — the subscription primitives: `tracked(id, data)` (labels a value for resume), `createEventLog({ size })` (ring buffer whose `since(lastEventId)` returns what was missed or `null` for a real gap), and `runSubscription` (the generator pump: one `data` packet per yield, transport backpressure honoured, exactly one `end`). The push→pull `createEventStream` lives in `src/utils.js` because the client needs it too. +- `src/sse/` — Server-Sent Events as a full transport behind the `@alexify/wrpc/sse` subpath: `server.js` (`ServerSseTransport` + `SseChannels`, the channel registry with `retention` and `Last-Event-ID` replay) and `client.js` (browser-safe `fetch` transport + incremental `SseParser`, registered as `WrpcClient.transport.sse`). A channel is `GET {basePath}/events?channel=` plus POSTs carrying `x-wrpc-channel`, both bound to ONE server-side `Client`. Text-only: binary streams are refused on it. The seam that makes it possible is the optional `call.stream({ status, headers })` on the abstract HTTP call, implemented by `nodeStream()` in `src/adapters/common.js` for every host that hands over a node `ServerResponse`. +- `src/scaling/` — the rooms backplane behind the `@alexify/wrpc/scaling` subpath: the structural contract (`publish(channel, message)` / `subscribe(channel, handler) -> unsubscribe` / `close()`, checked by `isBackplane`), `memory.js` (`MemoryBackplane`, in-process, microtask delivery) and `redis.js` (`createRedisAdapter({ pub, sub, prefix })`, ioredis-shaped, clients **injected** — see "No runtime dependencies"). Delivery is at-most-once by design. +- `src/server.js` — the batteries-included `Server` shell: `node:http(s)` + a WebSocket engine (default `createNodeEngine()`) composed around `RpcServer`. Constructor takes ONE options object with `router` — the old `(application, options)`/`getMethod` API is gone. With a **standalone** engine there is no node http server at all (`httpServer` is `null`), so always read the bound address through `server.address()`, never `server.httpServer.address()`. +- `src/engine/` — the replaceable server-side engine port (`WrpcSocket` contract + `createNodeEngine` + `isEngine`), published as the `@alexify/wrpc/engine` subpath; the shared contract test suite lives in `tests/engine/engineContract.js`. Two engine kinds satisfy the port: **hosted** (`standalone` falsy) attaches to a listener someone else owns — `attach({ server, ... })` — and MAY expose `handleUpgrade(req, socket, head)` on its source for manual driving; **standalone** (`standalone: true`, uWebSockets.js) owns the whole network stack including `node:http`, is attached with no server — `attach({ path, verifyClient, onHttpCall, ... })` — and must implement `listen({ host, port }) -> Promise
`. +- `src/adapters/` — host-framework adapters, each behind its own subpath: `mirror.js` (fastify route mirroring via `fastify.inject` — reverse-REST naming, JSON-Schema→`signature` distillation), `common.js` (shared plumbing that turns a framework request into the abstract `{ method, url, headers, body, respond }` call: `receiveBody`, `normalizeBody`, `statusLine`, `eachHeader`, `MAX_BODY_SIZE`), `uws.js` (`createUwsEngine`/`UwsSocket` — a standalone engine over uWebSockets.js), `fastify.js` (`wrpcFastify` plugin, node or uws backend by feature detection; `findUwsApp`), `express.js` (`createWrpc` → `{ rpc, engine, wsServer, handler, upgrade, close }` middleware that owns no listener). Node-only, never bundled for the browser. **Frameworks are injected, never depended on** — see "No runtime dependencies" below. +- `src/transport.js` — `ServerTransport` and its http/ws/event subclasses; per-request CORS headers (`buildHeaders(cors, origin)`), cookies. `ServerHttpTransport` wraps an abstract `{ method, url, headers, body, respond }` call (net-free — the seam for framework adapters); `ServerWsTransport` is `(connection, meta)`. - `src/streams.js` — `WrpcReadable`/`WrpcWritable`: chunked binary stream classes shared by client and server. - `src/chunks.js` / `src/chunks.browser.js` — binary chunk framing (Node `Buffer` vs `TextEncoder`/`TextDecoder`), swapped via `package.json#browser`. -- `src/websocket/` — a small from-scratch WebSocket server implementation (handshake, framing, `WebsocketServer`, `Connection`) used by `src/transport.js`; Node-only, never bundled for the browser. +- `src/websocket/` — a from-scratch WebSocket server implementation (handshake, framing, `WebsocketServer`, `Connection`, `SegmentQueue` O(n) receive buffering in `segments.js`, RFC 7692 permessage-deflate in `permessageDeflate.js`) used by `src/transport.js`; Node-only, never bundled for the browser. Published as the `@alexify/wrpc/ws` subpath (root `ws.js` shim + hand-maintained `ws.d.ts`) — engine internals are NOT exported from the main barrel. `WebsocketServer`'s `server` option is optional: without it nothing is bound and upgrades are driven by hand through the public `handleUpgrade(req, socket, head)`, which is how middleware adapters hook a listener they do not own. - `src/utils.js` — cross-platform primitives ported from `metautil` (`Emitter`, `jsonParse`) — see "No runtime dependencies" below. +- `src/query/index.js` — the TanStack Query bindings behind the `@alexify/wrpc/query` subpath: `createQueryUtils(client, { prefix, queryClient })` returning `queryKey`/`queryOptions`/`mutationOptions`/`subscriptionHandler`. Option **factories**, in the style of tRPC v11 — never hooks — so one file serves React/Solid/Svelte/Vue Query and query-core alike. It `require()`s **nothing** (that is what keeps it at ~1 KB min+gzip and browser-safe), takes the client and the `QueryClient` by injection with structural validation, and resolves `['unit','method']` paths *lazily* inside `queryFn` because `client.api` only exists after `load()` and is rebuilt on every reconnect. Path lookups use `Object.hasOwn` on both hops: a unit object is an `Emitter`, so `['chat','on']` would otherwise resolve to `Emitter.prototype.on`. +- `src/auth/index.js` — token strategies behind the `@alexify/wrpc/auth` subpath, browser-safe and require-free like `src/query/`: client token stores (`memoryStore`/`webStorage`/`cookieStorage`; a `Map` already satisfies the `get`/`set`/`delete` contract), `bearerAuth()` (composes the client's `headers`+`authenticate`+`refresh` options around a store), and the server halves `bearerTransport()`/`payloadTransport()` for the `sessions: { transport }` injection (`isTokenTransport` in `src/rpc/sessions.js`; the cookie default is byte-identical). Deliberately outside the base browser bundle — the seams live in the core, the strategies do not. +- `src/cli/types.js` — the `wrpc types --out api.d.ts` codegen, behind the `bin/wrpc.js` shim. Consumes `system/introspect` and writes the same shape of contract interface a user would hand-write for `connect()`; the `signature` descriptor format it reads is specified in `docs/reference/protocol.md`. Everything beyond argv is injected (`main(argv, { log, error, fetch, write, version })`), so the whole command is testable in-process. **Nothing from the wire is interpolated verbatim**: names go through `quoteKey` (JSON escaping, downgraded to single quotes only when nothing needed escaping) and type names are matched against a closed allowlist, with a depth cap on nesting. - `src/runtime/node.js` / `src/runtime/browser.js` — the one platform split needed beyond chunks: `generateUUID` (`node:crypto` vs `globalThis.crypto`), swapped via `package.json#browser`. -- `src/index.js` — the full barrel (client + server + websocket + utils). `src/index.browser.js` — a slimmer barrel excluding server/transport/websocket-server (anything touching `node:http`/`node:https`). +- `src/index.js` — the RPC-level barrel (client + server + streams + utils; websocket engine lives in the `./ws` subpath). `src/index.browser.js` — a slimmer barrel excluding server/transport (anything touching `node:http`/`node:https`). **No runtime dependencies.** An earlier pass ported code structurally from `metarhia/metacom`, which depends on `metautil`. Rather than install `metautil` (breaking the zero-dependency guarantee), the handful of functions actually used (`Emitter`, `jsonParse`, `generateUUID`, plus a few single-file-use helpers like `parseCookies`/`parseHost`/`receiveBody`/`split`/`parseParams`/`isError`) were copied in directly and adapted. `package.json` has no `dependencies` field at all — keep it that way; anything reused from another package must be copied in, not installed. +This rule extends to the adapters: **`uWebSockets.js`, `fastify`, `fastify-uws` and `express` are devDependencies used only by the adapter tests, and must never become runtime dependencies (not even `peerDependencies`/`optionalDependencies`).** The same holds for the other injected or compared packages: `ioredis` (only `tests/scaling/redis.integration.test.js`, which is skip-guarded on `REDIS_URL`), and `socket.io`/`socket.io-client`/`@trpc/server`/`@trpc/client` (only `bench/support/rpc-stacks.js`). Every adapter takes its host by injection — `createUwsEngine({ uws: require('uWebSockets.js') })`, `wrpcFastify` receives the fastify instance from `register()`, `createWrpc()` returns middleware the app mounts — and validates that injection structurally (duck typing, e.g. `isEngine`, `isTemplatedApp`) rather than by `instanceof` against an imported class. Nothing under `src/` may `require()` a framework. + Package manager is **pnpm** (`packageManager: pnpm@10.34.5`). CommonJS throughout (`require`/`module.exports`), no build/transpile step — `src/` ships as-is (same philosophy as the sibling `@alexify/kerberos` project). ## Commands @@ -28,16 +42,18 @@ Package manager is **pnpm** (`packageManager: pnpm@10.34.5`). CommonJS throughou pnpm test # run all tests: node --test (recursive discovery over tests/) node --test tests/smoke.test.js # run a single test file node --test --test-name-pattern="..." # filter tests by name +node --test tests/adapters/*.test.js # adapter suites (node --test takes files, not dirs) pnpm test:types # type-check tests/*.test-d.ts against index.d.ts via tsd pnpm test:coverage # c8 coverage over src/ (thresholds: 95% lines/statements, 90% branches, 95% functions) -pnpm lint # oxlint src tests scripts bench -pnpm format # oxfmt src tests scripts bench (format:check for CI) +pnpm test:perf # 1 GiB stream memory guard (tests/perf/, non-.test.js so node --test skips it) +pnpm lint # oxlint src tests scripts bench bin +pnpm format # oxfmt src tests scripts bench bin (format:check for CI) pnpm bench # runs every script in bench/ (bench/run-all.js), not just bench.js -pnpm size # bundle-size report (scripts/size.js), also run in CI's lint job +pnpm size # bundle-size report (scripts/size.js), also run in CI's lint job; browser-reachable entries carry a min+gzip budget and exceeding one FAILS the run pnpm docs:dev # VitePress dev server for docs/ (docs:build / docs:preview too) ``` -Linting/formatting is **oxlint/oxfmt** (`.oxlintrc.json`, `.oxfmtrc.json`; the `correctness` category is intentionally off, and `no-void` is intentionally not enabled — this codebase follows the Metarhia style of `return void fn()` for explicit fire-and-forget) — their native bindings require Node ≥20.19. CI (`.github/workflows/ci.yml`) runs three jobs: `lint` (Node 22 — lint, format:check, size), `test` (Node 22/24 matrix — test:coverage, test:types), `docs` (Node 22 — docs:build). Lint/format deliberately target `src tests scripts bench` only, so `docs/` is not covered by them. +Linting/formatting is **oxlint/oxfmt** (`.oxlintrc.json`, `.oxfmtrc.json`; the `correctness` category is intentionally off, and `no-void` is intentionally not enabled — this codebase follows the Metarhia style of `return void fn()` for explicit fire-and-forget) — their native bindings require Node ≥20.19. CI (`.github/workflows/ci.yml`) runs three jobs: `lint` (Node 22 — lint, format:check, size), `test` (Node 22/24 matrix — test:coverage, test:types; bare `node --test` recursively discovers `tests/adapters/**` too, so the adapter suites run here rather than in a separate job), `docs` (Node 22 — docs:build). The Autobahn Testsuite (`node scripts/autobahn/run.js`) and the 1 GiB stream memory guard (`pnpm test:perf`) are deliberately **not** wired into CI — run them by hand. `tests/scaling/redis.integration.test.js` covers the Redis backplane against a real server and is likewise a manual/local run (`REDIS_URL=redis://127.0.0.1:6379 node --test tests/scaling/redis.integration.test.js`), not a CI job — the in-repo ioredis-shaped fake in `tests/scaling/redis.test.js` is what `pnpm test` actually exercises. Lint/format deliberately target `src tests scripts bench` only, so `docs/` is not covered by them. Style: 2-space indent, single quotes, semicolons, 120-char lines (see `.editorconfig`, `.oxfmtrc.json`). @@ -46,6 +62,16 @@ Style: 2-space indent, single quotes, semicolons, 120-char lines (see `.editorco Mirrors the `@alexify/kerberos` entry-point pattern: - `index.js` → `require('./src/index.js')` — the Node/default entry. +- `ws.js` → `require('./src/websocket/ws.js')` + `ws.d.ts` — the `./ws` subpath (WebSocket engine). New subpaths follow the same pattern: root shim + hand-written root `.d.ts` + `exports` entry + `files` additions + `tests/.test-d.ts`. +- `engine.js` → `require('./src/engine/index.js')` + `engine.d.ts` — the `./engine` subpath (engine port: `createNodeEngine`, `isEngine`, the `Engine`/`WrpcSocket` contracts). +- `uws.js` → `require('./src/adapters/uws.js')` + `uws.d.ts` — the `./uws` subpath (`createUwsEngine`, `UwsSocket`). +- `fastify.js` → `require('./src/adapters/fastify.js')` + `fastify.d.ts` — the `./fastify` subpath (`wrpcFastify`, `findUwsApp`). +- `express.js` → `require('./src/adapters/express.js')` + `express.d.ts` — the `./express` subpath (`createWrpc`). +- `scaling.js` → `require('./src/scaling/index.js')` + `scaling.d.ts` — the `./scaling` subpath (`MemoryBackplane`, `createRedisAdapter`, `isBackplane`). +- `sse.js` → `require('./src/sse/index.js')` + `sse.browser.js` → `require('./src/sse/client.js')` + `sse.d.ts` — the `./sse` subpath. The only subpath with a `browser` condition: a browser needs the client transport, never the channel registry. +- `query.js` → `require('./src/query/index.js')` + `query.d.ts` — the `./query` subpath (`createQueryUtils`). Browser-reachable but needs **no** `browser` condition: `src/query/` has no Node-specific code, so one file serves both. `scripts/size.js` bundles it with `platform: 'browser'` under a budget to keep that true. +- `auth.js` → `require('./src/auth/index.js')` + `auth.d.ts` — the `./auth` subpath (token stores, `bearerAuth`, `bearerTransport`/`payloadTransport`). Same one-file browser-safe pattern as `./query`, same `platform: 'browser'` budget in `scripts/size.js`. +- `bin/wrpc.js` → `require('./src/cli/types.js')` — the `wrpc` command (`package.json#bin`). A shim on purpose: the logic lives under `src/` so the c8 thresholds cover it. Needs the shebang, the executable bit, and `bin/` in both `files` and the lint/format targets. - `browser.js` → `require('./src/index.browser.js')`; resolved via the `package.json#browser` field map and the `browser` condition in `exports`. Do not deduplicate the two files even once they look similar — the browser entry intentionally excludes server-only modules. - `index.d.ts` — hand-maintained types (not generated), covering the full `src/index.js` surface. - `src/index.js` — the full barrel; `src/index.browser.js` — the browser-safe subset (see above). @@ -53,8 +79,38 @@ Mirrors the `@alexify/kerberos` entry-point pattern: ## Testing -Tests live in `tests/` (not `test/` — this differs from `kerberos`), run via Node's built-in `node --test` runner (bare `node --test`, which recursively discovers `**/*.test.js` under the cwd — no vitest/jest config). Non-`.test.js` files under `tests/` (`tests/websocket/mockSocket.js`, `tests/websocket/protocolClient.js`) are shared test helpers, not test files themselves. The `#ws` subpath import (`package.json#imports`) lets tests require the websocket barrel without a relative path. Type tests use `tsd` (`tsd.directory: "tests"` in `package.json`), currently `tests/index.test-d.ts`. +Tests live in `tests/` (not `test/` — this differs from `kerberos`), run via Node's built-in `node --test` runner (bare `node --test`, which recursively discovers `**/*.test.js` under the cwd — no vitest/jest config; note that `node --test` accepts *files*, not directories, so a targeted run needs an explicit glob). Non-`.test.js` files under `tests/` are shared test helpers, not test files themselves: `tests/websocket/mockSocket.js`, `tests/websocket/protocolClient.js`, `tests/engine/engineContract.js` (the shared engine port contract, run against both a hosted and a standalone engine via `hostedHarness`/`standaloneHarness`), and the client-transport contract `tests/client/transportContract.js` (structural, run over ws/http/event/sse in `tests/client/transports.test.js`), the shared server boot `tests/helpers/server.js` (`bootServer`/`connectClient` — the default for NEW server-driving suites; never read a bound port through `server.httpServer.address()`), and the swap-test pair `tests/adapters/boots.js` (the five boots — `Server` over each engine, the fastify plugin over each backend, express middleware) + `tests/adapters/spec.js` (the one behavioral spec every boot must satisfy). The `#ws` subpath import (`package.json#imports`) lets tests require the websocket barrel without a relative path. Type tests use `tsd` (`tsd.directory: "tests"` in `package.json`): `tests/index.test-d.ts` plus one file per published subpath — `ws`, `engine`, `uws`, `fastify`, `express`, `scaling`, `sse`, `query` — and every new subpath must bring its own. **`tsd` type-checks with `@tsd/typescript` (5.9), not the repo's own `typescript` (7.x)**, so a type that relies on newer inference must be checked against both; `tests/cli/e2e.test.js` is the one place the repo's `tsc` runs, on the d.ts the CLI generated. + +Adapter tests take their frameworks from devDependencies and skip (never fail) when one is missing — see the `optional()`/`requireUws()` helpers in `tests/adapters/boots.js`, so a machine without a working `uWebSockets.js` binary can still run the suite. + +**Warning — a leaked uws engine hangs `node --test` forever.** A standalone engine holds a native listen socket; if a test fails an assertion before its cleanup line runs, the socket is never released and the whole run wedges instead of reporting a failure. Always register teardown with `t.after(...)` immediately after booting, never as a trailing `await close()` at the end of the test body. When running a suite that might hang, redirect output to a file and read it back with a kill guard — piping through `tail`/`head` buffers the output and shows nothing. + +## Performance conventions + +**Hot paths are hand-written; cold paths stay idiomatic.** A path is *hot* when it runs per WebSocket frame, per packet, per batch element, per stream chunk, per broadcast recipient, or per SSE line. Everything else — startup, router definition, introspection, reconnect, subscription resume, per-connection handshake, CLI codegen — is *cold*, and there `.map`/`.filter`/spread/`Object.entries` are the preferred spelling because they read better. This is a rule about **where**, not a ban on **what**: `.map` in `router.js` at definition time is correct code, and the same call per packet is not. + +On a hot path, prefer a manual indexed loop, preallocate with `new Array(len)` and truncate with `result.length = index` rather than growing, and use `for (const key in obj)` over `Object.entries(obj)` when the object is one this package built. Never write `push(...array)` — it is `Function.prototype.apply` in disguise and throws `RangeError` once the array exceeds the engine's argument limit, which turns a large cluster fan-in into a hard failure rather than a slow one (`src/rpc/cluster.js:384`). The established examples are `applyMask` in `src/websocket/frame.js:56` (word-wise XOR through a `Uint32Array` view), the once-sweep in `src/utils.js:46` (preallocate + index cursor + truncate, in place of `.filter`), and `SegmentQueue` in `src/websocket/segments.js:9`. + +**A hot-path hand-optimization must cite its benchmark in a comment.** If the win is not measured, the code is only harder to read — so the comment names the file that proves it, as `src/websocket/frame.js:52` cites `bench/unmask.js` and `src/utils.js:3` cites `bench/emitter.js`. New measurements go in a new top-level file under `bench/`; `bench/run-all.js` discovers it automatically. Capture before and after by hand — the harness in `bench/support/harness.js` prints ops/sec but stores no baseline, and nothing in CI runs `pnpm bench`. + +**Do not convert a `switch` or an `if`/`else if` chain into a table of handler functions.** The intuition that a table is the O(1) answer to a linear branch chain is half right, and the half that is wrong is the half that matters here. `bench/dispatch.js` measures all three shapes on this codebase's own discriminants: with the branch cost isolated, a 9-branch chain really is linear (165 M ops/s at the first branch, 69 M at the last) and a `switch` is flatter — but once a real handler call is in the loop, the call dominates and `switch` and the chain come out level (55.1 vs 55.0 M ops/s), while a table loses ~25% in every shape tested (42.4 M ops/s). V8 compiles a `switch` over string literals into direct comparisons and keeps the call target inlinable; a table yields a function *value*, which it cannot inline through, and `Object.freeze` and `Map` do not close the gap. So the branch chains in `src/rpc/dispatcher.js:383`, `src/client/core.js:657`, `src/sse/client.js:62` and the `switch` in `src/rpc/cluster.js:594` stay as they are. If a chain ever does become the bottleneck, order its branches by frequency first — that is free and keeps the guards next to the conditions they justify. + +**The dispatch tables that do exist are for late registration, not for speed.** `WrpcClient.transport` (`src/client/transports.js:198`) and `ServerTransport.transport` (`src/transport.js:282`) are plain object literals looked up as `TABLE[key]` and guarded with `typeof x !== 'function'`, and are deliberately **not** frozen, because a subpath entrypoint registers into them at require time (`src/sse/client.js:249`). Use that idiom when a third party must be able to add a case; freeze the table when the set is closed (`src/logging.js:25`). A table keyed on anything **peer-controlled** additionally needs `__proto__: null` or `Object.hasOwn` — a plain literal answers `TABLE['toString']` with a real function, which passes the `typeof` guard and silently swallows a packet that should have produced an honest error. `assignKey` in `src/rpc/router.js:127` and the two `Object.hasOwn` hops in `src/query/index.js:64` already defend against this. + +**A defensive copy is not an allocation to eliminate.** `Client#rooms` (`src/rpc/core.js:297`) exists so a caller can iterate while leaving; the chainable `Broadcast` copies in `src/rpc/rooms.js:195` are what make `b.to('a')` non-mutating; the listener snapshot on `Emitter.emit`'s multi-listener path guards against `off()` from inside a listener; and `Array.from(set)` in `src/scaling/redis.js:71` is required because that loop calls handlers synchronously. Where such a copy is genuinely unnecessary — `src/scaling/memory.js:42`, whose loop only schedules microtasks — say so in a comment, so the asymmetry reads as a decision rather than an oversight. + +**Some allocations are load-bearing for security, not correctness.** `Object.fromEntries(new URLSearchParams(...))` in `src/rpc/dispatcher.js:38` looks like an easy win and is not: `fromEntries` uses `CreateDataProperty`, so a `?__proto__=x` query string lands as an own property, while a hand-rolled `obj[key] = value` loop reintroduces prototype pollution on peer-controlled input. The cost there is the `URLSearchParams` parse anyway, not `fromEntries`. + +**Browser-reachable files are on a byte budget, and manual loops cost source bytes.** `src/client/*`, `src/utils.js`, `src/streams.js`, `src/sse/client.js`, `src/query/*` and `src/chunks.browser.js` all land in an entry with a min+gzip budget that `pnpm size` enforces in CI's lint job, and headroom is under 1 KB. Optimize there only with a measurement that justifies the bytes; if the bytes are earned, raise the budget in `scripts/size.js` alongside the change, with a comment saying why — the ratchet's documented escape hatch. `src/websocket/*`, `src/rpc/*`, `src/adapters/*` and `src/transport.js` are Node-only and carry no budget. + +**Two lint rules shape hot-path code.** `no-labels` is an error with `allowLoop: false`, so a manual loop may not use a labelled `break`/`continue` — restructure into a helper with an early `return` instead. `array-callback-return` is an error, so a `.map`/`.filter` callback must return on every path. ## Documentation site (`docs/`) -VitePress site deployed to Vercel (`vercel.json` pins the build command/output dir); `docs/` is never published to npm (the `files` field in `package.json` is an explicit allowlist that omits it). Currently a minimal skeleton (`docs/.vitepress/config.mts`, a `theme/` extending `DefaultTheme`, `docs/index.md` home page, one `docs/guide/getting-started.md` page) — expand the nav/sidebar in `config.mts` as more guide/API pages are added, following the fuller structure in `kerberos/docs/.vitepress/config.mts` as a reference. +VitePress 1.x site deployed to Vercel (`vercel.json` pins the build command/output dir); `docs/` is never published to npm (the `files` field in `package.json` is an explicit allowlist that omits it). It is the **full site, not a skeleton**: `docs/index.md` (home), a `guide/` track (introduction → server → client → transports & hosts → operations, plus `guide/adapters/`) and a `reference/` track (`protocol.md` — the wire protocol, frozen at 1.0 — `wire-format.md`, `engine.md`, `errors.md`). Every page is reachable from the sidebar in `docs/.vitepress/config.mts`, so **a new page means a new sidebar entry in the same change** — an orphan page is invisible. Two things in that config are hand-synced with `package.json` and are release-checklist items: the `nav` version label and the `keywords` array. + +`pnpm docs:build` fails on dead links, which is what CI's `docs` job is for. Lint/format deliberately do not cover `docs/`. + +**Diagrams are mermaid**, through `vitepress-plugin-mermaid` (already wired: the config is wrapped in `withMermaid`). Write them as ` ```mermaid ` fences. Three constraints that are easy to rediscover the hard way: the config must **not** set `mermaid.theme` (the plugin picks `default`/`dark` from VitePress' `isDark` and re-renders on toggle; setting it freezes diagrams in one theme); mermaid's CJS-only transitive deps (`dayjs`) need `vite.optimizeDeps.include` or the dev server fails to boot; and `.mermaid` label `line-height` must be pinned to 1.25 in `custom.css` or multi-line labels overflow the shapes mermaid measured them into. Check a new diagram in **both** themes and at mobile width. + +**Branding** lives in `docs/public/` (`logo-mark.svg` — the mark: a Node.js hexagon holding the two directions of an RPC — `favicon.png`, `logo.png` for `og:image`, `robots.txt`, `llms.txt`) and `docs/.vitepress/theme/custom.css` (Node.js green `#5FA04E` on ink `#1B1C1E`, applied by overriding VitePress CSS variables — the Pinia/Vite pattern, layout untouched). Light-theme `--vp-c-brand-1/-2` are **darkened** greens because `#5FA04E` fails WCAG AA as link text on white. The raster assets are generated from the mark by `node scripts/brand.js` (macOS `qlmanage`; re-run it after changing `logo-mark.svg`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7cb9048 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,193 @@ +# Contributing + +Thanks for looking. This file covers how to work on `@alexify/wrpc`, the house +rules that are not obvious from the code, and the release checklist. + +## Setup + +```bash +pnpm install +pnpm test +``` + +Requires **Node.js ≥ 22** and **pnpm** (`packageManager` pins the version). +There is no build step: `src/` ships as-is. + +## Commands + +```bash +pnpm test # node --test, recursive over tests/ +pnpm test:coverage # c8 over src/ — thresholds 95 lines / 95 statements / 90 branches / 95 functions +pnpm test:types # tsd against the .d.ts files +pnpm test:perf # the 1 GiB stream memory guard +pnpm lint # oxlint +pnpm format # oxfmt (format:check in CI) +pnpm size # bundle-size report; browser budgets fail the run +pnpm bench # every script in bench/ +pnpm docs:dev # the VitePress site (docs:build / docs:preview too) +``` + +Run a single file with `node --test tests/smoke.test.js`, or filter by name +with `--test-name-pattern`. `node --test` takes **files**, not directories — a +targeted run of a folder needs a glob (`node --test tests/adapters/*.test.js`). + +## House rules + +**Zero runtime dependencies.** `package.json` has no `dependencies` field, and +no `peerDependencies` or `optionalDependencies` either. Anything reused from +another package is copied in and adapted, not installed. Optional integrations +(uWebSockets.js, Redis, fastify, express, TanStack) are **injected by the +caller** and validated structurally — duck typing, never `instanceof` against +an imported class. Nothing under `src/` may `require()` a framework. + +**A `.d.ts` change and its `tsd` test land together.** The declarations are +hand-maintained, one file per subpath, and they drift silently otherwise. Every +new subpath brings a root shim, a root `.d.ts`, an `exports` entry, a `files` +entry and a `tests/.test-d.ts`. + +**`tsd` type-checks with `@tsd/typescript` (5.9), not the repo's own +`typescript`.** A type relying on newer inference has to be checked against +both. + +**Wire-protocol changes are documented in +[`docs/reference/protocol.md`](./docs/reference/protocol.md)**, which is frozen +at 1.0: additive, optional fields within a major version; anything else is a +major. + +**Style** is enforced by oxlint/oxfmt: 2-space indent, single quotes, +semicolons, 120-column lines. The `correctness` category is intentionally off, +and `no-void` is intentionally not enabled — `return void fn()` is the idiom +this codebase uses for explicit fire-and-forget. + +### Tests + +Tests live in `tests/`, run by Node's built-in runner. Files that are **not** +`*.test.js` are shared helpers, not tests: `tests/websocket/mockSocket.js`, +`tests/websocket/protocolClient.js`, `tests/engine/engineContract.js` (the +engine port contract suite), and the swap-test pair `tests/adapters/boots.js` + +`tests/adapters/spec.js` — one behavioural spec replayed against every way of +standing wrpc up. + +Adapter tests **skip** (never fail) when a framework is missing, so a machine +without a working `uWebSockets.js` binary can still run the suite. CI has them +all. + +> **A leaked uws engine hangs `node --test` forever.** A standalone engine holds +> a native listen socket; if a test fails an assertion before its cleanup line +> runs, the socket is never released and the whole run wedges instead of +> reporting a failure. Register teardown with `t.after(...)` **immediately after +> booting**, never as a trailing `await close()` at the end of the test body. + +## CI + +Three jobs (`.github/workflows/ci.yml`): + +- **lint** (Node 22) — `lint`, `format:check`, `size` +- **test** (Node 22 and 24) — `test:coverage`, `test:types` +- **docs** (Node 22) — `docs:build`, which fails on dead links + +Lint and format deliberately target `src tests scripts bench bin` only, so +`docs/` is not covered by them. + +Three checks are deliberately **not** in CI — run them by hand: + +- `node scripts/autobahn/run.js` — the RFC 6455/7692 conformance suite against + the engine in `src/websocket/`. Needs docker; several minutes for 500+ + cases. `FAILED` and `WRONG CODE` fail the run, `NON-STRICT` and + `INFORMATIONAL` do not. +- `pnpm test:perf` — the 1 GiB stream memory guard. +- `REDIS_URL=redis://127.0.0.1:6379 node --test tests/scaling/redis.integration.test.js` + — the scaling backplane against a real Redis. `pnpm test` already covers the + adapter's contract through an in-repo ioredis-shaped fake + (`tests/scaling/redis.test.js`); this is only useful when you want to check + a live server, and the file skips itself without `REDIS_URL`. + +## Stability and deprecation + +The published surface is stable under semver, with two carve-outs marked +`@experimental` in the `.d.ts` files: + +- the **telemetry** writer shapes and metric set (`telemetry` option) — the + signals will keep improving in minors; +- the **engine port** internals beyond the documented `WrpcSocket` contract + (`capabilities` in particular). + +An `@experimental` API may change in a minor release, with the change +described in the CHANGELOG. Everything else follows the usual rule: removal +or breaking change of a stable API needs (1) a deprecation note in the +CHANGELOG and the docs for at least one minor release, and (2) a major +version to actually remove it. The wire protocol has its own, stronger +promise — see [protocol.md](./docs/reference/protocol.md#stability): packet +shapes never break inside a major, and the `wrpc.v1` subprotocol names the +revision on the wire. + +## Release checklist + +Releases are **manual**. Nothing in the tooling bumps a version, tags, or +publishes. + +1. **Green everything** on the release commit: + ```bash + pnpm lint && pnpm format:check && pnpm test:coverage && pnpm test:types && pnpm size && pnpm docs:build + ``` + `tests/package/consistency.test.js` already fails this step if + `docs/.vitepress/config.mts`'s keywords or nav version label drift from + `package.json` — there is no separate manual sync step for either. +2. **Move `[Unreleased]` to the new version** in `CHANGELOG.md`, with the date, + and open a fresh empty `[Unreleased]` above it. Before the first publish, + `[Unreleased]` carries a "not published to npm yet" note right under its + heading — delete that note as part of this move; it does not apply again + after v1.0.0 ships. +3. **Bump `version` in `package.json`, and confirm it differs from what npm + already has.** Semver against the **JavaScript API**; the wire protocol has + [its own promise](./docs/reference/protocol.md#stability). Before the first + publish there is nothing on the registry to compare against, so this half + of the step is a no-op; from the second release on, run + `npm view @alexify/wrpc version` and make sure the bumped value doesn't + match it — publishing an already-used version is a rejected `npm publish`, + not a warning. +4. **Check what would ship.** `files` in `package.json` is an explicit + allowlist, so a new root shim or `.d.ts` that was not added to it silently + disappears from the tarball: + ```bash + npm publish --dry-run + ``` + Verify the file list contains every root shim, every `.d.ts`, `bin/`, `src/`, + and **not** `docs/`, `tests/`, `bench/`, `scripts/`, `examples/` or + `coverage/`. +5. **Verify the tarball resolves**, CommonJS and ESM alike. In a scratch + directory: + ```bash + npm pack + npm install ./alexify-wrpc-.tgz + node -e "require('@alexify/wrpc'); require('@alexify/wrpc/engine'); require('@alexify/wrpc/query')" + node --input-type=module -e "import { Server, connect } from '@alexify/wrpc'; import { createNodeEngine } from '@alexify/wrpc/engine'; void Server; void connect; void createNodeEngine;" + npx wrpc --version + ``` + and that a bundler-resolution consumer type-checks against the shipped + `.d.ts` files (`tsd` only ever checks `tests/`, never the installed + tarball): + ```bash + printf "import { connect } from '@alexify/wrpc';\nvoid connect;\n" > /tmp/wrpc-esm-smoke.ts + npx tsc --moduleResolution bundler --module esnext --noEmit /tmp/wrpc-esm-smoke.ts + ``` +6. **Commit, tag, push**: `git tag v && git push --follow-tags`. +7. **Publish**: `npm publish` (`publishConfig.access` is already `public`). +8. **Release notes** on GitHub from the CHANGELOG entry. +9. **The docs site deploys from `main` through Vercel** (`vercel.json` pins + the build command and output directory) — no separate step, but confirm it + actually landed before announcing anything: + ```bash + curl -o /dev/null -sw '%{http_code}\n' https://wrpc.vercel.app/ + curl -o /dev/null -sw '%{http_code}\n' https://wrpc.vercel.app/reference/protocol + ``` + Both must answer `200` — the site has previously 404'd on everything while + the deploy was pointed at a branch without the docs on it. +10. **Refresh the size numbers quoted in prose.** README's bundle-size table, + `docs/guide/browser.md`, and any "under N KB" claim in `docs/index.md` or + `docs/guide/client.md` are hand-copied from the `pnpm size` output already + produced in step 1 — there is no guard test for prose, so they drift the + moment a budget changes and nothing catches it until someone notices. + +Releases stay a person running these commands by hand, on purpose: no +GitHub Actions release workflow, no publish provenance step. diff --git a/README.md b/README.md index efe28bc..bb5e002 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,345 @@ -# wrpc +

+ wrpc +

-Fast and low overhead, zero-dependency, WebSocket-based RPC protocol for Node.js and browsers. +

wrpc

+ +[![npm](https://img.shields.io/npm/v/%40alexify%2Fwrpc)](https://www.npmjs.com/package/@alexify/wrpc) +[![CI](https://github.com/Alexis-Technologies/wrpc/actions/workflows/ci.yml/badge.svg)](https://github.com/Alexis-Technologies/wrpc/actions/workflows/ci.yml) +[![node](https://img.shields.io/node/v/%40alexify%2Fwrpc)](#installation) +[![dependencies](https://img.shields.io/badge/runtime_dependencies-0-brightgreen)](#bundle-size) +[![docs](https://img.shields.io/badge/docs-online-blue)](https://wrpc.vercel.app/) +[![license](https://img.shields.io/npm/l/%40alexify%2Fwrpc)](./LICENSE) + +A fast, **zero-dependency** WebSocket-based RPC protocol for Node.js and +browsers. Router and procedures, subscriptions that resume, rooms +that scale across processes, and binary streams with backpressure that reaches +all the way into TCP — [under 15 KB min+gzip](#bundle-size) in a browser bundle, and +nothing at all in your lockfile. + +```javascript +const { Server, WrpcClient, defineRouter, procedure } = require('@alexify/wrpc'); + +const router = defineRouter({ + greeting: { + hello: procedure({ + access: 'public', + handler: async (context, { name }) => `Hello, ${name}`, + }), + }, +}); + +const server = new Server({ router, host: '127.0.0.1', port: 8000, protocol: 'http' }); +await server.listen(); + +const client = await WrpcClient.connect('ws://127.0.0.1:8000/api'); +await client.load('greeting'); +await client.api.greeting.hello({ name: 'World' }); // → 'Hello, World' +``` + +📖 **[Full documentation](https://wrpc.vercel.app)** · [Getting +started](https://wrpc.vercel.app/guide/getting-started) · [Wire +protocol](https://wrpc.vercel.app/reference/protocol) + +### Why zero dependencies? + +**Nothing extra in the process handling your RPC traffic.** `package.json` has +no `dependencies` field at all — and no `peerDependencies` or +`optionalDependencies` either, because those are dependencies with extra steps. +The WebSocket engine is written from scratch (RFC 6455 + 7692), and the handful +of utilities borrowed from elsewhere were copied in and adapted rather than +installed. + +**Optional integrations are injected, not depended on.** uWebSockets.js, Redis, +fastify, express and TanStack Query all work — you `require` them and hand them +over, and wrpc duck-types the injection at the boundary. So the integrations +you do not use cost you nothing, and the ones you do use are on your version, +not ours. + +**One protocol, both sides.** The browser build contains zero Node builtins, so +the client that talks to your server is the same implementation, not a +reimplementation. + +### Positioning + +| | **wrpc** | **tRPC** | **Socket.IO** | +| --- | --- | --- | --- | +| Transport | WebSocket, HTTP, SSE, worker port | HTTP, WebSocket | WebSocket + long-poll fallback | +| Types | contract-first + codegen, no build step | TypeScript inference | none built in | +| Realtime | subscriptions, events, rooms, acks | subscriptions | events, rooms, acks | +| Binary | streams with backpressure | ❌ | ❌ (messages only) | +| Scaling | rooms backplane (any pub/sub) | your own | adapters | +| Cluster ops | fetchClients, commands, presence (local read) | your own | fetchSockets (round-trip) | +| Runtime deps | **0** | a few | several | +| Needs TypeScript | ❌ | effectively yes | ❌ | + +### When NOT to use wrpc + +- **You want a public HTTP API.** wrpc has a REST mode, but it is a convenience + for reaching procedures, not an API design. If third parties consume it, + write an HTTP API and document it. +- **Your stack is polyglot.** The protocol is documented and small enough to + reimplement, but the only implementation today is JavaScript. gRPC exists for + a reason. +- **You need guaranteed delivery.** Rooms are at-most-once fan-out, not a + queue. Subscriptions with `tracked()` values and an event log let a client + catch up; if you need a broker's guarantees, use a broker. +- **You want end-to-end type inference from server code.** tRPC's model — where + the client's types come from the server's implementation with nothing written + twice — is genuinely nicer if your whole stack is TypeScript. wrpc's contract + is declared or generated, deliberately, because it must also work for + JavaScript users and across a network boundary the compiler cannot see. + +## Table of Contents + +- [Installation](#installation) · [Bundle size](#bundle-size) · [Browser usage](#browser-usage) +- [Quick Start](#quick-start) +- [Features](#features) +- [Exports](#exports) +- [Documentation](#documentation) +- [Contributing](#contributing) · [Changelog](#changelog) · [Security](#security) · [License](#license) ## Installation ```bash -pnpm add @alexify/wrpc +npm install @alexify/wrpc ``` +Requires **Node.js ≥ 22** (or any modern browser through a bundler). The +package is CommonJS and ships as-is — no build step and no transpile; +`require('@alexify/wrpc')` and `import { Server } from '@alexify/wrpc'` both +work. + +### Bundle size + +Measured with `pnpm size` (esbuild, fully minified with identifier mangling, +then gzipped): + +| Entry | min | min+gzip | budget | +| ----- | ---:| --------:| ------:| +| `@alexify/wrpc` — browser (client, streams, chunks) | 44.3 KB | **14.9 KB** | 15.0 KB | +| `@alexify/wrpc` — node (client + server) | 158.2 KB | 52.2 KB | — | +| `@alexify/wrpc/ws` (WebSocket engine) | 20.7 KB | 7.5 KB | — | +| `@alexify/wrpc/engine` (engine port) | 21.2 KB | 7.7 KB | — | +| `@alexify/wrpc/uws` (uWebSockets.js adapter) | 16.0 KB | 6.4 KB | — | +| `@alexify/wrpc/fastify` | 135.3 KB | 46.2 KB | — | +| `@alexify/wrpc/express` | 122.9 KB | 41.3 KB | — | +| `@alexify/wrpc/scaling` (rooms backplane) | 4.1 KB | 1.7 KB | — | +| `@alexify/wrpc/sse` — browser (client transport) | 47.2 KB | **15.9 KB** | 16.0 KB | +| `@alexify/wrpc/sse` — node | 86.1 KB | 28.6 KB | — | +| `@alexify/wrpc/query` (TanStack bindings) | 2.7 KB | **1.1 KB** | 2.0 KB | +| `@alexify/wrpc/auth` (token strategies) | 3.3 KB | **1.5 KB** | 2.0 KB | + +The Node-only rows are reported for visibility into what each subpath pulls in +— they never ship to a browser, and the adapter rows include the whole core +because they bundle it. Only the browser-reachable entries carry a **budget**, +and exceeding one **fails CI**: it is a ratchet against accidental bloat, raised +deliberately in the same change that earns the bytes. + +Injected packages (uWebSockets.js, ioredis, fastify, express, +`@tanstack/query-core`) are never bundled — you install what you use. + ### Browser usage -The package ships a browser entry (`browser.js`), resolved automatically by bundlers that honor the `package.json` `browser` field (webpack, Vite, esbuild `platform: browser`, Rollup, Parcel, Bun). It exposes `WrpcClient`/`WrpcClientProxy` and the stream/chunk helpers, and excludes the Node-only server code. +The package ships a browser entry (`browser.js`), resolved automatically by any +bundler that honours the `package.json` `browser` field and the `browser` +condition in `exports` — webpack 5, Vite, esbuild (`platform: 'browser'`), +Parcel, Bun. Rollup users need +[`@rollup/plugin-node-resolve`](https://github.com/rollup/plugins/tree/master/packages/node-resolve) +with `browser: true`. + +It contains `WrpcClient`/`WrpcClientProxy`, the stream and chunk helpers, and +**zero Node builtins** — the server half is not in it. The two platform-specific +pairs (`chunks`, `runtime`) are swapped automatically, and `scripts/size.js` +fails the build if any browser entry ever resolves a `node:` import or a +package. ## Quick Start -```js -const { Server, WrpcClient } = require('@alexify/wrpc'); - -class Procedure { - constructor({ access = 'public', handler }) { - this.access = access; - this.handler = handler; - } - async enter() {} - leave() {} - invoke(context, args) { - return this.handler(args, context); - } -} +A router of procedures, a server, a client: -const api = { - system: { - introspect: { handler: async () => api }, - }, - greeting: { - hello: { handler: async ({ name }) => `Hello, ${name}` }, +```javascript +const { Server, WrpcClient, defineRouter, procedure, tracked } = require('@alexify/wrpc'); + +const router = defineRouter({ + chat: { + // a call + send: procedure({ + access: 'session', + input: (args) => { if (!args?.text) throw new Error('text is required'); }, + handler: async (context, { text }) => { + const sent = context.server.to('lobby').emit('chat/message', { text }); + return { sent }; + }, + }), + // a subscription: many values, one connection + onMessage: procedure.subscription({ + access: 'session', + handler: async function* (context, args, { lastEventId, signal }) { + for await (const message of feed({ signal })) yield tracked(message.id, message); + }, + }), + // an inbound, fire-and-forget event + on: { + typing: procedure({ handler: async (context, data) => announce(data) }), + }, }, -}; - -const application = { - console, - static: { constructor: { name: 'Static' } }, - auth: { saveSession: async () => {} }, - getMethod: (unit, _version, method) => { - const def = api[unit]?.[method]; - return def ? new Procedure(def) : null; + auth: { + login: procedure({ + access: 'public', + handler: async (context, { user }) => { + context.client.startSession(undefined, { user }); + return { ok: true }; + }, + }), }, -}; +}); -const server = new Server(application, { host: '127.0.0.1', port: 8000, protocol: 'http' }); +const server = new Server({ router, host: '127.0.0.1', port: 8000, protocol: 'http' }); await server.listen(); +``` -const client = await WrpcClient.connect('ws://127.0.0.1:8000/'); -await client.load('greeting'); -const result = await client.api.greeting.hello({ name: 'World' }); +```javascript +const client = await WrpcClient.connect('ws://127.0.0.1:8000/api'); +await client.load('chat', 'auth'); + +await client.api.auth.login({ user: 'ada' }); +client.api.chat.on('message', ({ text }) => console.log(text)); +client.sendEvent('chat/typing', { who: 'ada' }); + +for await (const message of client.api.chat.onMessage.iterate()) { + console.log(message); +} ``` -## API Reference +## Features + +| Area | What you get | +| ---- | ------------ | +| **RPC** | [Router and procedures](https://wrpc.vercel.app/guide/router) with access control, [Standard Schema](https://standardschema.dev) validators, timeouts, concurrency queues and versioned units | +| **Realtime** | [Events both ways](https://wrpc.vercel.app/guide/rooms), [rooms](https://wrpc.vercel.app/guide/rooms), [acks](https://wrpc.vercel.app/guide/rooms#asking-a-room) (`client.ask`, `to(room).ask`), [subscriptions with resume](https://wrpc.vercel.app/guide/subscriptions), cancellation, call batching | +| **Hooks** | [Lifecycle phases](https://wrpc.vercel.app/guide/hooks) in the fastify tradition — router, unit and procedure levels flattened into one pipeline; no `(ctx, next)` middleware | +| **Streams** | [Binary upload/download](https://wrpc.vercel.app/guide/streams) interleaved on one connection, with end-to-end backpressure | +| **REST** | [Declarative endpoints](https://wrpc.vercel.app/guide/rest) on the same procedures — verb, path, status, fastify-shaped schemas, `/vN` version paths, OpenAPI via [`wrpc types --openapi`](https://wrpc.vercel.app/guide/cli) | +| **Sessions & auth** | [Cookie-backed sessions](https://wrpc.vercel.app/guide/sessions) restored on reconnect, pluggable store, CSRF-aware REST dispatch; [bearer/payload token carriers, client stores and the authenticate/refresh lifecycle](https://wrpc.vercel.app/guide/auth) | +| **Scaling** | [Rooms backplane](https://wrpc.vercel.app/guide/scaling) over any pub/sub; Redis and in-memory adapters included; [cluster layer](https://wrpc.vercel.app/guide/cluster) — replicated presence (`count` with no round-trip), `fetchClients`, cross-instance commands, node-to-node ask | +| **Transports** | WebSocket, plain HTTP, [Server-Sent Events](https://wrpc.vercel.app/guide/sse), Service Worker `MessagePort`; [pluggable wire codec](https://wrpc.vercel.app/guide/codec) and [request metadata](https://wrpc.vercel.app/guide/metadata) (`x-wrpc-meta-`) | +| **Hosts** | Batteries-included [server](https://wrpc.vercel.app/guide/server), or [fastify](https://wrpc.vercel.app/guide/adapters/fastify) / [express](https://wrpc.vercel.app/guide/adapters/express) / [uWebSockets.js](https://wrpc.vercel.app/guide/adapters/uws) / bare `node:http` | +| **Client** | Exponential backoff with full jitter, app-level heartbeat, automatic re-`load()` and re-subscribe, offline/online | +| **Observability** | [Structured logging](https://wrpc.vercel.app/guide/logging) into your pino, [OpenTelemetry](https://wrpc.vercel.app/guide/telemetry) spans and metrics, W3C trace context across the wire | +| **Operations** | [Security](https://wrpc.vercel.app/guide/security) hardening, [graceful drain and shutdown](https://wrpc.vercel.app/guide/production), [benchmarks](https://wrpc.vercel.app/guide/performance) you can reproduce, [testing patterns](https://wrpc.vercel.app/guide/testing) | +| **DX** | [Contract-first typed client](https://wrpc.vercel.app/guide/typed-client), [`wrpc types` codegen](https://wrpc.vercel.app/guide/cli), [TanStack Query bindings](https://wrpc.vercel.app/guide/query), hand-maintained `.d.ts` for every subpath | + +## Observability + +Both are **injected, never depended on** — the package still has no +dependencies. Your logger and your OpenTelemetry SDK are duck-typed. -See [index.d.ts](./index.d.ts) for the full public surface: `WrpcClient`/`WrpcClientProxy` (client), `Server`/`Client`/`Context`/`Session` (server), `WrpcReadable`/`WrpcWritable` (streams), and `WebsocketServer`/`Connection`/`Frame`/`FrameParser` (the underlying WebSocket implementation). +```js +const pino = require('pino'); +const api = require('@opentelemetry/api'); + +const server = new Server({ router, logger: pino(), telemetry: { api } }); +``` -## Benchmarks +`logger` takes a structured logger (pino, bunyan, winston), a `Console`, or +`false` to go silent. wrpc binds children for you — `component`, `peer` and a +per-call `callId` — and `context.log` inside a handler is already scoped to +that call. -_Coming soon — see `bench/bench.js`._ +`telemetry` takes the `@opentelemetry/api` module or your own +`{ tracer, meter }`. Spans follow the OTel `rpc.*` convention, fourteen metrics +cover calls, connections, subscriptions, broadcasts and streams, and `call` +packets carry W3C trace context so a client span parents the server's across +the network hop. + +Neither can break a request: a logger that throws, a broken exporter or a meter +that dies is contained at the call site. + +See [Logging](https://wrpc.vercel.app/guide/logging) and +[OpenTelemetry](https://wrpc.vercel.app/guide/telemetry). + +## Exports + +| Subpath | Exports | Docs | +| --- | --- | --- | +| `@alexify/wrpc` | `Server`, `RpcServer`, `WrpcClient`, `connect`, `defineRouter`, `procedure`, `tracked`, `createEventLog`, `createEventStream`, `MemorySessionStore`, `WrpcReadable`, `WrpcWritable`, `WrpcError`, `chunkEncode`/`chunkDecode` | [Server](https://wrpc.vercel.app/guide/server) · [Client](https://wrpc.vercel.app/guide/client) | +| `@alexify/wrpc/ws` | `WebsocketServer`, `Connection`, `Frame`, `FrameParser`, `OPCODES`, `CLOSE_CODES` | [Wire format](https://wrpc.vercel.app/reference/wire-format) | +| `@alexify/wrpc/engine` | `createNodeEngine`, `isEngine`, the `Engine`/`WrpcSocket` contracts | [Engine port](https://wrpc.vercel.app/reference/engine) | +| `@alexify/wrpc/uws` | `createUwsEngine`, `UwsSocket` | [uWebSockets.js](https://wrpc.vercel.app/guide/adapters/uws) | +| `@alexify/wrpc/fastify` | `wrpcFastify`, `findUwsApp` | [Fastify](https://wrpc.vercel.app/guide/adapters/fastify) | +| `@alexify/wrpc/express` | `createWrpc` | [Express](https://wrpc.vercel.app/guide/adapters/express) | +| `@alexify/wrpc/scaling` | `MemoryBackplane`, `createRedisAdapter`, `isBackplane` | [Scaling](https://wrpc.vercel.app/guide/scaling) | +| `@alexify/wrpc/sse` | `SseChannels`, `ServerSseTransport`, `ClientSseTransport`, `SseParser` | [Server-Sent Events](https://wrpc.vercel.app/guide/sse) | +| `@alexify/wrpc/query` | `createQueryUtils` | [TanStack Query](https://wrpc.vercel.app/guide/query) | +| `@alexify/wrpc/auth` | `bearerAuth`, `memoryStore`, `webStorage`, `cookieStorage`, `bearerTransport`, `payloadTransport` | [Authentication](https://wrpc.vercel.app/guide/auth) | +| `wrpc` (bin) | `wrpc types --out api.d.ts` | [Codegen CLI](https://wrpc.vercel.app/guide/cli) | + +Every subpath ships hand-maintained TypeScript declarations — no generation, no +`any` where a real type belongs. + +## Documentation + +- **Start here** — [getting started](https://wrpc.vercel.app/guide/getting-started), + [why wrpc?](https://wrpc.vercel.app/guide/why). +- **Server** — [server](https://wrpc.vercel.app/guide/server), + [router](https://wrpc.vercel.app/guide/router), + [hooks](https://wrpc.vercel.app/guide/hooks), + [sessions](https://wrpc.vercel.app/guide/sessions), + [rooms](https://wrpc.vercel.app/guide/rooms), + [subscriptions](https://wrpc.vercel.app/guide/subscriptions), + [streams](https://wrpc.vercel.app/guide/streams), + [scaling](https://wrpc.vercel.app/guide/scaling), + [cluster](https://wrpc.vercel.app/guide/cluster), + [REST](https://wrpc.vercel.app/guide/rest), + [authentication](https://wrpc.vercel.app/guide/auth), + [metadata](https://wrpc.vercel.app/guide/metadata). +- **Client** — [client](https://wrpc.vercel.app/guide/client), + [typed client](https://wrpc.vercel.app/guide/typed-client), + [browser & bundling](https://wrpc.vercel.app/guide/browser), + [CLI](https://wrpc.vercel.app/guide/cli), + [TanStack Query](https://wrpc.vercel.app/guide/query). +- **Transports & hosts** — [SSE](https://wrpc.vercel.app/guide/sse), + [wire codec](https://wrpc.vercel.app/guide/codec), + [uWebSockets.js](https://wrpc.vercel.app/guide/adapters/uws), + [fastify](https://wrpc.vercel.app/guide/adapters/fastify), + [express](https://wrpc.vercel.app/guide/adapters/express). +- **Operations** — [security](https://wrpc.vercel.app/guide/security), + [running in production](https://wrpc.vercel.app/guide/production), + [testing](https://wrpc.vercel.app/guide/testing), + [performance](https://wrpc.vercel.app/guide/performance), + [logging](https://wrpc.vercel.app/guide/logging), + [OpenTelemetry](https://wrpc.vercel.app/guide/telemetry). +- **Reference** — [wire protocol](https://wrpc.vercel.app/reference/protocol) + (frozen at 1.0), [wire format](https://wrpc.vercel.app/reference/wire-format), + [engine port](https://wrpc.vercel.app/reference/engine), + [errors & close codes](https://wrpc.vercel.app/reference/errors). +- **Types** — [`index.d.ts`](./index.d.ts) is the full public surface, plus one + `.d.ts` per subpath. + +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for the development workflow, the +house rules (zero dependencies, `.d.ts` and `tsd` in the same change) and the +release checklist. + +```bash +pnpm test # node --test, recursive +pnpm test:coverage # c8, thresholds 95/95/90/95 +pnpm test:types # tsd +pnpm lint # oxlint +pnpm size # bundle-size budgets +pnpm docs:dev # the documentation site +``` ## Changelog See [CHANGELOG.md](./CHANGELOG.md). +## Security + +See [SECURITY.md](./SECURITY.md) for how to report a vulnerability. + ## License MIT — see [LICENSE](./LICENSE). diff --git a/SECURITY.md b/SECURITY.md index fe36adf..abb2207 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,12 +2,55 @@ ## Reporting a vulnerability -Please report suspected vulnerabilities privately via +Report suspected vulnerabilities **privately** via [GitHub Security Advisories](https://github.com/Alexis-Technologies/wrpc/security/advisories/new) -or by emailing the author (see `package.json`). Do not open public issues for -security reports. You should receive a response within a few days. +or by emailing the author (see `package.json`). Do not open public issues or +pull requests for security reports — a fix should ship before the details do. + +What to include: the affected surface (see below), a minimal reproduction, +and the impact as you understand it. Reports are acknowledged within +**7 days**; a confirmed vulnerability gets a fix or a mitigation plan within +**30 days**, and coordinated disclosure (an advisory + a patched release) +once the fix is out. Credit is given unless you ask otherwise. + +## Scope + +wrpc implements its own protocol surfaces from scratch, and they are the +areas where a report matters most: + +- **The WebSocket engine** (`src/websocket/`): RFC 6455 handshake and + framing, RFC 7692 permessage-deflate — parsing hostile bytes off the wire, + inflation limits (`maxPayload`), buffering limits (`maxBuffer`, + `maxBackpressure`). +- **The RPC dispatch layer** (`src/rpc/`): packet parsing, access control + (`access`, hooks), session handling and cookies (token generation, + RFC 6265 encoding, the `Set-Cookie` path), prototype-pollution guards on + wire-supplied keys. +- **The HTTP surface** (`src/transport.js`, `src/adapters/`, `src/sse/`): + CORS policy, the sec-fetch-site CSRF gate, SSE channel identity binding + and caps, body-size limits. +- **The auth surfaces** (`src/auth/`, the `sessions.transport` seam in + `src/rpc/sessions.js`): where the session token travels on the wire — + bearer/payload carriers, the ws subprotocol carrier, client token stores + — and the `authenticate`/`refresh` client lifecycle around it. The most + security-sensitive recent code in the package. +- **The cluster surface** (`src/rpc/cluster.js`): the backplane is a trust + peer of every node; the opt-in `cluster.secret` HMAC and the command + shape validation exist to be unbreakable too. +- **The codegen CLI** (`src/cli/`): everything read from a remote server's + introspection is untrusted input to a file generator. + +Denial-of-service through resource exhaustion on any of these surfaces is +in scope; the limits exist to be unbreakable. ## Supported versions -Only the latest published release receives security fixes. The package has -not been published to npm yet. +| Version | Supported | +| --- | --- | +| Unreleased (`main` at HEAD, pre-first-publish) | ✔ fixes land on `main` | +| Latest published minor (once 1.0.0 ships) | ✔ security fixes | +| Older releases | ✖ upgrade to the latest | + +The wire protocol carries its own compatibility promise +([protocol.md](./docs/reference/protocol.md#stability)) — a security fix +will never need a wire-breaking change inside a major version. diff --git a/auth.d.ts b/auth.d.ts new file mode 100644 index 0000000..02dead2 --- /dev/null +++ b/auth.d.ts @@ -0,0 +1,86 @@ +// Hand-maintained types for the @alexify/wrpc/auth subpath: token stores +// (client), token transports (server), and the bearerAuth() composition. +// Structural throughout — nothing here imports the other declaration files. + +import type { WrpcClient, RefreshHook, WrpcClientOptions } from './client.js'; + +/** + * Where the client keeps tokens. Sync or async — IndexedDB is only + * reachable through async, so every caller awaits either way. A `Map` + * already satisfies the contract. + */ +export interface TokenStore { + get(key: string): unknown | Promise; + set(key: string, value: unknown): unknown | Promise; + delete(key: string): unknown | Promise; +} + +export declare function isTokenStore(value: unknown): value is TokenStore; + +/** In-memory, per-tab: a Map IS the contract. */ +export declare function memoryStore(): Map; + +/** Structural view of localStorage/sessionStorage. */ +export interface WebStorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +/** localStorage/sessionStorage-backed store; values JSON-encoded. */ +export declare function webStorage(storage: WebStorageLike, options?: { prefix?: string }): TokenStore; + +/** + * A JS-readable cookie as the store (document.cookie) — NOT the HttpOnly + * session cookie, which belongs to the server's cookie token transport. + */ +export declare function cookieStorage( + doc: { cookie: string }, + options?: { prefix?: string; maxAge?: number; path?: string }, +): TokenStore; + +export interface BearerAuthOptions { + store: TokenStore; + /** Returns the token pair to store (`{ access, ... }`) or falsy for none. */ + signIn: (client: WrpcClient, info: { reconnected: boolean; attempts: number }) => unknown; + /** Receives the stored tokens; what it returns replaces them (falsy clears). */ + refresh?: (client: WrpcClient, tokens: unknown, error: unknown) => unknown; + /** The store key; default 'tokens'. */ + key?: string; + /** Refresh trigger codes; default [401, 403]. */ + on?: Array; +} + +/** + * The client-side composition: `headers` (Authorization on every open), + * `authenticate` (signs in when the store is empty, awaited before the + * reconnect restore) and single-flight `refresh` — ready to spread into + * `connect(url, bearerAuth({ ... }))`. + */ +export declare function bearerAuth(options: BearerAuthOptions): Pick< + WrpcClientOptions, + 'headers' | 'authenticate' +> & { refresh?: { on: Array; handler: RefreshHook } }; + +/** + * The server half (`sessions.transport`): token in + * `Authorization: ` — the real header where the transport + * can send one, the `wrpc_h` connect-URL parameter on browser ws. + * Non-ambient: exempt from the safe-method CSRF rule, `write()` stamps + * nothing. + */ +export declare function bearerTransport(options?: { scheme?: string }): { + ambient: false; + read(request: { headers?: Record | undefined>; url?: string }): string | null; + write(token: string): null; +}; + +/** + * The server half for token-in-metadata: a field of the client's declared + * `meta` option (`meta: { token }` / the x-wrpc-meta header). + */ +export declare function payloadTransport(options?: { field?: string }): { + ambient: false; + read(request: { headers?: Record | undefined>; url?: string }): string | null; + write(token: string): null; +}; diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..9d266fb --- /dev/null +++ b/auth.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./src/auth/index.js'); diff --git a/bench/batch-ordering.js b/bench/batch-ordering.js new file mode 100644 index 0000000..296377d --- /dev/null +++ b/bench/batch-ordering.js @@ -0,0 +1,100 @@ +/** + * `ServerHttpTransport#ordered` — the HTTP batch reordering step. + * + * A batch frame gets ONE response carrying every answer, emitted in the order + * the packets were sent so a caller can zip requests to responses positionally. + * Answers arrive in completion order, so they have to be put back. + * + * The obvious complaint about the original is that it is O(n^2): a findIndex + * scan per batch slot. The obvious fix — index the answers by id — is a + * pessimization at the sizes that actually occur, because building the Map + * costs more than the quadratic it removes until n gets large. This bench is + * what sets the threshold in src/transport.js, so re-run it before changing it. + * + * Run with `node bench/batch-ordering.js` or `pnpm bench`. + */ + +'use strict'; + +const SIZES = [4, 8, 16, 24, 32, 64, 128]; +const MEASURE_MS = 300; + +const scan = (batch, collected) => { + const pending = collected.slice(); + const answers = []; + for (const id of batch) { + const index = pending.findIndex((packet) => packet.id === id); + if (index < 0) continue; + answers.push(pending[index]); + pending.splice(index, 1); + } + for (let i = 0; i < pending.length; i++) answers.push(pending[i]); + return answers; +}; + +const indexed = (batch, collected) => { + // Buckets, not single packets: two answers may legitimately share an id. + const byId = new Map(); + for (let i = 0; i < collected.length; i++) { + const packet = collected[i]; + const bucket = byId.get(packet.id); + if (bucket === undefined) byId.set(packet.id, [i]); + else bucket.push(i); + } + const answers = []; + const taken = new Uint8Array(collected.length); + for (const id of batch) { + const bucket = byId.get(id); + if (bucket === undefined || bucket.length === 0) continue; + const index = bucket.shift(); + taken[index] = 1; + answers.push(collected[index]); + } + // Leftovers in COLLECTION order, matching the scan's trailing push. + for (let i = 0; i < collected.length; i++) if (!taken[i]) answers.push(collected[i]); + return answers; +}; + +const fixture = (n) => { + const batch = new Array(n); + for (let i = 0; i < n; i++) batch[i] = `id-${i}`; + // Completion order is not send order: reverse is the worst realistic case. + const collected = new Array(n); + for (let i = 0; i < n; i++) collected[i] = { id: `id-${n - 1 - i}`, type: 'callback', result: i }; + return { batch, collected }; +}; + +const measure = (fn, batch, collected) => { + for (let i = 0; i < 2000; i++) fn(batch, collected); + let iterations = 0; + const start = process.hrtime.bigint(); + const deadline = start + BigInt(MEASURE_MS) * 1_000_000n; + do { + for (let i = 0; i < 200; i++) fn(batch, collected); + iterations += 200; + } while (process.hrtime.bigint() < deadline); + const ns = Number(process.hrtime.bigint() - start); + return iterations / (ns / 1e9); +}; + +console.log(`Batch ordering benchmark — Node ${process.version}\n`); +console.log( + ` ${'batch size'.padEnd(12)}${'linear scan'.padStart(16)}${'id-indexed'.padStart(16)}${'winner'.padStart(14)}`, +); + +for (const n of SIZES) { + const { batch, collected } = fixture(n); + const a = JSON.stringify(scan(batch, collected)); + const b = JSON.stringify(indexed(batch, collected)); + if (a !== b) throw new Error(`ordering mismatch at n=${n}`); + const scanRate = measure(scan, batch, collected); + const indexRate = measure(indexed, batch, collected); + const winner = + indexRate > scanRate + ? `indexed ${(indexRate / scanRate).toFixed(2)}x` + : `scan ${(scanRate / indexRate).toFixed(2)}x`; + console.log( + ` ${String(n).padEnd(12)}${Math.round(scanRate).toLocaleString('en-US').padStart(13)}/s` + + `${Math.round(indexRate).toLocaleString('en-US').padStart(13)}/s${winner.padStart(14)}`, + ); +} diff --git a/bench/bench.js b/bench/bench.js index ffdc443..83727e3 100644 --- a/bench/bench.js +++ b/bench/bench.js @@ -23,7 +23,7 @@ async function main() { echo: { handler: async (args) => args }, notify: { handler: async (_args, context) => { - context.client.emit('bench/ping', { ping: true }); + context.client.sendEvent('bench/ping', { ping: true }); return { ok: true }; }, }, diff --git a/bench/cluster.js b/bench/cluster.js new file mode 100644 index 0000000..7e9323f --- /dev/null +++ b/bench/cluster.js @@ -0,0 +1,135 @@ +'use strict'; + +// The cluster layer's costs, each against the guarantee it must keep: +// - presence reads (count/presence) are local sums — nanoseconds, no I/O; +// - presence deltas ride every join/leave — the overhead a clustered +// registry adds to the hot membership path; +// - plain emit() must NOT have paid for ask()'s existence: same +// encode-once fan-out as before; +// - broadcast ask() pays one stringify plus a per-recipient id suffix. +// Two RpcServers share a MemoryBackplane, so the numbers include the real +// envelope serialization, just not a broker's network. + +const { EventEmitter } = require('node:events'); + +const { Connection } = require('../src/websocket/ws.js'); +const { RpcServer } = require('../src/rpc/core.js'); +const { defineRouter, procedure } = require('../src/rpc/router.js'); +const { MemoryBackplane } = require('../src/scaling/index.js'); + +class SinkSocket extends EventEmitter { + writableLength = 0; + destroyed = false; + bytes = 0; + + write(data) { + this.bytes += data.length; + return true; + } + + cork() {} + uncork() {} + pause() {} + resume() {} + end() {} + destroy() { + this.destroyed = true; + } +} + +const report = (name, count, elapsedMs) => { + const perSec = Math.round((count / elapsedMs) * 1000); + console.log(` ${name.padEnd(52)}${perSec.toLocaleString('en-US').padStart(14)}/sec`); +}; + +const router = () => defineRouter({ noop: { ping: procedure({ access: 'public', handler: async () => null }) } }); + +const attachMany = (rpc, count, room) => { + const clients = []; + for (let i = 0; i < count; i++) { + const conn = new Connection(new SinkSocket(), Buffer.alloc(0), { maxBackpressure: 0 }); + const client = rpc.attachSocket(conn, { remoteAddress: `10.0.0.${i}` }); + if (room) client.join(room); + clients.push(client); + } + return clients; +}; + +const settle = async (turns = 8) => { + for (let i = 0; i < turns; i++) await Promise.resolve(); +}; + +const main = async () => { + console.log(`Cluster benchmark — Node ${process.version}\n`); + console.log(` ${'scenario'.padEnd(52)}${'rate'.padStart(18)}`); + + const backplane = new MemoryBackplane(); + const a = new RpcServer({ router: router(), logger: false, backplane, instanceId: 'a' }); + const b = new RpcServer({ router: router(), logger: false, backplane, instanceId: 'b' }); + attachMany(a, 100, 'load'); + attachMany(b, 100, 'load'); + await settle(); + + // Presence reads: the numbers application code calls per render/handler. + { + const iterations = 2_000_000; + let sink = 0; + let started = performance.now(); + for (let i = 0; i < iterations; i++) sink += a.cluster.count('load'); + report('cluster.count() — 2 instances', iterations, performance.now() - started); + started = performance.now(); + for (let i = 0; i < iterations / 10; i++) sink += a.cluster.presence('load').total; + report('cluster.presence() — 2 instances', iterations / 10, performance.now() - started); + if (sink < 0) throw new Error('unreachable'); + } + + // The membership hot path with deltas attached to it. + { + const [client] = attachMany(a, 1, null); + const iterations = 200_000; + const started = performance.now(); + for (let i = 0; i < iterations; i++) { + client.join('churn'); + client.leave('churn'); + } + report('join+leave with presence deltas', iterations, performance.now() - started); + } + + // Plain emit() after ask() landed: the encode-once regression number, + // directly comparable to send-path.js's fan-out rows. + { + const data = { text: 'x'.repeat(512), n: 42 }; + const iterations = 5_000; + const started = performance.now(); + for (let i = 0; i < iterations; i++) a.to('load').emit('chat/message', data); + report('room fan-out x100 emit (512 B)', iterations, performance.now() - started); + } + + // The ask fan-out itself: one stringify, per-recipient id suffix, one + // pending-answer slot each. Answers never arrive (sink sockets), so the + // measured cost is the SEND side; the tiny timeout keeps slots bounded. + { + const data = { text: 'x'.repeat(512), n: 42 }; + const iterations = 2_000; + const started = performance.now(); + for (let i = 0; i < iterations; i++) { + a.to('load') + .local() + .ask('chat/poll', data, { timeout: 1 }) + .catch(() => {}); + } + report('room fan-out x100 ask send-side (512 B)', iterations, performance.now() - started); + // Let the 1 ms timeouts drain before closing. + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + await a.close(); + await b.close(); + backplane.close(); + console.log(); +}; + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/bench/cors-headers.js b/bench/cors-headers.js new file mode 100644 index 0000000..1f8f7e6 --- /dev/null +++ b/bench/cors-headers.js @@ -0,0 +1,70 @@ +'use strict'; + +// The Allow-Headers memo in src/transport.js (allowedHeaders): the string +// derives only from the immutable `cors` option, yet buildHeaders runs on +// every HTTP call — so before the WeakMap memo, six camelCase metaHeaders +// paid two global-regex toKebab passes per key per request. This file is +// the measurement that comment cites. +// +// The three shapes measured: no cors at all (the constant), a cors object +// with six camelCase metaHeaders rebuilt per call (the pre-memo cost, +// reproduced inline), and the same object through the memoized +// allowedHeaders via buildHeaders. + +const { bench } = require('./support/harness.js'); +const { buildHeaders } = require('../src/transport.js'); +const { toKebab } = require('../src/utils.js'); + +const CORS = { + origins: ['https://app.example'], + credentials: true, + metaHeaders: ['userId', 'tenantId', 'idempotencyKey', 'xAppVersion', 'requestId', 'deviceId'], +}; + +const DEFAULT_HEADERS = 'Content-Type, x-wrpc-channel, last-event-id, x-wrpc-meta'; + +// The pre-memo shape: what allowedHeaders did per request before the cache. +const rebuildEveryCall = (cors) => { + const declared = cors.headers ?? DEFAULT_HEADERS; + let allow = declared; + for (let i = 0; i < cors.metaHeaders.length; i++) allow += `, x-wrpc-meta-${toKebab(cors.metaHeaders[i])}`; + return allow; +}; + +// The harness awaits per call, which would drown a nanosecond op in promise +// overhead — each iteration runs a sync batch. +const BATCH = 1000; + +const run = async () => { + await bench( + 'buildHeaders, no cors option', + async () => { + let out; + for (let i = 0; i < BATCH; i++) out = buildHeaders(undefined, 'https://app.example'); + return out; + }, + { opsPerIteration: BATCH }, + ); + + await bench( + 'Allow-Headers rebuilt per call (pre-memo shape)', + async () => { + let out; + for (let i = 0; i < BATCH; i++) out = rebuildEveryCall(CORS); + return out; + }, + { opsPerIteration: BATCH }, + ); + + await bench( + 'buildHeaders, six camelCase metaHeaders (memoized)', + async () => { + let out; + for (let i = 0; i < BATCH; i++) out = buildHeaders(CORS, 'https://app.example'); + return out; + }, + { opsPerIteration: BATCH }, + ); +}; + +run(); diff --git a/bench/dispatch.js b/bench/dispatch.js new file mode 100644 index 0000000..e6318c8 --- /dev/null +++ b/bench/dispatch.js @@ -0,0 +1,257 @@ +/** + * Branch dispatch: `switch` vs `if`/`else if` vs a table of handler functions. + * + * This bench exists to be cited, not to be won. The intuition that a lookup + * table is the O(1) answer to a long `if`/`else if` chain is half right: the + * chain really is linear in branch position, but in V8 the construct that + * fixes it is `switch`, not a table. A `switch` over string literals compiles + * to direct comparisons and the call target stays inlinable; a table yields a + * function VALUE, which V8 cannot inline through, and a peer-keyed one needs a + * null prototype on top — so it loses on both counts. + * + * The shapes below are the real ones: the six cluster envelope types from + * src/rpc/cluster.js #receive, and the nine packet types from + * src/rpc/dispatcher.js handlePacket, measured at the first branch, the last + * branch, and a uniform mix so the linearity is visible rather than asserted. + * + * Run with `node bench/dispatch.js` or `pnpm bench`. + */ + +'use strict'; + +const MEASURE_MS = 400; + +let sink = 0; +const handler = (n) => (value) => { + sink += value + n; +}; + +const measure = (name, fn, pick) => { + for (let i = 0; i < 200_000; i++) fn(pick(i), i); + let iterations = 0; + const start = process.hrtime.bigint(); + const deadline = start + BigInt(MEASURE_MS) * 1_000_000n; + do { + for (let i = 0; i < 10_000; i++) fn(pick(i), i); + iterations += 10_000; + } while (process.hrtime.bigint() < deadline); + const ns = Number(process.hrtime.bigint() - start); + const perSec = (iterations / (ns / 1e9) / 1e6).toFixed(1); + console.log(` ${name.padEnd(44)}${perSec.padStart(8)} M ops/sec`); +}; + +// --- six cluster envelope types, src/rpc/cluster.js #receive --------------- + +const CLUSTER = ['hello', 'state', 'delta', 'e', 'q', 'cmd']; +const C = CLUSTER.map((_, i) => handler(i)); + +const clusterSwitch = (t, v) => { + switch (t) { + case 'hello': + return C[0](v); + case 'state': + return C[1](v); + case 'delta': + return C[2](v); + case 'e': + return C[3](v); + case 'q': + return C[4](v); + case 'cmd': + return C[5](v); + default: + return undefined; + } +}; + +const CLUSTER_TABLE = { __proto__: null }; +for (let i = 0; i < CLUSTER.length; i++) CLUSTER_TABLE[CLUSTER[i]] = C[i]; +const clusterTable = (t, v) => { + const fn = CLUSTER_TABLE[t]; + if (typeof fn === 'function') return fn(v); + return undefined; +}; + +const CLUSTER_FROZEN = Object.freeze(Object.fromEntries(CLUSTER.map((name, i) => [name, C[i]]))); +const clusterFrozen = (t, v) => { + const fn = CLUSTER_FROZEN[t]; + if (typeof fn === 'function') return fn(v); + return undefined; +}; + +const CLUSTER_MAP = new Map(CLUSTER.map((name, i) => [name, C[i]])); +const clusterMap = (t, v) => { + const fn = CLUSTER_MAP.get(t); + if (fn !== undefined) return fn(v); + return undefined; +}; + +// --- nine packet types, src/rpc/dispatcher.js handlePacket ---------------- + +const PACKETS = ['call', 'subscribe', 'unsubscribe', 'cancel', 'stream', 'event', 'callback', 'ping', 'pong']; +const P = PACKETS.map((_, i) => handler(i)); + +const packetChain = (t, v) => { + if (t === 'call') return P[0](v); + else if (t === 'subscribe') return P[1](v); + else if (t === 'unsubscribe') return P[2](v); + else if (t === 'cancel') return P[3](v); + else if (t === 'stream') return P[4](v); + else if (t === 'event') return P[5](v); + else if (t === 'callback') return P[6](v); + else if (t === 'ping') return P[7](v); + else if (t === 'pong') return P[8](v); + return undefined; +}; + +const packetSwitch = (t, v) => { + switch (t) { + case 'call': + return P[0](v); + case 'subscribe': + return P[1](v); + case 'unsubscribe': + return P[2](v); + case 'cancel': + return P[3](v); + case 'stream': + return P[4](v); + case 'event': + return P[5](v); + case 'callback': + return P[6](v); + case 'ping': + return P[7](v); + case 'pong': + return P[8](v); + default: + return undefined; + } +}; + +const PACKET_TABLE = { __proto__: null }; +for (let i = 0; i < PACKETS.length; i++) PACKET_TABLE[PACKETS[i]] = P[i]; +const packetTable = (t, v) => { + const fn = PACKET_TABLE[t]; + if (typeof fn === 'function') return fn(v); + return undefined; +}; + +const first = () => PACKETS[0]; +const last = () => PACKETS[PACKETS.length - 1]; +const mixed = (i) => PACKETS[i % PACKETS.length]; +const clusterMix = (i) => CLUSTER[i % CLUSTER.length]; + +console.log(`Dispatch benchmark — Node ${process.version}\n`); + +console.log('6 cluster envelope types, uniform mix (src/rpc/cluster.js #receive)'); +measure('switch', clusterSwitch, clusterMix); +measure('object table (__proto__: null)', clusterTable, clusterMix); +measure('frozen object table', clusterFrozen, clusterMix); +measure('Map table', clusterMap, clusterMix); + +console.log('\n9 packet types, ALWAYS FIRST branch (chain best case)'); +measure('if/else if chain', packetChain, first); +measure('switch', packetSwitch, first); +measure('object table', packetTable, first); + +console.log('\n9 packet types, ALWAYS LAST branch (chain worst case)'); +measure('if/else if chain', packetChain, last); +measure('switch', packetSwitch, last); +measure('object table', packetTable, last); + +console.log('\n9 packet types, uniform mix (src/rpc/dispatcher.js handlePacket)'); +measure('if/else if chain', packetChain, mixed); +measure('switch', packetSwitch, mixed); +measure('object table', packetTable, mixed); + +// The rows above all pay for a real handler call, which is what production +// does — and that call is expensive enough to swamp the branch cost, which is +// why switch and the chain come out level there. Isolating the dispatch (no +// call, just a returned number) shows the effect that is otherwise hidden: the +// chain IS linear in branch position, the switch is flatter, and the table is +// flat but starts from a worse constant. Both facts matter, so both are here. + +const chainOnly = (t) => { + if (t === 'call') return 0; + else if (t === 'subscribe') return 1; + else if (t === 'unsubscribe') return 2; + else if (t === 'cancel') return 3; + else if (t === 'stream') return 4; + else if (t === 'event') return 5; + else if (t === 'callback') return 6; + else if (t === 'ping') return 7; + else if (t === 'pong') return 8; + return -1; +}; + +const switchOnly = (t) => { + switch (t) { + case 'call': + return 0; + case 'subscribe': + return 1; + case 'unsubscribe': + return 2; + case 'cancel': + return 3; + case 'stream': + return 4; + case 'event': + return 5; + case 'callback': + return 6; + case 'ping': + return 7; + case 'pong': + return 8; + default: + return -1; + } +}; + +const INDEX_TABLE = { __proto__: null }; +for (let i = 0; i < PACKETS.length; i++) INDEX_TABLE[PACKETS[i]] = i; +const tableOnly = (t) => { + const value = INDEX_TABLE[t]; + return value === undefined ? -1 : value; +}; + +// `pick` reads through an array so the discriminant is not a foldable literal — +// with a constant, V8 elides the whole chain and reports a fantasy number. +const measureOnly = (name, fn, pick) => { + for (let i = 0; i < 2_000_000; i++) sink += fn(pick(i)); + const iterations = 20_000_000; + const start = process.hrtime.bigint(); + for (let i = 0; i < iterations; i++) sink += fn(pick(i)); + const ns = Number(process.hrtime.bigint() - start); + console.log(` ${name.padEnd(44)}${(iterations / (ns / 1e9) / 1e6).toFixed(0).padStart(8)} M ops/sec`); +}; + +const firstOnly = (i) => PACKETS[i & 0]; +const lastOnly = (i) => PACKETS[8 - (i & 0)]; + +console.log('\ndispatch only, no handler call — FIRST branch'); +measureOnly('if/else if chain', chainOnly, firstOnly); +measureOnly('switch', switchOnly, firstOnly); +measureOnly('object table', tableOnly, firstOnly); + +console.log('\ndispatch only, no handler call — LAST branch'); +measureOnly('if/else if chain', chainOnly, lastOnly); +measureOnly('switch', switchOnly, lastOnly); +measureOnly('object table', tableOnly, lastOnly); + +console.log('\ndispatch only, no handler call — uniform mix'); +measureOnly('if/else if chain', chainOnly, mixed); +measureOnly('switch', switchOnly, mixed); +measureOnly('object table', tableOnly, mixed); + +// A table keyed on a peer-controlled string answers inherited names unless the +// prototype is severed — the reason PACKET_TABLE above is `__proto__: null`. +const NAIVE = { call: P[0] }; +console.log( + `\nnaive table: typeof TABLE['toString'] === '${typeof NAIVE.toString}' ` + + `| null-proto table: '${typeof PACKET_TABLE.toString}'`, +); + +if (sink === Number.MIN_SAFE_INTEGER) console.log('unreachable', sink); diff --git a/bench/emitter.js b/bench/emitter.js new file mode 100644 index 0000000..25496d9 --- /dev/null +++ b/bench/emitter.js @@ -0,0 +1,102 @@ +/** + * `Emitter.emit` — the hottest shared primitive in the project. It runs once + * per inbound client message (src/client/transports.js), once per SSE event + * (src/sse/client.js), and once per stream chunk (src/streams.js), and every + * one of those sites has exactly ONE synchronous listener. + * + * The number that matters is the single-sync-listener row: it is what pays for + * the hand-written fast path in src/utils.js, and it is what a future edit + * would be giving up by collapsing that branch back into the generic path. + * + * The awaited rows measure dispatch + settle. The fire-and-forget row measures + * what `void emitter.emit(...)` actually costs at a call site that never + * awaits — the allocation, not the microtask drain — so it is batched inside a + * single iteration with opsPerIteration, because the harness awaits fn(). + * + * Run with `node bench/emitter.js` or `pnpm bench`. + */ + +'use strict'; + +const { bench } = require('./support/harness.js'); +const { Emitter } = require('../src/utils.js'); + +const BATCH = 1000; + +let sink = 0; +const sync = (value) => { + sink += value; +}; + +const main = async () => { + console.log(`Emitter benchmark — Node ${process.version}\n`); + + { + const emitter = new Emitter(); + let i = 0; + await bench('emit awaited — 0 listeners', () => emitter.emit('m', i++)); + } + { + const emitter = new Emitter(); + emitter.on('m', sync); + let i = 0; + await bench('emit awaited — 1 sync listener', () => emitter.emit('m', i++)); + } + { + const emitter = new Emitter(); + emitter.on('m', async (value) => { + sink += value; + }); + let i = 0; + await bench('emit awaited — 1 async listener', () => emitter.emit('m', i++)); + } + { + const emitter = new Emitter(); + emitter.on('m', sync); + emitter.on('m', (value) => { + sink -= value; + }); + let i = 0; + await bench('emit awaited — 2 sync listeners', () => emitter.emit('m', i++)); + } + { + // A lone `once` takes the slow path on purpose: the sweep may delete the + // whole record, which the fast path is not allowed to do. + let i = 0; + await bench('emit awaited — lone once listener (re-armed)', () => { + const emitter = new Emitter(); + emitter.once('m', sync); + return emitter.emit('m', i++); + }); + } + { + const emitter = new Emitter(); + emitter.on('m', () => { + throw new Error('bench'); + }); + let i = 0; + await bench('emit awaited — throwing listener (rejection path)', () => + emitter.emit('m', i++).then( + () => {}, + () => {}, + )); + } + { + // The `void emitter.emit(...)` shape: nothing awaits, so this is the + // allocation cost alone. + const emitter = new Emitter(); + emitter.on('m', sync); + let i = 0; + await bench( + 'emit fire-and-forget — 1 sync listener', + () => { + for (let n = 0; n < BATCH; n++) void emitter.emit('m', i++); + }, + { opsPerIteration: BATCH, warmup: 20 }, + ); + } + + if (sink === Number.MIN_SAFE_INTEGER) console.log('unreachable', sink); +}; + +void main(); diff --git a/bench/meta.js b/bench/meta.js new file mode 100644 index 0000000..e79d450 --- /dev/null +++ b/bench/meta.js @@ -0,0 +1,86 @@ +'use strict'; + +// Two per-request meta costs and the measurements their comments cite: +// +// 1. sanitizeMeta (src/rpc/dispatcher.js) — the size cap used to run +// JSON.stringify on EVERY bag just to read .length; the upper-bound walk +// accepts the common small bag without serializing. +// 2. declaredData's query gate (src/rpc/core.js) — the wrpc_meta +// URLSearchParams parse is gated on a substring probe, because the +// common REST/curl request carries no wrpc_meta at all. + +const { bench } = require('./support/harness.js'); + +const bag = { idem: '9f3c-a114', tenant: 'acme', retries: 3 }; +const LIMIT = 2048; + +const stringifyMeasure = (value, limit) => JSON.stringify(value).length <= limit; + +const upperBound = (value) => { + let bound = 2; + for (const key in value) { + bound += key.length * 6 + 6; + const entry = value[key]; + const type = typeof entry; + if (type === 'string') bound += entry.length * 6 + 2; + else if (type === 'number' || type === 'boolean') bound += 24; + else if (entry === null) bound += 4; + else return -1; + } + return bound; +}; + +const boundMeasure = (value, limit) => { + const bound = upperBound(value); + return !((bound < 0 || bound > limit) && JSON.stringify(value).length > limit); +}; + +const QUERY = 'page=2&sort=name&filter=active&locale=uk&theme=dark'; + +const BATCH = 1000; + +const run = async () => { + await bench( + 'meta cap: JSON.stringify every bag (old)', + async () => { + let out; + for (let i = 0; i < BATCH; i++) out = stringifyMeasure(bag, LIMIT); + return out; + }, + { opsPerIteration: BATCH }, + ); + + await bench( + 'meta cap: upper-bound walk (new)', + async () => { + let out; + for (let i = 0; i < BATCH; i++) out = boundMeasure(bag, LIMIT); + return out; + }, + { opsPerIteration: BATCH }, + ); + + await bench( + 'wrpc_meta lookup: URLSearchParams on every query (old)', + async () => { + let out; + for (let i = 0; i < BATCH; i++) out = new URLSearchParams(QUERY).get('wrpc_meta'); + return out; + }, + { opsPerIteration: BATCH }, + ); + + await bench( + 'wrpc_meta lookup: substring probe gate (new)', + async () => { + let out; + for (let i = 0; i < BATCH; i++) { + out = QUERY.includes('wrpc_meta') ? new URLSearchParams(QUERY).get('wrpc_meta') : null; + } + return out; + }, + { opsPerIteration: BATCH }, + ); +}; + +run(); diff --git a/bench/parser-throughput.js b/bench/parser-throughput.js new file mode 100644 index 0000000..c0af989 --- /dev/null +++ b/bench/parser-throughput.js @@ -0,0 +1,188 @@ +'use strict'; + +/** + * Receive-path throughput benchmark for the WebSocket engine. + * + * Feeds pre-built masked frames into a server-side Connection through a + * minimal fake socket — the same entry point real TCP data takes — and + * measures how fast the SegmentQueue + FrameParser pipeline turns wire + * bytes into 'message' events: + * + * (a) one 16 MiB masked BINARY message delivered in 16 KiB segments — + * exercises incremental header parsing and spanning-segment consume; + * (b) 50K masked 200 B TEXT frames, many frames per 64 KiB segment — + * exercises per-frame overhead, including UTF-8 validation. + * + * Received 'message' events are counted and spot-checked; any mismatch + * fails the run (process.exitCode = 1). + * + * Run: node bench/parser-throughput.js (or as part of `pnpm bench`) + */ + +const { EventEmitter } = require('node:events'); +const { performance } = require('node:perf_hooks'); +const { Connection, Frame } = require('../src/websocket/ws.js'); + +const KIB = 1024; +const MIB = 1024 * 1024; + +let failures = 0; + +const fail = (message) => { + failures++; + if (failures <= 10) console.error(`FAIL: ${message}`); +}; + +// Minimal socket stand-in: Connection only needs events plus these members. +class FakeSocket extends EventEmitter { + destroyed = false; + writableLength = 0; + cork() {} + uncork() {} + write() { + return true; + } + pause() {} + resume() {} + end() {} + destroy() { + this.destroyed = true; + } +} + +const createConnection = (onMessage) => { + const socket = new FakeSocket(); + const connection = new Connection(socket, null); + connection.on('message', onMessage); + connection.on('error', (error) => fail(`connection error: ${error.message}`)); + return socket; +}; + +const sliceSegments = (wire, segmentSize) => { + const segments = []; + for (let offset = 0; offset < wire.length; offset += segmentSize) { + segments.push(wire.subarray(offset, offset + segmentSize)); + } + return segments; +}; + +const benchLargeBinary = () => { + const SIZE = 16 * MIB; + const SEGMENT = 16 * KIB; + const WARMUP = 3; + const ITERATIONS = 20; + + const payload = Buffer.alloc(SIZE, 0x5a); + payload[0] = 0x01; + payload[SIZE >> 1] = 0x02; + payload[SIZE - 1] = 0x03; + + const frame = Frame.binary(Buffer.from(payload)); + frame.maskPayload(); + const wire = frame.toBuffer(); + // The 16 MiB payload always spans segments, so consume() copies before + // the in-place unmask — the shared wire buffer stays intact across runs. + const segments = sliceSegments(wire, SEGMENT); + + let received = 0; + const socket = createConnection((message, isBinary) => { + received++; + if (!isBinary) fail('large binary: expected a binary message'); + if (message.length !== SIZE) { + fail(`large binary: length ${message.length} !== ${SIZE}`); + } else if (message[0] !== 0x01 || message[SIZE >> 1] !== 0x02 || message[SIZE - 1] !== 0x03) { + fail('large binary: payload spot-check mismatch'); + } + }); + + let measured = 0; + for (let iter = 0; iter < WARMUP + ITERATIONS; iter++) { + const start = performance.now(); + for (const segment of segments) socket.emit('data', segment); + const duration = performance.now() - start; + if (iter >= WARMUP) measured += duration; + } + + const expected = WARMUP + ITERATIONS; + if (received !== expected) fail(`large binary: received ${received} messages, expected ${expected}`); + + const seconds = measured / 1000; + return { + scenario: `16 MiB binary message, ${SEGMENT / KIB} KiB segments`, + mbPerSec: (SIZE * ITERATIONS) / MIB / seconds, + msgsPerSec: ITERATIONS / seconds, + messages: received, + }; +}; + +const benchSmallText = () => { + const FRAME_COUNT = 50_000; + const TEXT = 'x'.repeat(200); + const SEGMENT = 64 * KIB; + const WARMUP = 2; + const ITERATIONS = 10; + + const buffers = new Array(FRAME_COUNT); + for (let i = 0; i < FRAME_COUNT; i++) { + const frame = Frame.text(TEXT); + frame.maskPayload(); + buffers[i] = frame.toBuffer(); + } + const wire = Buffer.concat(buffers); + + let received = 0; + let firstOfRun = null; + const socket = createConnection((message, isBinary) => { + received++; + if (isBinary) fail('small text: expected a text message'); + if (message.length !== TEXT.length) fail(`small text: length ${message.length} !== ${TEXT.length}`); + if (firstOfRun === null) firstOfRun = message.toString('utf8'); + }); + + let measured = 0; + for (let iter = 0; iter < WARMUP + ITERATIONS; iter++) { + // Small payloads are unmasked in place inside their segment, so every + // run gets a fresh copy of the wire bytes (built outside the timing). + const wireCopy = Buffer.from(wire); + const segments = sliceSegments(wireCopy, SEGMENT); + firstOfRun = null; + const start = performance.now(); + for (const segment of segments) socket.emit('data', segment); + const duration = performance.now() - start; + if (iter >= WARMUP) measured += duration; + if (firstOfRun !== TEXT) fail(`small text: run ${iter} first message corrupted`); + } + + const expected = (WARMUP + ITERATIONS) * FRAME_COUNT; + if (received !== expected) fail(`small text: received ${received} messages, expected ${expected}`); + + const seconds = measured / 1000; + return { + scenario: `200 B text frames x ${FRAME_COUNT / 1000}K, ${SEGMENT / KIB} KiB segments`, + mbPerSec: (TEXT.length * FRAME_COUNT * ITERATIONS) / MIB / seconds, + msgsPerSec: (FRAME_COUNT * ITERATIONS) / seconds, + messages: received, + }; +}; + +const formatNumber = (num) => num.toLocaleString('en-US', { maximumFractionDigits: 2 }); + +const main = () => { + console.log(`Parser throughput benchmark — Node ${process.version}\n`); + const results = [benchLargeBinary(), benchSmallText()]; + console.log(` ${'scenario'.padEnd(46)}${'MB/s'.padStart(12)}${'msgs/sec'.padStart(14)}`); + for (const { scenario, mbPerSec, msgsPerSec } of results) { + const row = `${scenario.padEnd(46)}${formatNumber(mbPerSec).padStart(12)}${formatNumber(msgsPerSec).padStart(14)}`; + console.log(` ${row}`); + } + console.log(); + if (failures > 0) { + console.error(`Integrity: FAILED (${failures} mismatches)`); + process.exitCode = 1; + } else { + const totalMessages = results.reduce((sum, result) => sum + result.messages, 0); + console.log(`Integrity: OK (${formatNumber(totalMessages)} messages verified)`); + } +}; + +main(); diff --git a/bench/replay-buffer.js b/bench/replay-buffer.js new file mode 100644 index 0000000..0244e27 --- /dev/null +++ b/bench/replay-buffer.js @@ -0,0 +1,60 @@ +'use strict'; + +// The ring buffers in EventLog (src/rpc/subscriptions.js) and SseChannel +// (src/sse/server.js): both replay buffers used to drop their oldest entry +// with Array#shift on a per-event path. This file is the measurement those +// comments cite: push+shift at steady state versus the preallocated +// slots+head+count ring, at the default capacity of 100 — where the shift +// cost is also NOT monotonic in size (V8's left-trimming threshold), so a +// config value silently changed the per-event cost. + +const { bench } = require('./support/harness.js'); + +const CAPACITY = 100; +const BATCH = 1000; + +const run = async () => { + { + // Steady state: the buffer is full, every push evicts. + const entries = []; + for (let i = 0; i < CAPACITY; i++) entries.push({ id: i, data: 'x' }); + let n = CAPACITY; + await bench( + 'push + shift (old shape)', + async () => { + for (let i = 0; i < BATCH; i++) { + entries.push({ id: n++, data: 'x' }); + if (entries.length > CAPACITY) entries.shift(); + } + return entries.length; + }, + { opsPerIteration: BATCH }, + ); + } + + { + const slots = new Array(CAPACITY); + let head = 0; + let count = 0; + let n = 0; + await bench( + 'ring: slots + head + count (new shape)', + async () => { + for (let i = 0; i < BATCH; i++) { + const entry = { id: n++, data: 'x' }; + if (count < CAPACITY) { + slots[(head + count) % CAPACITY] = entry; + count++; + } else { + slots[head] = entry; + head = (head + 1) % CAPACITY; + } + } + return count; + }, + { opsPerIteration: BATCH }, + ); + } +}; + +run(); diff --git a/bench/rpc-comparison.js b/bench/rpc-comparison.js index 7d6ac87..0b4755e 100644 --- a/bench/rpc-comparison.js +++ b/bench/rpc-comparison.js @@ -1,9 +1,17 @@ /** - * Compares wrpc's full RPC round-trip against an equivalent minimal - * "echo RPC" (same {id, method, args} envelope, correlated by id) built - * directly on top of ws, uWebSockets.js, @fastify/websocket, and - * fastify-uws — so the numbers isolate transport overhead rather than - * protocol-design differences. See bench/support/rpc-stacks.js. + * Compares wrpc's full RPC round-trip against two kinds of neighbour: + * + * - RAW TRANSPORTS (ws, uWebSockets.js, @fastify/websocket, fastify-uws) + * driving an equivalent minimal "echo RPC" — the same {id, method, args} + * envelope correlated by id, so those numbers isolate transport overhead + * rather than protocol-design differences. They are a floor, not a rival: + * none of them implements sessions, rooms, streams or subscriptions. + * - RPC FRAMEWORKS (socket.io, tRPC over wsLink) doing the job wrpc does, + * envelope and bookkeeping included. + * + * See bench/support/rpc-stacks.js. Each measurement runs twice: one call at + * a time (latency) and 64 in flight (throughput) — a stack with a fixed + * per-call delay reads very differently under the two. * * Each stack runs in its own child process (spawned here) because mixing * raw uWebSockets.js with fastify-uws's bundled copy of it in one process @@ -17,7 +25,10 @@ const path = require('node:path'); const { spawnSync } = require('node:child_process'); -const STACK_ORDER = ['wrpc', 'ws', 'uws', 'fastify-websocket', 'fastify-uws']; +const STACK_ORDER = ['wrpc', 'ws', 'uws', 'fastify-websocket', 'fastify-uws', 'socket.io', 'trpc-ws']; +// Matched by suffix against the names rpc-stack-worker.js prints. The +// pipelined row keeps its own suffix (`… ×64`), so the match stays unambiguous. +const MEASUREMENTS = ['small payload', '10KB payload', 'small payload ×64']; const WORKER = path.join(__dirname, 'support', 'rpc-stack-worker.js'); function runStack(key) { @@ -42,14 +53,14 @@ function main() { for (const key of STACK_ORDER) byStack.set(key, runStack(key)); console.log('\nSummary (ops/sec, relative to wrpc):\n'); - for (const size of ['small payload', '10KB payload']) { + for (const size of MEASUREMENTS) { const baseline = byStack.get('wrpc').find((r) => r.name.endsWith(size)).opsPerSec; console.log(` ${size}:`); for (const key of STACK_ORDER) { const result = byStack.get(key).find((r) => r.name.endsWith(size)); const ratio = (result.opsPerSec / baseline).toFixed(2); const label = result.name.slice(0, result.name.indexOf(' — ')); - console.log(` ${label.padEnd(38)} ${String(result.opsPerSec).padStart(10)} ops/sec (${ratio}x)`); + console.log(` ${label.padEnd(44)} ${String(result.opsPerSec).padStart(10)} ops/sec (${ratio}x)`); } console.log(); } diff --git a/bench/send-path.js b/bench/send-path.js new file mode 100644 index 0000000..2b5aeb9 --- /dev/null +++ b/bench/send-path.js @@ -0,0 +1,127 @@ +'use strict'; + +// The send-side benchmarks the receive-side suite never had: frame writing, +// permessage-deflate, and — the one that matters for scale-out — room +// fan-out, where the encode-once work is graded. Everything runs in-process +// over sink sockets: what is measured is wrpc's own serialization and +// framing cost, not the kernel's. + +const { EventEmitter } = require('node:events'); + +const { Connection } = require('../src/websocket/ws.js'); +const { compress, decompress } = require('../src/websocket/permessageDeflate.js'); +const { RpcServer } = require('../src/rpc/core.js'); +const { defineRouter, procedure } = require('../src/rpc/router.js'); + +// A socket that swallows writes and counts bytes: the null sink every +// send-side measurement drains into. +class SinkSocket extends EventEmitter { + writableLength = 0; + destroyed = false; + bytes = 0; + writes = 0; + + write(data) { + this.bytes += data.length; + this.writes++; + return true; + } + + cork() {} + uncork() {} + pause() {} + resume() {} + end() {} + destroy() { + this.destroyed = true; + } +} + +const mb = (bytes) => bytes / (1024 * 1024); + +const report = (name, count, elapsedMs, bytes) => { + const perSec = Math.round((count / elapsedMs) * 1000); + const throughput = bytes === undefined ? '' : `${((mb(bytes) / elapsedMs) * 1000).toFixed(0).padStart(8)} MB/s`; + console.log(` ${name.padEnd(46)}${perSec.toLocaleString('en-US').padStart(14)}/sec${throughput}`); +}; + +const benchSendText = (label, payload, options = {}) => { + const socket = new SinkSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { maxBackpressure: 0, ...options }); + const iterations = 200_000; + const started = performance.now(); + for (let i = 0; i < iterations; i++) conn.sendText(payload); + const elapsed = performance.now() - started; + conn.terminate(); + report(label, iterations, elapsed, socket.bytes); +}; + +const benchDeflate = () => { + const json = JSON.stringify({ type: 'event', name: 'chat/message', data: { text: 'x'.repeat(2048) } }); + const payload = Buffer.from(json); + const iterations = 20_000; + + let started = performance.now(); + for (let i = 0; i < iterations; i++) compress(payload); + report( + 'permessage-deflate compress (2 KB json)', + iterations, + performance.now() - started, + payload.length * iterations, + ); + + const compressed = compress(payload); + started = performance.now(); + for (let i = 0; i < iterations; i++) decompress(compressed, 1024 * 1024); + report( + 'permessage-deflate decompress (2 KB json)', + iterations, + performance.now() - started, + payload.length * iterations, + ); +}; + +// Room fan-out over sink sockets. THE baseline for encode-once: today every +// recipient pays its own JSON.stringify + utf8 encode (+ deflate when on), +// so the per-recipient cost is what this number exposes. +const benchFanout = async (clients, deflate) => { + const router = defineRouter({ noop: { ping: procedure({ access: 'public', handler: async () => null }) } }); + const rpc = new RpcServer({ router, logger: false }); + const sockets = []; + for (let i = 0; i < clients; i++) { + const socket = new SinkSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { + maxBackpressure: 0, + ...(deflate ? { deflate: { threshold: 1, windowBits: 15 } } : {}), + }); + const client = rpc.attachSocket(conn, { remoteAddress: `10.0.0.${i}` }); + client.join('load'); + sockets.push(socket); + } + const data = { text: 'x'.repeat(512), n: 42 }; + const iterations = deflate ? 500 : 5_000; + const started = performance.now(); + for (let i = 0; i < iterations; i++) rpc.to('load').emit('chat/message', data); + const elapsed = performance.now() - started; + const bytes = sockets.reduce((sum, socket) => sum + socket.bytes, 0); + report(`room fan-out x${clients}${deflate ? ' +deflate' : ''} (512 B)`, iterations, elapsed, bytes); + await rpc.close(); +}; + +const main = async () => { + console.log(`Send-path benchmark — Node ${process.version}\n`); + console.log(` ${'scenario'.padEnd(46)}${'rate'.padStart(18)}${'throughput'.padStart(12)}`); + benchSendText('sendText 200 B', 'x'.repeat(200)); + benchSendText('sendText 4 KB', 'x'.repeat(4096)); + benchSendText('sendText 200 B +deflate', 'x'.repeat(200), { deflate: { threshold: 1, windowBits: 15 } }); + benchDeflate(); + await benchFanout(50, false); + await benchFanout(200, false); + await benchFanout(50, true); + console.log(); +}; + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/bench/serialize-callback.js b/bench/serialize-callback.js new file mode 100644 index 0000000..b74d31d --- /dev/null +++ b/bench/serialize-callback.js @@ -0,0 +1,64 @@ +'use strict'; + +// The compiled-serializer fast path in handleRpc (src/rpc/dispatcher.js): +// when a procedure's schema.response compiled a serializer, the callback +// envelope is assembled by string concatenation around the compiled result +// text instead of JSON.stringify walking the whole packet. This file is the +// measurement that comment cites. +// +// The "compiled serializer" here is a hand-written shape-specific function — +// exactly what fast-json-stringify generates for a known schema — so the +// numbers measure the SEAM (envelope surgery vs whole-packet stringify), +// not any particular serializer library. + +const { bench } = require('./support/harness.js'); + +const result = { + id: 'p_812', + orgId: 'org_42', + name: 'A project with a reasonably long name', + archived: false, + members: 12, + tags: ['alpha', 'beta', 'gamma'], +}; +const id = 'b1f0c9d2-4c1a-4a56-9d7e-2f3a4b5c6d7e'; + +// What fjs emits for the response schema: direct property access, no walk. +const compiledSerialize = (value) => + `{"id":${JSON.stringify(value.id)},"orgId":${JSON.stringify(value.orgId)},"name":${JSON.stringify(value.name)},` + + `"archived":${value.archived},"members":${value.members},"tags":${JSON.stringify(value.tags)}}`; + +// The harness awaits per call, which would drown a sub-microsecond op in +// promise overhead — so each iteration runs a sync batch. +const BATCH = 1000; + +const run = async () => { + await bench( + 'JSON.stringify(whole packet)', + async () => { + let out; + for (let i = 0; i < BATCH; i++) out = JSON.stringify({ type: 'callback', id, result }); + return out; + }, + { opsPerIteration: BATCH }, + ); + + await bench( + 'envelope surgery + compiled result', + async () => { + let out; + for (let i = 0; i < BATCH; i++) { + out = `{"type":"callback","id":${JSON.stringify(id)},"result":${compiledSerialize(result)}}`; + } + return out; + }, + { opsPerIteration: BATCH }, + ); +}; + +// Measured on Node 24 (2026-08): ~1.16x for the seam itself with this +// hand-written serializer; a real fast-json-stringify compilation widens +// the gap further on string-heavy payloads (its escape-scan beats +// JSON.stringify's). The seam is the point: without it a compiled +// serializer could only feed a SECOND stringify of the envelope. +run(); diff --git a/bench/support/harness.js b/bench/support/harness.js index e9c219d..f211764 100644 --- a/bench/support/harness.js +++ b/bench/support/harness.js @@ -4,12 +4,38 @@ const { performance } = require('node:perf_hooks'); const WARMUP_ITERATIONS = 200; const MEASURE_MS = 1_000; +const FIRST_CALL_MS = 10_000; + +// A bench whose fn awaits something that never settles — a renamed method, an +// event that stopped going on the wire — wedges the whole run with no output +// at all, which is far harder to diagnose than a thrown error. The first +// warmup call is the canary: racing ONLY that one keeps the guard out of the +// measured loop, where a per-iteration timer would allocate and skew the very +// numbers this file exists to report. +async function firstCall(name, fn) { + let timer; + const stalled = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`bench "${name}" did not settle within ${FIRST_CALL_MS} ms`)), + FIRST_CALL_MS, + ); + }); + try { + await Promise.race([fn(), stalled]); + } finally { + clearTimeout(timer); + } +} async function bench(name, fn, options = {}) { const warmup = options.warmup ?? WARMUP_ITERATIONS; const measureMs = options.measureMs ?? MEASURE_MS; + // A pipelined fn issues several calls per iteration, so ops and iterations + // stop being the same number. + const opsPerIteration = options.opsPerIteration ?? 1; - for (let i = 0; i < warmup; i++) await fn(); + if (warmup > 0) await firstCall(name, fn); + for (let i = 1; i < warmup; i++) await fn(); let iterations = 0; const start = performance.now(); @@ -18,8 +44,8 @@ async function bench(name, fn, options = {}) { iterations += 1; } const elapsed = performance.now() - start; - const opsPerSec = Math.round((iterations / elapsed) * 1000); - console.log(`${name.padEnd(52)} ${opsPerSec.toLocaleString('en-US').padStart(12)} ops/sec`); + const opsPerSec = Math.round(((iterations * opsPerIteration) / elapsed) * 1000); + console.log(`${name.padEnd(60)} ${opsPerSec.toLocaleString('en-US').padStart(12)} ops/sec`); return { name, opsPerSec }; } diff --git a/bench/support/rpc-stack-worker.js b/bench/support/rpc-stack-worker.js index 1b36acb..01cb050 100644 --- a/bench/support/rpc-stack-worker.js +++ b/bench/support/rpc-stack-worker.js @@ -13,6 +13,14 @@ const stacks = require('./rpc-stacks.js'); const smallPayload = { name: 'Ada' }; const largePayload = { text: 'x'.repeat(10_000) }; +// One call at a time measures LATENCY: a stack with a fixed per-call delay +// looks catastrophic here however cheap its per-call work is. PIPELINE calls +// in flight at once measures THROUGHPUT, where that delay overlaps away. Both +// are reported because a stack can be fast at one and slow at the other. +const PIPELINE = 64; + +const pipelined = (call, payload) => () => Promise.all(Array.from({ length: PIPELINE }, () => call(payload))); + async function main() { const key = process.argv[2]; const stack = stacks[key]; @@ -26,6 +34,12 @@ async function main() { const results = []; results.push(await bench(`${stack.label} — small payload`, () => handle.call(smallPayload))); results.push(await bench(`${stack.label} — 10KB payload`, () => handle.call(largePayload))); + results.push( + await bench(`${stack.label} — small payload ×${PIPELINE}`, pipelined(handle.call, smallPayload), { + warmup: 4, + opsPerIteration: PIPELINE, + }), + ); await handle.stop(); console.log(`RESULT_JSON:${JSON.stringify(results)}`); diff --git a/bench/support/rpc-stacks.js b/bench/support/rpc-stacks.js index 3840da7..0b249ca 100644 --- a/bench/support/rpc-stacks.js +++ b/bench/support/rpc-stacks.js @@ -157,10 +157,76 @@ async function startFastifyUws() { }; } +// Socket.io: a real RPC framework rather than a raw transport, so it pays for +// its own envelope, its acknowledgement bookkeeping and its engine.io layer — +// the same kinds of cost wrpc pays. `emitWithAck` is the request/response +// shape closest to a call. +async function startSocketIo() { + const { Server } = require('socket.io'); + const { io } = require('socket.io-client'); + const httpServer = http.createServer(); + const server = new Server(httpServer, { serveClient: false }); + server.on('connection', (socket) => { + socket.on('echo', (args, ack) => ack(args)); + }); + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)); + const { port } = httpServer.address(); + // websocket only: the default starts on HTTP long-polling and upgrades, + // which would measure the upgrade rather than the steady state. + const client = io(`http://127.0.0.1:${port}`, { transports: ['websocket'] }); + await new Promise((resolve, reject) => { + client.once('connect', resolve); + client.once('connect_error', reject); + }); + return { + call: (args) => client.emitWithAck('echo', args), + stop: async () => { + client.close(); + await server.close(); + await new Promise((resolve) => httpServer.close(resolve)); + }, + }; +} + +// tRPC over WebSocket (httpSubscriptionLink's sibling, wsLink) — the closest +// comparison to wrpc's own positioning. The function parser keeps the input +// validator free, so the number is transport plus tRPC's own envelope rather +// than someone's schema library. +async function startTrpcWs() { + const { initTRPC } = require('@trpc/server'); + const { applyWSSHandler } = require('@trpc/server/adapters/ws'); + const { createTRPCClient, createWSClient, wsLink } = require('@trpc/client'); + + const t = initTRPC.create(); + const appRouter = t.router({ + echo: t.procedure.input((value) => value).query(({ input }) => input), + }); + + const httpServer = http.createServer(); + const wss = new WebSocket.Server({ server: httpServer }); + const handler = applyWSSHandler({ wss, router: appRouter }); + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)); + const { port } = httpServer.address(); + + const wsClient = createWSClient({ url: `ws://127.0.0.1:${port}` }); + const client = createTRPCClient({ links: [wsLink({ client: wsClient })] }); + return { + call: (args) => client.echo.query(args), + stop: async () => { + wsClient.close(); + handler.broadcastReconnectNotification(); + wss.close(); + await new Promise((resolve) => httpServer.close(resolve)); + }, + }; +} + module.exports = { wrpc: { label: 'wrpc (own WS + RPC dispatch)', start: startWrpc }, ws: { label: 'ws (raw echo RPC)', start: startWs }, uws: { label: 'uWebSockets.js (raw echo RPC)', start: startUws }, 'fastify-websocket': { label: '@fastify/websocket (raw echo RPC)', start: startFastifyWebsocket }, 'fastify-uws': { label: 'fastify-uws (raw echo RPC)', start: startFastifyUws }, + 'socket.io': { label: 'socket.io (framework RPC via emitWithAck)', start: startSocketIo }, + 'trpc-ws': { label: 'tRPC (framework RPC over wsLink)', start: startTrpcWs }, }; diff --git a/bench/support/wrpc-echo.js b/bench/support/wrpc-echo.js index d3e68c5..fa10c55 100644 --- a/bench/support/wrpc-echo.js +++ b/bench/support/wrpc-echo.js @@ -1,64 +1,44 @@ 'use strict'; -const { randomUUID } = require('node:crypto'); const { Server } = require('../../src/server.js'); - -const noop = () => {}; - -class ProcedureMock { - constructor({ access = 'public', handler }) { - this.access = access; - this.handler = handler; - } - - // eslint-disable-next-line class-methods-use-this - async enter() {} - // eslint-disable-next-line class-methods-use-this - leave() {} - invoke(context, args) { - return this.handler(args, context); - } -} - -const createApplication = (api) => { - const introspect = (units = []) => { - const result = {}; - for (const unit of units) { - if (!api[unit]) continue; - const methods = {}; - for (const name of Object.keys(api[unit])) methods[name] = {}; - result[unit] = methods; +const { defineRouter, procedure } = require('../../src/rpc/router.js'); + +// `api` is the bench-local shorthand every stack under bench/ shares: +// { unit: { method: { access?, handler(args, context) } } }. Adapt it onto +// the real router — procedure() handlers take (context, args), the default +// access here is 'public' since these benchmarks measure an unauthenticated +// echo, and system/introspect no longer needs a hand-rolled stand-in: +// RpcServer registers it itself. +const toRouter = (api) => { + const definition = {}; + for (const [unit, methods] of Object.entries(api)) { + const unitDef = {}; + for (const [name, def] of Object.entries(methods)) { + unitDef[name] = procedure({ + access: def.access ?? 'public', + handler: (context, args) => def.handler(args, context), + }); } - return result; - }; - - return { - console: { log: noop, info: noop, warn: noop, error: noop, debug: noop }, - static: { constructor: { name: 'Static' } }, - auth: { saveSession: async () => {} }, - getMethod: (unit, _ver, method) => { - if (unit === 'system' && method === 'introspect') { - return new ProcedureMock({ handler: async (units) => introspect(units) }); - } - const def = api[unit]?.[method]; - if (!def) return null; - return new ProcedureMock(def); - }, - }; + definition[unit] = unitDef; + } + return defineRouter(definition); }; const createWrpcServer = async (api) => { - const options = { + const server = new Server({ + router: toRouter(api), host: '127.0.0.1', port: 0, protocol: 'http', timeouts: { bind: 100 }, - queue: { concurrency: 100, size: 100, timeout: 5_000 }, - generateId: randomUUID, - }; - const server = new Server(createApplication(api), options); + // `logger: false` (the real option — `console` never existed) matters + // beyond hygiene: a benchmark that logs a line per call measures the + // terminal, and the un-silenced output used to overflow spawnSync's + // 1 MiB buffer and kill the whole comparison run. + logger: false, + }); await server.listen(); - const { port } = server.httpServer.address(); + const { port } = server.address(); return { server, port }; }; diff --git a/bench/unmask.js b/bench/unmask.js new file mode 100644 index 0000000..43405ac --- /dev/null +++ b/bench/unmask.js @@ -0,0 +1,131 @@ +'use strict'; + +/** + * XOR unmask strategy benchmark for the WebSocket receive path. + * + * Compares three ways to unmask a client frame payload in place: + * (a) naive byte-wise loop — inline copy of the old implementation; + * (b) applyMask — the production word-wise XOR from src/websocket/frame.js; + * (c) Uint32Array-view variant, included for comparison. + * + * Masking is an involution (applying it twice restores the input), so each + * iteration reuses the same buffer without copies. Iteration counts scale + * down with payload size so the whole script finishes quickly. + * + * Run: node bench/unmask.js (or as part of `pnpm bench`) + */ + +const { performance } = require('node:perf_hooks'); +const crypto = require('node:crypto'); +const { applyMask } = require('../src/websocket/frame.js'); + +const KIB = 1024; +const MIB = 1024 * 1024; + +const SIZES = [ + { label: '64 B', size: 64, iterations: 1_000_000 }, + { label: '1 KiB', size: KIB, iterations: 400_000 }, + { label: '64 KiB', size: 64 * KIB, iterations: 6_000 }, + { label: '1 MiB', size: MIB, iterations: 384 }, +]; + +// (a) Naive byte-wise loop — the pre-optimization implementation. +const naiveMask = (payload, mask) => { + for (let i = 0; i < payload.length; i++) { + payload[i] ^= mask[i & 3]; + } +}; + +// (c) Uint32Array view over the aligned middle, byte-wise head and tail. +// Word composition assumes a little-endian host, like the production code. +const uint32Mask = (payload, mask) => { + const length = payload.length; + let i = 0; + const misalign = payload.byteOffset & 3; + if (misalign !== 0) { + const prefix = Math.min(4 - misalign, length); + for (; i < prefix; i++) payload[i] ^= mask[i & 3]; + } + const words = (length - i) >> 2; + if (words > 0) { + const view = new Uint32Array(payload.buffer, payload.byteOffset + i, words); + const mask32 = + (mask[i & 3] | (mask[(i + 1) & 3] << 8) | (mask[(i + 2) & 3] << 16) | (mask[(i + 3) & 3] << 24)) >>> 0; + for (let w = 0; w < words; w++) view[w] ^= mask32; + i += words << 2; + } + for (; i < length; i++) payload[i] ^= mask[i & 3]; +}; + +const STRATEGIES = [ + { name: 'naive byte-wise', fn: naiveMask }, + { name: 'applyMask (prod)', fn: applyMask }, + { name: 'Uint32Array view', fn: uint32Mask }, +]; + +// Every strategy must produce byte-identical output across lengths (odd +// tails included) and all four byteOffset alignments before being timed. +const verifyStrategies = () => { + let ok = true; + const lengths = [0, 1, 2, 3, 4, 5, 7, 8, 63, 64, 65, 1027]; + for (const length of lengths) { + const mask = crypto.randomBytes(4); + const data = crypto.randomBytes(length); + const expected = Buffer.from(data); + naiveMask(expected, mask); + for (let offset = 0; offset < 4; offset++) { + for (const { name, fn } of STRATEGIES) { + const buffer = Buffer.from(new ArrayBuffer(length + offset), offset, length); + data.copy(buffer); + fn(buffer, mask); + if (!buffer.equals(expected)) { + ok = false; + console.error(`FAIL: ${name} mismatch at length ${length}, byteOffset ${offset}`); + } + } + } + } + return ok; +}; + +const bench = (fn, payload, mask, iterations) => { + const warmup = Math.max(1, iterations >> 3); + for (let i = 0; i < warmup; i++) fn(payload, mask); + const start = performance.now(); + for (let i = 0; i < iterations; i++) fn(payload, mask); + const seconds = (performance.now() - start) / 1000; + return { + opsPerSec: iterations / seconds, + mbPerSec: (payload.length * iterations) / MIB / seconds, + }; +}; + +const formatNumber = (num) => num.toLocaleString('en-US', { maximumFractionDigits: 2 }); + +const formatOps = (ops) => { + if (ops >= 1_000_000) return `${formatNumber(ops / 1_000_000)}M`; + if (ops >= 1000) return `${formatNumber(ops / 1000)}K`; + return formatNumber(ops); +}; + +const main = () => { + console.log(`XOR unmask benchmark — Node ${process.version}`); + if (!verifyStrategies()) { + process.exitCode = 1; + return; + } + console.log(); + for (const { label, size, iterations } of SIZES) { + const payload = crypto.randomFillSync(Buffer.allocUnsafe(size)); + const mask = crypto.randomBytes(4); + console.log(`Payload ${label} — ${formatNumber(iterations)} iterations`); + console.log(` ${'strategy'.padEnd(18)}${'ops/sec'.padStart(12)}${'MB/s'.padStart(14)}`); + for (const { name, fn } of STRATEGIES) { + const { opsPerSec, mbPerSec } = bench(fn, payload, mask, iterations); + console.log(` ${name.padEnd(18)}${formatOps(opsPerSec).padStart(12)}${formatNumber(mbPerSec).padStart(14)}`); + } + console.log(); + } +}; + +main(); diff --git a/bench/validate.js b/bench/validate.js new file mode 100644 index 0000000..db71f9b --- /dev/null +++ b/bench/validate.js @@ -0,0 +1,73 @@ +'use strict'; + +// The synchronous fast path in runValidator (src/rpc/router.js): a compiled +// ajv validator is a plain synchronous function, and the old async wrapper +// (`await runValidator(...).catch(...)`) taxed it with two promise +// allocations and microtask hops per validated call and per yielded +// subscription value. This file is the measurement that comment cites: the +// validator below reproduces the compiled shape (check, throw-with-issues +// on failure, return the value), and the two harnesses reproduce the old +// and the new call-site shapes. + +const { bench } = require('./support/harness.js'); + +const compiledShape = (args) => { + const value = args && typeof args === 'object' ? args : {}; + if (typeof value.n !== 'number') { + const error = new Error('/body must have number n'); + error.details = { issues: [{ message: 'must be number', path: '/body/n' }] }; + throw error; + } + return value; +}; + +// The OLD shape: async wrapper + .catch per invocation. +const runValidatorOld = async (validator, value, context = null) => { + const result = await validator(value, context); + return result === undefined ? value : result; +}; + +// The NEW shape: sync call, thenable check, try/catch at the site. +const runValidatorNew = (validator, value, context = null) => { + const result = validator(value, context); + if (result && typeof result.then === 'function') { + return result.then((settled) => (settled === undefined ? value : settled)); + } + return result === undefined ? value : result; +}; + +const args = { n: 7, tag: 'bench' }; +const BATCH = 1000; + +const run = async () => { + await bench( + 'old: await runValidator(...).catch(...)', + async () => { + let out; + for (let i = 0; i < BATCH; i++) { + out = await runValidatorOld(compiledShape, args).catch(() => null); + } + return out; + }, + { opsPerIteration: BATCH }, + ); + + await bench( + 'new: sync fast path + thenable check', + async () => { + let out; + for (let i = 0; i < BATCH; i++) { + try { + const checked = runValidatorNew(compiledShape, args); + out = checked && typeof checked.then === 'function' ? await checked : checked; + } catch { + out = null; + } + } + return out; + }, + { opsPerIteration: BATCH }, + ); +}; + +run(); diff --git a/bin/wrpc.js b/bin/wrpc.js new file mode 100755 index 0000000..303c788 --- /dev/null +++ b/bin/wrpc.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +'use strict'; + +// A shim, on purpose: everything the command does lives in src/cli/types.js, +// where the coverage thresholds reach it and where it can be driven in-process +// by a test instead of through a child process. +const { main } = require('../src/cli/types.js'); + +main(process.argv.slice(2)).then( + (code) => { + process.exitCode = code; + }, + // main() answers with an exit code rather than throwing, so this is the + // "impossible" branch. It exists because the alternative — Node printing an + // unhandled-rejection trace and exiting 1 — would replace the CLI's error + // format with a stack, exactly when something is already confusing. + (error) => { + console.error(`wrpc: ${error?.stack ?? error}`); + process.exitCode = 1; + }, +); diff --git a/browser.d.ts b/browser.d.ts new file mode 100644 index 0000000..be3b26f --- /dev/null +++ b/browser.d.ts @@ -0,0 +1,6 @@ +// Types for the browser entry (`browser.js`, the `browser` condition of the +// root export). The runtime barrel exports the client surface alone, so the +// types do too: importing a server name from '@alexify/wrpc' in a browser +// bundle is a compile error here instead of an `undefined` at runtime, and +// nothing in this graph touches node types. +export * from './client.js'; diff --git a/client.d.ts b/client.d.ts new file mode 100644 index 0000000..c5b3930 --- /dev/null +++ b/client.d.ts @@ -0,0 +1,1047 @@ +// The browser-safe half of the hand-maintained type surface: everything a +// client bundle can reach, and NOTHING that needs node types — a browser TS +// project without @types/node compiles against this file. index.d.ts +// re-exports all of it and adds the server surface on top; browser.d.ts +// (the `browser` types condition) re-exports ONLY this. + +/** + * Structural view of a node Writable — what finalize()/pipe() need. Typed + * structurally so this file stays free of node imports: a browser TS + * project without @types/node must compile against it. + */ +export interface WritableLike { + write(chunk: unknown, callback?: (error?: Error | null) => void): boolean; + end(): void; + on(event: string, listener: (...args: Array) => void): unknown; + once(event: string, listener: (...args: Array) => void): unknown; +} + +export declare class Emitter { + constructor(options?: { maxListeners?: number }); + emit(eventName: PropertyKey, value?: unknown): Promise; + on(eventName: PropertyKey, listener: (value: any) => void): void; + once(eventName: PropertyKey, listener: (value: any) => void): void; + off(eventName: PropertyKey, listener?: (value: any) => void): void; + clear(eventName?: PropertyKey): void; + listeners(eventName: PropertyKey): Array<(value: any) => void>; + listenerCount(eventName: PropertyKey): number; + eventNames(): Array; +} + +/** + * The REST-mode body codec (`codec.rest`): encodes VALUES — the plain + * result, the wire error object, the request body — never packet + * envelopes. Binary is allowed here (whole HTTP bodies, no framing to + * collide with), unlike the text-only packet half. + */ +export interface WrpcRestCodec { + encode(value: unknown): string | Uint8Array; + decode(body: Uint8Array | string): unknown; + /** Overrides `application/json` on REST bodies, both directions. */ + contentType?: string; +} + +export interface WrpcPacketCodec { + encode(packet: unknown): string; + decode(text: string): unknown; + /** Overrides `application/json` on packet-mode HTTP/SSE requests. */ + contentType?: string; + /** REST bodies stay JSON unless this section re-frames them. */ + rest?: WrpcRestCodec; +} + +/** A codec carries the packet half, the rest section, or both. */ +export type WrpcCodec = WrpcPacketCodec | { rest: WrpcRestCodec }; + +/** + * Structural check for an injected wire codec (shared with the server) — + * a narrowing predicate, like every isX guard in the package. + */ +export declare function isCodec(value: unknown): value is WrpcCodec; + +export class WrpcError extends Error { + code: number; + /** Structured issue lists the server attached; an optional wire field. */ + details?: unknown; + constructor(options: { message: string; code: number; details?: unknown }); +} + +export interface ReadableOptions { + highWaterMark?: number; +} + +export class WrpcReadable extends Emitter { + id: string; + name: string; + size: number; + queue: Array; + streaming: boolean; + status: string; + bytesRead: number; + highWaterMark: number; + constructor( + id: string, + name: string, + size: number, + options?: ReadableOptions, + ); + push(data: ArrayBufferView): Promise; + finalize(writable: WritableLike): Promise; + pipe(writable: T): T; + toBlob(type?: string): Promise; + close(): Promise; + terminate(): Promise; + stop(force?: boolean): Promise; + read(): Promise; + pull(): ArrayBufferView | undefined; + checkStreamLimits(): void; + waitEvent(event: PropertyKey): Promise; + [Symbol.asyncIterator](): AsyncIterableIterator; +} + +export interface Transport { + send(obj: object): void; + write(data: string | ArrayBufferView): void; +} + +export class WrpcWritable extends Emitter { + id: string; + name: string; + size: number; + transport: Transport; + /** True once the transport closed: write() reports false, no 'drain' follows. */ + readonly closed: boolean; + constructor(id: string, name: string, size: number, transport: Transport); + init(): void; + write(data: ArrayBufferView): boolean; + end(): void; + terminate(): void; +} + +export interface BlobUploader { + id: string; + upload(): Promise; +} + +/** + * One method's introspection descriptor — the raw `system/introspect` value + * shape `use()` consumes. A structural mirror of the server-side MethodInfo + * (index.d.ts); deliberately not imported across the two hand-maintained + * declaration files. + */ +export interface WrpcMethodInfo { + access: string; + /** Present only on subscriptions; a client scaffolds a call otherwise. */ + kind?: 'subscription'; + meta?: Record; + signature?: Record; + /** The declarative REST mapping, when the procedure carries one. */ + http?: { method: string; path: string; status?: number }; + /** Input schema parts (params/querystring/body) for client pre-validation. */ + schema?: { params?: object; querystring?: object; body?: object }; +} + +declare class ClientTransport extends Emitter { + url: string; + active: boolean; + constructor(url: string); + open(options?: WrpcClientOptions): Promise; + close(): void; + send(obj: object): void; + write(data: string | ArrayBufferView): void; + online(): void; + offline(): void; +} +export type { ClientTransport }; + +export class WrpcClient extends Emitter { + static connections: Set; + static isOnline: boolean; + static online(): void; + static offline(): void; + static initialize(): void; + /** + * With a contract — `WrpcClient.connect(url)` — the whole `api` is + * typed. Without one it behaves exactly as it always did. See + * {@link connect}, which is the same call under a name that reads better + * with a type argument. + */ + static connect( + url: string, + options?: WrpcClientOptions, + ): Promise>; + static transport: { + ws: new (url: string) => ClientTransport; + http: new (url: string) => ClientTransport; + event: { + getInstance(url: string): ClientTransport; + }; + /** Late registration — how the sse subpath (and tests) add transports. */ + [name: string]: unknown; + }; + + url: string; + /** + * The loaded units. A method is a function for a call and a + * {@link SubscriptionMethod} for a subscription — a subscription answers + * with a stream, so it is not callable. + * + * With a contract type this is {@link TypedApi}; note that it types what + * the contract DECLARES, not what has been `load()`ed yet — a unit read + * before its `load()` is `undefined` at runtime. + */ + api: TypedApi; + readonly active: boolean; + + constructor( + url: string, + transport: ClientTransport, + options?: WrpcClientOptions, + ); + /** Reconnect attempts made since the last successful open. */ + readonly attempt: number; + + open(): Promise; + close(): void; + load(...units: Array>): Promise; + /** + * One call by wire target ('unit/name', 'unit.vN/name'), no scaffolding — + * the escape hatch for a method whose unit has not been load()ed, which is + * every method inside an `authenticate` hook on a first connect. No + * client-side pre-validation, no REST leg. Deliberately untyped: `api` is + * the typed surface. + */ + call(method: string, args?: unknown, options?: CallOptions): Promise; + /** + * Static introspection: scaffolds units from an introspection artifact + * (the raw `system/introspect` shape, e.g. generated by + * `wrpc types --schema`) with no wire traffic. Synchronous and usable + * before open(). A unit already load()ed is skipped (dynamic wins); + * static units are never re-introspected on reconnect. + */ + use(introspection: Record>): this; + getStream(id: string): WrpcReadable | WrpcWritable; + createStream(name: string, size: number): WrpcWritable; + createBlobUploader(blob: Blob): BlobUploader; + send(obj: object): void; + /** Fire-and-forget event to the server; `name` is 'unit/event'. */ + sendEvent | (string & {})>(name: Name, data?: ClientSendData): void; + /** + * Registers the answer this client gives when the server asks `name` + * ('unit/event') — the receiving half of the server's `client.ask()` and + * `Broadcast.ask()`. One responder per name: a duplicate registration + * throws. Client-level rather than per-unit on purpose — unit objects + * carry server-named methods, where a method called 'respond' would + * collide. + */ + respond | (string & {})>( + name: Name, + handler: (data: ServerEventData) => ServerEventAnswer | Promise>, + ): void; + unrespond(name: string): boolean; + /** Sends whatever calls are waiting to be batched. Safe to call anytime. */ + flush(): void; + write(data: string | ArrayBufferView): void; +} + +/** + * Truncated exponential backoff with full jitter: + * `delay = random(0, min(maxDelay, minDelay * factor ** attempt))`. + */ +/** + * Runs once for all concurrent refusals; receives the FIRST refusal's error. + * A throw makes every waiting call surface its own original refusal. + */ +export type RefreshHook = (client: WrpcClient, error: unknown) => unknown; + +export interface ReconnectOptions { + /** First retry window in ms; default 2000. */ + minDelay?: number; + /** Cap in ms; default 30000. */ + maxDelay?: number; + /** Window growth per attempt; default 2. */ + factor?: number; + /** Spread the delay over the whole window; default true. */ + jitter?: boolean; + /** Attempts before giving up and emitting 'reconnect-failed'. */ + retries?: number; + /** + * How long (ms) an open connection must SURVIVE before the attempt + * counter resets. A TCP/WS open proves nothing by itself: an + * accept-then-drop peer would otherwise pin every client at minDelay + * forever. Default: minDelay. 0 restores the old reset-on-open behavior. + */ + stableAfter?: number; +} + +/** + * App-level heartbeat: `{ type: 'ping' }` out, `{ type: 'pong' }` back. + * A browser WebSocket exposes no protocol ping, so this is the only way to + * notice a connection that died without a close frame. WebSocket transport + * only; `false` disables it. + */ +export interface HeartbeatOptions { + /** Milliseconds between pings; default 30000. */ + interval?: number; + /** Milliseconds to wait for the pong before reconnecting; default 10000. */ + timeout?: number; +} + +/** + * Call batching: several calls issued in the same tick travel as ONE frame + * (a JSON array). Only `call` packets batch — a ping, a cancel or an + * unsubscribe is a control packet whose whole point is to arrive now. + */ +export interface BatchOptions { + /** 'microtask' (default) or a delay in ms. */ + flush?: 'microtask' | number; + /** Packets per frame before an early flush; default 16. */ + maxSize?: number; + /** Bytes per frame before an early flush; default 65536. */ + maxBytes?: number; +} + +export interface CallOptions { + /** Aborting sends `{type:'cancel'}` and rejects with code 499. */ + signal?: AbortSignal; + /** + * Per-call metadata, carried as the packet's optional `meta` field and + * surfaced server-side as `context.callMeta`. Deliberately outside schema + * validation. On a mapped REST leg there is no packet, so it merges over + * the connection bag and travels as request headers instead — per-call + * winning a key collision. + * + * Handed to the wire VERBATIM: unlike `withMeta`, this escape hatch does + * not kebab-normalize keys. It is the raw seam the auth hooks write + * against; prefer `withMeta` unless you need that. + */ + meta?: Record; + /** + * Per-call deadline in ms, overriding the connection-wide `callTimeout`. + * Rides the packet as an additive `timeout` field, so the server SHORTENS + * the procedure's own budget to match (the gRPC-deadline shape — a + * caller's budget can only tighten, never widen, the server's limit). + * Expiry rejects with the coded 408 `WrpcError`. Not yet carried on the + * mapped REST leg, where the connection-wide timeout semantics apply. + */ + timeout?: number; +} + +/** + * Opt-in per-call retry: a call refused with a listed code is re-issued + * (fresh packet id) after a truncated-exponential, jittered backoff, up to + * `attempts` extra tries. `true` takes the defaults. Off by default, and + * deliberately NOT a buffer: nothing is queued while offline — a failure is + * retried on the caller's own promise, visibly. Scope non-idempotent + * procedures away from the trigger codes. + */ +export interface RetryOptions { + /** Extra attempts after the first failure; default 2. */ + attempts?: number; + /** Codes that trigger a retry; default `[503]` (the coded dead-connection/draining refusals). */ + on?: Array; + /** First backoff window in ms; default 200. */ + minDelay?: number; + /** Cap in ms; default 2000. */ + maxDelay?: number; + /** Window growth per attempt; default 2. */ + factor?: number; + /** Spread the delay over the whole window; default true. */ + jitter?: boolean; +} + +/** A live subscription, as seen by the caller that opened it. */ +export interface Subscription { + readonly id: string; + /** The last tracked eventId seen; what a reconnect resumes from. */ + readonly lastEventId: string | undefined; + readonly closed: boolean; + unsubscribe(): boolean; +} + +export interface SubscribeOptions { + /** Where to resume from; the server decides what that means. */ + lastEventId?: string; + onData?(data: Data): void; + /** The subscription died: the handler threw, or the server refused it. */ + onError?(error: WrpcError): void; + /** + * The subscription ended cleanly — the handler finished, or + * {@link WrpcClient.close} took the whole client down under it. + * + * Exactly one of `onEnd`/`onError` fires, and only for an ending the caller + * did not ask for: calling `unsubscribe()` yourself is silent, because you + * already know. A reconnect is not an ending either — the client re-opens + * its subscriptions from the last eventId it saw. + */ + onEnd?(): void; +} + +export interface IterateOptions extends SubscribeOptions { + /** Aborting unsubscribes and ends the iterator. */ + signal?: AbortSignal; + highWaterMark?: number; +} + +/** What `client.api[unit][method]` is when the method is a subscription. */ +export interface SubscriptionMethod { + readonly kind: 'subscription'; + subscribe(args?: object, options?: SubscribeOptions): Subscription; + iterate(args?: object, options?: IterateOptions): AsyncIterableIterator & { subscription: Subscription }; +} + +// --------------------------------------------------------------------------- +// Contract-first typed client +// +// The contract is written ONCE, as an ordinary TypeScript interface, and +// threaded through `connect()`. Nothing is generated and nothing is +// checked at runtime: these types describe the api the server already +// introspects, so they buy autocompletion and a compile error on a typo — +// not a guarantee that the server agrees. (`wrpc types`, the codegen CLI, +// writes exactly this shape of interface FROM a running server.) +// +// interface Api { +// chat: { +// send(args: { text: string }): Promise<{ id: string }>; +// onMessage: SubscriptionContract<{ room: string }, { text: string }>; +// }; +// 'auth.v1': { signIn(args: { login: string }): Promise<{ token: string }> }; +// } +// +// const client = await connect('wss://host'); +// await client.load('chat'); +// const { id } = await client.api.chat.send({ text: 'hi' }); + +/** + * Declares a contract member as a subscription rather than a call: it answers + * with a stream, so on the client it becomes a {@link TypedSubscriptionMethod} + * (`subscribe`/`iterate`) instead of something callable. + * + * Declaration-only — there is no runtime value to construct, and the phantom + * key exists so a plain object type can never be mistaken for one. (The + * `~`-prefixed key is the same idiom Standard Schema uses for `~standard`.) + * Not to be confused with {@link Subscription}, which is the live handle + * `subscribe()` hands back. + */ +export interface SubscriptionContract { + readonly '~wrpc.subscription': (args: Args) => Data; +} + +/** + * No contract given: `api` keeps the loose, runtime-shaped record it has + * always had, so nothing about an untyped client changes. + */ +export type UntypedApi = Record>; + +/** + * `any` has to be caught before any conditional type sees it: `any extends X` + * matches BOTH branches, which would turn an untyped unit into a union of + * "callable" and "subscription" and make it neither. + */ +export type IsAny = 0 extends 1 & T ? true : false; + +/** `[args]` when args are required, `[args?]` when the contract says `void`. */ +export type ContractArgs> = [Args] extends [void] + ? [args?: Args, ...rest: Rest] + : [args: Args, ...rest: Rest]; + +/** + * What an unusable contract member maps to. A wrpc procedure receives exactly + * ONE args object, so a member declared with two parameters (or a rest + * parameter, or as something that is not a function at all) cannot be called: + * slot 1 on the client is {@link CallOptions}, not a second argument. The text + * is what the compiler quotes back at the call site. + */ +export type InvalidContractMember = ['wrpc: a contract member is `(args) => Promise` or a SubscriptionContract']; + +/** + * A contract member's parameters, as the client takes them. + * + * A zero-argument member KEEPS its args slot: `#scaffold` builds + * `(args = {}, options = {})`, so slot 0 is always the wire args. Collapsing + * the tuple would make `ping({ signal })` compile and then ship `{"signal":{}}` + * to the server as the procedure's arguments, silently dropping the + * cancellation the caller asked for — `ping(undefined, { signal })` is the + * spelling that works. + */ +export type TypedParams> = Params extends readonly [] ? [args?: undefined] + : Params extends readonly [unknown?] ? Params + : [args: InvalidContractMember]; + +/** What a subscription member of a contract becomes on the client. */ +export interface TypedSubscriptionMethod { + readonly kind: 'subscription'; + subscribe(...params: ContractArgs]>): Subscription; + iterate( + ...params: ContractArgs]> + ): AsyncIterableIterator & { subscription: Subscription }; +} + +/** + * One contract member, translated. A call keeps its declared parameters and + * gains the trailing {@link CallOptions} the client accepts, which is what + * carries `{ signal }`; its result is awaited, since the wire always answers + * with a promise whether the handler did or not. + */ +export type TypedMethod = IsAny extends true ? any + : T extends SubscriptionContract ? TypedSubscriptionMethod + : T extends (...args: infer Params) => infer Result + ? TypedCallMethod, Awaited> + : InvalidContractMember; + +/** + * A callable contract member: the call itself, plus `withMeta` — the bound + * variant that stamps every call it makes with per-call metadata. Keys are + * kebab-normalized once at bind time, so a bound method costs no more per + * invocation than an unbound one. + */ +export type TypedCallMethod, Result> = { + (...args: [...Params, options?: CallOptions]): Promise; + withMeta(meta: Record): (...args: [...Params, options?: CallOptions]) => Promise; +}; + +// --------------------------------------------------------------------------- +// Typed events — declarations only, zero runtime bytes. +// +// Two reserved contract keys close the realtime typing gap: +// +// interface Api { +// chat: { +// send(args: { text: string }): Promise<{ id: string }>; +// // server -> client: what `api.chat.on(...)` delivers, and what the +// // server's ask (`client.respond`) carries. Function form encodes the +// // ask's answer type; a bare payload type works for fire-and-forget. +// events: { +// message: (data: { text: string; from: string }) => void; +// confirm: (data: { id: string }) => boolean; +// }; +// // client -> server: what `client.sendEvent('chat/typing', ...)` +// // carries (the router's inbound `on` handlers receive it). +// sends: { typing: { on: boolean } }; +// }; +// } +// +// Both keys are stripped from the method map (they are declarations, not +// procedures) and neither exists at runtime. + +/** The `events` map a unit declared, or `never`. */ +export type UnitEvents = Unit extends { events: infer Events } ? Events : never; + +/** A declared event's listener: function form kept, payload form wrapped. */ +export type EventListenerOf = T extends (data: infer Data) => any ? (data: Data) => void : (data: T) => void; + +/** A declared event's payload, whichever form declared it. */ +export type EventDataOf = T extends (data: infer Data) => any ? Data : T; + +/** + * The unit-object emitter surface, narrowed to the declared event names — + * what a unit becomes instead of the bare {@link Emitter} once its contract + * declares `events`. Runtime-compatible: the unit object IS an Emitter. + */ +export interface TypedUnitEmitter { + emit(eventName: PropertyKey, value?: unknown): Promise; + on(name: Name, listener: EventListenerOf): void; + once(name: Name, listener: EventListenerOf): void; + off(name: Name, listener?: EventListenerOf): void; + clear(eventName?: PropertyKey): void; + listeners(eventName: PropertyKey): Array<(value: any) => void>; + listenerCount(eventName: PropertyKey): number; + eventNames(): Array; +} + +/** Every `unit/event` name the contract declares under `events`. */ +export type ServerEventName = { + [Unit in keyof Api & string]: [UnitEvents] extends [never] ? never + : `${Unit}/${keyof UnitEvents & string}`; +}[keyof Api & string]; + +/** The payload of one declared server event, by its `unit/event` name. */ +export type ServerEventData = Name extends `${infer Unit}/${infer Event}` + ? Unit extends keyof Api ? Event extends keyof UnitEvents ? EventDataOf[Event]> + : unknown + : unknown + : unknown; + +/** What a responder may answer a declared ask with (function form only). */ +export type ServerEventAnswer = Name extends `${infer Unit}/${infer Event}` + ? Unit extends keyof Api + ? Event extends keyof UnitEvents + ? UnitEvents[Event] extends (data: any) => infer Answer ? Answer : unknown + : unknown + : unknown + : unknown; + +/** Every `unit/event` name the contract declares under `sends`. */ +export type ClientSendName = { + [Unit in keyof Api & string]: Api[Unit] extends { sends: infer Sends } ? `${Unit}/${keyof Sends & string}` + : never; +}[keyof Api & string]; + +/** The payload of one declared client→server event. */ +export type ClientSendData = Name extends `${infer Unit}/${infer Event}` + ? Unit extends keyof Api + ? Api[Unit] extends { sends: infer Sends } ? (Event extends keyof Sends ? EventDataOf : unknown) + : unknown + : unknown + : unknown; + +/** + * One contract unit, translated. + * + * `on` is dropped: a unit IS an {@link Emitter} at runtime, so `api.chat.on` + * has to stay the listener registration. Mapping a contract key called `on` + * would shadow it with an overload — and an *optional* one reduces the whole + * intersection to `never`, which turns every member access on that unit into + * an error pointing nowhere. `events` and `sends` are the reserved typed- + * event declarations above — never procedures. + */ +export type TypedUnit = IsAny extends true ? Record + : { + [Method in keyof Unit as Method extends 'on' | 'events' | 'sends' ? never : Method]: TypedMethod; + }; + +/** + * The whole contract, translated. Each unit is also an {@link Emitter} — that + * is where server → client events for the unit arrive; a unit that declares + * `events` gets the narrowed {@link TypedUnitEmitter} instead. + */ +export type TypedApi = { + [Unit in keyof Api]: ([UnitEvents] extends [never] ? Emitter : TypedUnitEmitter>) & + TypedUnit; +}; + +/** + * The first parameter of a parameter tuple. Projected from the tuple rather + * than inferred from `(args: infer A) => any`, because a zero-parameter + * function IS assignable to a one-parameter target — so that inference + * succeeds with `unknown` where the honest answer is `void`. + */ +export type FirstArg> = Params extends readonly [] ? void + : Params extends readonly [infer Arg] ? Arg + : Params extends readonly [(infer Arg)?] ? Arg | undefined + : Params extends readonly [infer Arg, ...Array] ? Arg + : void; + +/** + * The argument type of a contract member, call or subscription — declared + * ({@link SubscriptionContract}) or already mapped + * ({@link TypedSubscriptionMethod}). + */ +export type InferArgs = T extends SubscriptionContract ? Args + : T extends TypedSubscriptionMethod ? Args + : T extends (...args: infer Params) => any ? FirstArg + : void; + +/** What a contract member answers with: a call's result, a subscription's value. */ +export type InferResult = T extends SubscriptionContract ? Data + : T extends TypedSubscriptionMethod ? Data + : T extends (...args: Array) => infer Result ? Awaited + : never; + +/** + * Opens a connection. The same call as {@link WrpcClient.connect}, spelled as + * a function because that is where the contract type argument reads naturally: + * `connect('wss://host')`. Without one, the client is untyped exactly as + * before. + */ +export declare function connect( + url: string, + options?: WrpcClientOptions, +): Promise>; + +export interface WrpcClientOptions { + /** + * Milliseconds a call may await its answer before rejecting with a coded + * 408 `WrpcError` ('Request timeout'); default 7000. + */ + callTimeout?: number; + /** + * Cap (ms) on the transport handshake; default 30000, `0`/`false` + * disables. Connection setup is the one phase with no liveness signal at + * all — the heartbeat starts after 'open', and nothing schedules the next + * reconnect attempt while open() is pending — so a handshake that neither + * opens nor errors would park the reconnect ladder forever. On expiry the + * open rejects with a coded 408 `WrpcError` ('Connect timeout') and the + * normal reconnect cycle continues with the attempt counter intact. + */ + connectTimeout?: number | false; + /** Coalesce calls into batch frames; `true` takes the defaults. */ + batch?: BatchOptions | boolean; + /** Opt-in per-call retry policy; `true` takes the defaults. */ + retry?: RetryOptions | boolean; + /** + * Which registered transport to use. Defaults to the URL scheme; 'sse' + * exists once '@alexify/wrpc/sse' has been required. + */ + /** + * A single registered transport name, or an ORDERED fallback list: each + * candidate gets its own reconnect budget; when one exhausts, the next + * takes over ('transport-fallback' fires) and only the last exhausting + * emits 'reconnect-failed'. No default order — the list is yours. + */ + transport?: 'ws' | 'http' | 'sse' | string | Array; + reconnect?: ReconnectOptions | false; + /** + * Presents this connection's credential. Awaited inside `open()` on the + * first connect — so `await connect(url, { authenticate })` returns an + * already-authenticated client — and again on every reconnect BEFORE the + * subscriptions are re-opened and the units re-loaded, which is the window + * an `'open'` listener cannot reach. A throw fails the connection: the + * transport is terminated, `'authenticate-failed'` fires, and the normal + * backoff takes over (a first-connect throw rejects `connect()` instead). + * Call `client.close()` before throwing to stop instead of retrying. + * Inside the hook, address methods with `client.call('unit/name', args)` — + * `api` is built by `load()`, which runs after auth. + */ + authenticate?: (client: WrpcClient, info: { reconnected: boolean; attempts: number }) => unknown; + /** + * Single-flight credential refresh for a LIVE connection (`authenticate` + * heals a NEW one). When a call is refused with a listed code (default + * `[401]`), the handler runs once for ALL concurrent refusals and each + * refused call is re-issued exactly once, with a fresh packet id, on both + * the packet and the REST leg. If the refresh itself fails — or the retry + * is refused again — the ORIGINAL error surfaces. Never fires for calls + * made inside the `authenticate` hook. Note: wrpc's own "no session" + * refusal is 403; list it via `on` to cover it. + */ + refresh?: RefreshHook | { on?: Array; handler: RefreshHook }; + /** + * Connection-phase headers, re-evaluated on every open (function form + * included) so a reconnect presents fresh values. They ride as REAL + * request headers on http/sse, as one `wrpc_h` query parameter on the + * browser ws connect URL (the WHATWG WebSocket constructor takes no + * headers — note the URL lands in proxy access logs), and in the + * `wrpc:connect` message on the worker transport. The server surfaces + * them on `client.meta.headers` / `context.meta.headers`, observed + * headers winning, reserved names (cookie, host, origin, sec-*, + * content-*, proxy-*, x-wrpc-*) dropped from the query path. Labels, + * never credentials on the ws leg. + * + * Keys are normalized to kebab-case (`xAppVersion` -> `x-app-version`), so + * one spelling addresses a value whatever carrier brought it — and that is + * the spelling to write in `schema.headers`. Values are stringified, so a + * number arrives as a string on every transport rather than being coerced + * on http and dropped on ws. + */ + headers?: Record | (() => Record | Promise>); + /** + * Connection-phase metadata — headers' unvalidated sibling. Re-evaluated + * on every open; rides the `x-wrpc-meta` request header on http/sse, the + * `wrpc_meta` connect-URL parameter on ws, and the `wrpc:connect` message + * on the worker transport. Lands on `client.meta.data` server-side. Never + * runs through schema validation — see the per-call twin in CallOptions. + * + * Keys are kebab-normalized like `headers`; values keep their JSON types + * unless `metaFormat` is `'prefixed'`. + */ + meta?: Record | (() => Record | Promise>); + /** + * How the connection-phase `meta` bag is spelled on the wire. + * + * `'json'` (default) sends one `x-wrpc-meta` header of percent-encoded + * JSON: type-faithful, and one stable name on the CORS allowlist. + * + * `'prefixed'` sends one `x-wrpc-meta-` header per entry — the S3 + * `x-amz-meta-*` idiom, so a gateway can route on, inject or strip an + * individual key without JSON surgery. Values become strings (objects are + * JSON-stringified, null/undefined drop the key), and cross-origin + * callers must name each key in `cors.metaHeaders`. + * + * The choice changes the wire only on http/sse; ws and the worker + * transport have no headers and keep the one query parameter. What it + * changes everywhere is the VALUES: `'prefixed'` flattens on every + * transport, so the bag the server observes never depends on the carrier. + */ + metaFormat?: 'json' | 'prefixed'; + /** + * Pluggable query-string serializer (qs and friends) for mapped REST + * requests over the http transport — mirror of the server's option. + */ + querystring?: { stringify(query: object): string }; + /** + * Client-side pre-validation: an injected ajv-shaped compiler applied to + * the introspected input schema parts, so a doomed call rejects locally + * (WrpcError 400 + details) without the round trip. + */ + validation?: { ajv: { compile(schema: object): (value: unknown) => boolean } }; + /** + * The client half of the server's wire codec — same structural shape. + * The packet half (single-line text) frames ws/http/sse packet paths; + * the optional `rest` section frames REST-leg BODIES (values, not + * packets — binary allowed). Either half alone is valid. + */ + codec?: + | { + encode(packet: unknown): string; + decode(text: string): unknown; + contentType?: string; + rest?: { encode(value: unknown): string | Uint8Array; decode(body: Uint8Array | string): unknown; contentType?: string }; + } + | { rest: { encode(value: unknown): string | Uint8Array; decode(body: Uint8Array | string): unknown; contentType?: string } }; + /** Shorthand for `reconnect.minDelay`. */ + reconnectTimeout?: number; + heartbeat?: HeartbeatOptions | false; + worker?: ServiceWorker; + /** + * Off by default, unlike the server: a client that printed on every + * reconnect would be noise in a browser console nobody asked for. A logger + * observes errors in addition to the `'error'` event, it does not replace + * it. + */ + logger?: WrpcLogger | boolean; + /** + * Off unless a tracer, a meter, or the OTel api module is supplied. With a + * propagator, outgoing packets carry `tp`/`ts` so the server's span becomes + * a child of this client's. + */ + telemetry?: WrpcTelemetryOptions | null; + /** Jitter source; injectable so tests can pin the backoff schedule. */ + random?: () => number; + /** + * Packet, subscription and stream ids; uuid v4 unless the app brings its + * own (cuid/ulid/a test counter). Correlation ids, not secrets. A stream + * id must stay within 255 UTF-8 bytes (the chunk header stores its + * length in one byte). + */ + generateId?: () => string; + /** + * WebSocket subprotocols to offer. Defaults to ['wrpc.v1'], which the + * server echoes back as the wire revision; an empty array offers nothing + * (the pre-versioning handshake). See protocol.md#versioning. + */ + protocols?: Array; + proxy?: (data: string, packet: object | null) => void; +} + +export class WrpcClientProxy extends Emitter { + constructor(options?: WrpcClientOptions); + open(): Promise; + close(): void; +} + +/** + * Push -> pull adapter: the bridge between "something calls me with a + * value" and "someone is `for await`-ing values". The queue is bounded — + * a producer that outruns the consumer drops the OLDEST value and says so + * through `dropped`. + */ +export declare class EventStream implements AsyncIterableIterator { + constructor(options?: { signal?: AbortSignal; highWaterMark?: number }); + readonly length: number; + readonly closed: boolean; + dropped: number; + push(value: T): boolean; + end(): void; + fail(error: Error): void; + next(): Promise>; + return(): Promise>; + [Symbol.asyncIterator](): AsyncIterableIterator; +} + +export declare function createEventStream(options?: { + signal?: AbortSignal; + highWaterMark?: number; +}): EventStream; + +/** The third argument every subscription handler receives. */ + +/** + * A logger to inject. Every member is optional so that both shapes are + * structurally assignable without importing anybody's types: + * + * - a **structured** logger (pino, bunyan, winston) — identified by `child` + * or `level`, and called as `(entry, message)` + * - a **Console** — called as `(message)`, with the entry dropped + * + * `true` logs to the global console; `false` disables logging outright. An + * object that matches neither shape disables it too: an observability option + * never throws. + */ +export interface WrpcLogger { + child?(bindings: Record): WrpcLogger; + level?: unknown; + log?(...args: any[]): void; + info?(...args: any[]): void; + debug?(...args: any[]): void; + warn?(...args: any[]): void; + error?(...args: any[]): void; +} + +/** + * The normalized writer wrpc hands BACK (context.log, client.log): every + * level exists, `child` always answers, and `enabled` is false when logging + * is off. What a user injects is {@link WrpcLogger}; this is what they get. + */ +export interface WrpcLogWriter { + readonly enabled: boolean; + child(bindings: Record): WrpcLogWriter; + log(entry: Record, message?: string): void; + info(entry: Record, message?: string): void; + debug(entry: Record, message?: string): void; + warn(entry: Record, message?: string): void; + error(entry: Record, message?: string): void; +} + +// --------------------------------------------------------------------------- +// OpenTelemetry +// +// Structural views of the OTel objects, so a real SDK is assignable without +// wrpc importing (or depending on) @opentelemetry/api. Only `Span.end` is +// required — everything a span might not implement is optional, and wrpc +// calls it optionally. + +export interface WrpcSpan { + setAttribute?(key: string, value: unknown): unknown; + addEvent?(name: string, attributes?: Record): unknown; + recordException?(error: unknown): void; + setStatus?(status: { code: number; message?: string }): unknown; + end(): void; +} + +export interface WrpcTracer { + startSpan?(name: string, options?: unknown): WrpcSpan; + startActiveSpan?(name: string, options: unknown, fn: (span: WrpcSpan) => T): T; +} + +export interface WrpcCounter { + add(value: number, attributes?: Record): void; +} + +export interface WrpcHistogram { + record(value: number, attributes?: Record): void; +} + +export interface WrpcMeter { + createCounter(name: string, options?: unknown): WrpcCounter; + createHistogram(name: string, options?: unknown): WrpcHistogram; + createUpDownCounter?(name: string, options?: unknown): WrpcCounter; +} + +export interface WrpcPropagation { + inject(context: unknown, carrier: object, setter: unknown): void; + extract(context: unknown, carrier: object, getter: unknown): unknown; +} + +export interface WrpcContextApi { + active(): unknown; +} + +export interface WrpcTelemetryApi { + trace?: { getTracer(name: string, version?: string): WrpcTracer }; + metrics?: { getMeter(name: string, version?: string): WrpcMeter }; + propagation?: WrpcPropagation; + context?: WrpcContextApi; +} + +/** + * Two injection modes. `{ api }` — the `@opentelemetry/api` module, from + * which wrpc derives its own tracer and meter so spans carry the + * `@alexify/wrpc` instrumentation scope. `{ tracer, meter }` — pre-built + * instances; either alone is a supported configuration. + * + * With neither, telemetry is off and every recording path is a no-op. + * + * @experimental The telemetry writer shapes and metric set may change in a + * minor release; see CONTRIBUTING.md "Stability and deprecation". + */ +export interface WrpcTelemetryOptions { + api?: WrpcTelemetryApi; + tracer?: WrpcTracer; + meter?: WrpcMeter; + /** + * Default true. When false, the peer address is left off spans. A session + * token is never recorded at any setting — that is a credential, not an + * identity, and the two do not share a switch. + */ + includeIdentity?: boolean; + /** + * Trace context is written to, and read from, the packet's `tp`/`ts` + * fields through these. `api` supplies both; pass them explicitly when + * injecting a bare `tracer`/`meter`. Without a propagator wrpc emits local + * spans only — it will not hand-roll the W3C format. + */ + propagation?: WrpcPropagation; + context?: WrpcContextApi; + /** + * Default true, as in gRPC and HTTP instrumentation: an inbound + * `traceparent` becomes the server span's parent. Set false when peers are + * untrusted — a hostile client can otherwise forge trace ids. + */ + trustRemoteContext?: boolean; +} + +export interface CallPacket { + type: 'call'; + id: string; + method: string; + args: object; +} + +export interface StreamPacket { + type: 'stream'; + id: string; + name?: string; + size?: number; + status?: 'end' | 'terminate'; +} + +/** + * Fire-and-forget, both directions; `name` is 'unit/event'. With an `id` + * it is an ask (server → client): the receiver MUST answer with a + * `callback` carrying the same id. + */ +export interface EventPacket { + type: 'event'; + name: string; + data?: unknown; + id?: string; +} + +export interface SubscribePacket { + type: 'subscribe'; + id: string; + method: string; + args?: object; + lastEventId?: string; +} + +/** One value of a subscription; `eventId` only for tracked values. */ +export interface DataPacket { + type: 'data'; + id: string; + eventId?: string; + data?: unknown; +} + +/** A subscription's terminal packet — refusals included. */ +export interface EndPacket { + type: 'end'; + id: string; + error?: { message: string; code: number; details?: unknown }; +} + +export interface UnsubscribePacket { + type: 'unsubscribe'; + id: string; +} + +export interface CancelPacket { + type: 'cancel'; + id: string; +} + +/** A JSON array of packets: several requests, or several answers, in one frame. */ +export type BatchFrame = Array< + CallPacket | SubscribePacket | UnsubscribePacket | CancelPacket | StreamPacket | EventPacket +>; + +/** App-level heartbeat; see HeartbeatOptions. */ +export interface HeartbeatPacket { + type: 'ping' | 'pong'; +} + +export function chunkEncode(id: string, payload: Uint8Array): Uint8Array; +export function chunkDecode(chunk: Uint8Array): { + id: string; + payload: Uint8Array; +}; diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 73a6e19..2366b6c 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,15 +1,74 @@ import { defineConfig } from 'vitepress'; import { withMermaid } from 'vitepress-plugin-mermaid'; +const ogTitle = 'wrpc — WebSocket RPC for Node.js and the browser'; const ogDescription = - 'Fast and low overhead, zero-dependency, WebSocket-based RPC protocol for Node.js and browsers.'; + 'Fast, zero-dependency WebSocket-based RPC for Node.js and browsers: router and procedures, ' + + 'subscriptions with resume, rooms with a scaling backplane, binary streams with real backpressure, ' + + 'SSE, and a typed client — with no runtime dependencies.'; const repo = 'https://github.com/Alexis-Technologies/wrpc'; const base = '/'; const hostname = 'https://wrpc.vercel.app/'; +const ogImage = `${hostname}logo.png`; + +// Mirrors package.json "keywords" — kept as a one-term-per-line list so the +// two stay easy to diff. +const keywords = [ + 'wrpc', + 'websocket', + 'rpc', + 'protocol', + 'nodejs', + 'browser', + 'fast', + 'zero-dependency', + 'authentication', + 'realtime', + 'communication', + 'messaging', + 'remote-procedure-call', + 'rpc-framework', + 'websocket-rpc', + 'typescript', + 'javascript', + 'node', + 'opentelemetry', + 'observability', + 'sse', + 'server-sent-events', + 'rest', + 'typed-client', + 'codegen', + 'tanstack-query', + 'subscriptions', + 'pubsub', + 'cluster', + 'streams', +].join(', '); + +// schema.org structured data — helps search and AI engines understand the +// package as a software entity, not just text on a page. +const jsonLd = { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: '@alexify/wrpc', + alternateName: 'wrpc', + description: ogDescription, + applicationCategory: 'DeveloperApplication', + operatingSystem: 'Node.js >= 22, modern browsers', + url: hostname, + image: ogImage, + downloadUrl: 'https://www.npmjs.com/package/@alexify/wrpc', + codeRepository: repo, + license: 'https://opensource.org/licenses/MIT', + keywords, + author: { '@type': 'Organization', name: 'Alexis Technologies' }, + offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, +}; // https://vitepress.dev/reference/site-config -export default withMermaid( - defineConfig({ +export default withMermaid({ + ...defineConfig({ title: '@alexify/wrpc', titleTemplate: ':title — wrpc', description: ogDescription, @@ -20,25 +79,136 @@ export default withMermaid( sitemap: { hostname }, head: [ + ['link', { rel: 'icon', type: 'image/svg+xml', href: `${base}logo-mark.svg` }], + ['link', { rel: 'icon', type: 'image/png', href: `${base}favicon.png` }], + ['meta', { name: 'theme-color', content: '#5FA04E' }], ['meta', { name: 'author', content: 'Alexis Technologies' }], + ['meta', { name: 'keywords', content: keywords }], ['meta', { name: 'robots', content: 'index, follow' }], ['meta', { property: 'og:type', content: 'website' }], ['meta', { property: 'og:site_name', content: '@alexify/wrpc' }], + ['meta', { property: 'og:title', content: ogTitle }], ['meta', { property: 'og:description', content: ogDescription }], + ['meta', { property: 'og:image', content: ogImage }], + ['meta', { name: 'twitter:card', content: 'summary_large_image' }], + ['meta', { name: 'twitter:title', content: ogTitle }], + ['meta', { name: 'twitter:description', content: ogDescription }], + ['meta', { name: 'twitter:image', content: ogImage }], + ['script', { type: 'application/ld+json' }, JSON.stringify(jsonLd)], ], + // Mermaid pulls in a few CJS-only transitive deps (dayjs among them). Vite's + // dev server hands them to the browser unbundled, where `import dayjs from` has + // no default export and the whole app fails to boot. Pre-bundling them fixes dev; + // noExternal keeps mermaid inlined for the SSR build. + vite: { + optimizeDeps: { include: ['mermaid', 'dayjs'] }, + ssr: { noExternal: ['mermaid'] }, + }, + + // Per-page canonical + og:url for clean SEO indexing. + transformPageData(pageData) { + const path = pageData.relativePath.replace(/index\.md$/, '').replace(/\.md$/, ''); + const canonical = `${hostname}${path}`; + pageData.frontmatter.head ??= []; + pageData.frontmatter.head.push( + ['link', { rel: 'canonical', href: canonical }], + ['meta', { property: 'og:url', content: canonical }], + ); + }, + themeConfig: { - nav: [{ text: 'Guide', link: '/guide/getting-started', activeMatch: '/guide/' }], + logo: '/logo-mark.svg', + // ─── Top navigation ────────────────────────────────────────────── + nav: [ + { text: 'Guide', link: '/guide/getting-started', activeMatch: '/guide/' }, + { text: 'Reference', link: '/reference/protocol', activeMatch: '/reference/' }, + { + // Hand-synced with package.json "version" — part of the release checklist. + text: 'v1.0.0', + items: [ + { text: 'Changelog', link: `${repo}/blob/main/CHANGELOG.md` }, + { text: 'npm', link: 'https://www.npmjs.com/package/@alexify/wrpc' }, + { text: 'Releases', link: `${repo}/releases` }, + ], + }, + ], + + // ─── Sidebar ───────────────────────────────────────────────────── sidebar: { '/guide/': [ { text: 'Introduction', - items: [{ text: 'Getting Started', link: '/guide/getting-started' }], + items: [ + { text: 'Getting Started', link: '/guide/getting-started' }, + { text: 'Why wrpc?', link: '/guide/why' }, + ], + }, + { + text: 'Server', + items: [ + { text: 'Server', link: '/guide/server' }, + { text: 'Router & procedures', link: '/guide/router' }, + { text: 'Declarative REST', link: '/guide/rest' }, + { text: 'Hooks', link: '/guide/hooks' }, + { text: 'Sessions', link: '/guide/sessions' }, + { text: 'Authentication', link: '/guide/auth' }, + { text: 'Metadata', link: '/guide/metadata' }, + { text: 'Rooms', link: '/guide/rooms' }, + { text: 'Subscriptions', link: '/guide/subscriptions' }, + { text: 'Binary streams', link: '/guide/streams' }, + { text: 'Scaling', link: '/guide/scaling' }, + { text: 'Cluster', link: '/guide/cluster' }, + ], + }, + { + text: 'Client', + items: [ + { text: 'Client', link: '/guide/client' }, + { text: 'Typed client', link: '/guide/typed-client' }, + { text: 'Browser & bundling', link: '/guide/browser' }, + { text: 'Codegen CLI', link: '/guide/cli' }, + { text: 'TanStack Query', link: '/guide/query' }, + ], + }, + { + text: 'Transports & hosts', + items: [ + { text: 'Server-Sent Events', link: '/guide/sse' }, + { text: 'Wire codec', link: '/guide/codec' }, + { text: 'uWebSockets.js', link: '/guide/adapters/uws' }, + { text: 'Fastify', link: '/guide/adapters/fastify' }, + { text: 'Express', link: '/guide/adapters/express' }, + ], + }, + { + text: 'Operations', + items: [ + { text: 'Security', link: '/guide/security' }, + { text: 'Running in production', link: '/guide/production' }, + { text: 'Testing', link: '/guide/testing' }, + { text: 'Performance', link: '/guide/performance' }, + { text: 'Logging', link: '/guide/logging' }, + { text: 'OpenTelemetry', link: '/guide/telemetry' }, + ], + }, + ], + '/reference/': [ + { + text: 'Reference', + items: [ + { text: 'Wire protocol', link: '/reference/protocol' }, + { text: 'Wire format', link: '/reference/wire-format' }, + { text: 'Engine port', link: '/reference/engine' }, + { text: 'Errors & close codes', link: '/reference/errors' }, + { text: 'Stability & deprecation', link: '/reference/stability' }, + ], }, ], }, + // ─── Local, zero-config full-text search ───────────────────────── search: { provider: 'local' }, socialLinks: [{ icon: 'github', link: repo }], @@ -52,6 +222,27 @@ export default withMermaid( message: 'Released under the MIT License.', copyright: 'Copyright © 2026 Alexis Technologies', }, + + docFooter: { + prev: 'Previous page', + next: 'Next page', + }, }, }), -); + + // ─── Mermaid ─────────────────────────────────────────────────────────── + // Deliberately no `theme` here: vitepress-plugin-mermaid picks 'default' or + // 'dark' from VitePress' own isDark and re-renders on toggle, and anything we + // set would win over that and freeze the diagrams in one theme. Colors are + // therefore left to mermaid; only typography and layout are ours. + mermaid: { + fontFamily: 'var(--vp-font-family-base)', + flowchart: { useMaxWidth: true, htmlLabels: true, padding: 12 }, + sequence: { useMaxWidth: true, actorMargin: 40, wrap: true }, + }, + + mermaidPlugin: { + // `vp-raw` keeps VitePress' own markdown styling off the rendered SVG. + class: 'mermaid vp-raw', + }, +}); diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css new file mode 100644 index 0000000..9052f7d --- /dev/null +++ b/docs/.vitepress/theme/custom.css @@ -0,0 +1,143 @@ +/** + * Brand theme for wrpc — the same approach Pinia/Vite/Vue use: override VitePress + * CSS variables to restyle the default theme rather than fork its components. + * + * Palette: Node.js green (#5FA04E / #7DBE6C) on ink (#1B1C1E). wrpc is a Node.js + * protocol library, so it wears the platform's colour. + * + * Note on contrast: #5FA04E as link text on white is 3.17:1, which fails WCAG AA. + * So in the light theme brand-1/-2 are darkened greens (text and links, 5.6:1 and + * 4.4:1) and the pure Node green is reserved for filled surfaces, where the text + * on top is ink. + */ + +:root { + /* ─── Brand colors (buttons, links, accents) ──────────────────────── */ + --vp-c-brand-1: #3d7431; + --vp-c-brand-2: #4c8f3d; + --vp-c-brand-3: #5fa04e; + --vp-c-brand-soft: rgba(95, 160, 78, 0.16); + + /* Default theme alias mappings (kept in sync with brand) */ + --vp-c-default-1: var(--vp-c-gray-1); + --vp-c-default-2: var(--vp-c-gray-2); + --vp-c-default-3: var(--vp-c-gray-3); + --vp-c-default-soft: var(--vp-c-gray-soft); + + --vp-c-tip-1: var(--vp-c-brand-1); + --vp-c-tip-2: var(--vp-c-brand-2); + --vp-c-tip-3: var(--vp-c-brand-3); + --vp-c-tip-soft: var(--vp-c-brand-soft); +} + +.dark { + --vp-c-brand-1: #7dbe6c; + --vp-c-brand-2: #9bd08c; + --vp-c-brand-3: #417e33; + --vp-c-brand-soft: rgba(95, 160, 78, 0.18); +} + +/* ─── Buttons: ink on Node green, the way nodejs.org does its CTAs ─── */ +:root { + --vp-button-brand-bg: #5fa04e; + --vp-button-brand-hover-bg: #7dbe6c; + --vp-button-brand-active-bg: #417e33; + --vp-button-brand-border: transparent; + --vp-button-brand-hover-border: transparent; + --vp-button-brand-active-border: transparent; + --vp-button-brand-text: #1b1c1e; + --vp-button-brand-hover-text: #1b1c1e; + --vp-button-brand-active-text: #1b1c1e; +} + +/* ─── Home hero: gradient title + glowing logo blob (the "Pinia look") ─ */ +:root { + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: -webkit-linear-gradient(120deg, #5fa04e 25%, #9bd08c); + + /* Deeper than the mark itself: the logo is #5FA04E, so an equally bright halo + would swallow it. Forest green keeps the mark the brightest thing on screen. */ + --vp-home-hero-image-background-image: linear-gradient(-45deg, #417e33 40%, #22431b 100%); + --vp-home-hero-image-filter: blur(48px); +} + +@media (min-width: 640px) { + :root { + --vp-home-hero-image-filter: blur(72px); + } +} + +@media (min-width: 960px) { + :root { + --vp-home-hero-image-filter: blur(96px); + } +} + +/* ─── Code block inline highlight accent ──────────────────────────────── */ +:root { + --vp-code-block-bg: var(--vp-c-bg-alt); +} + +/* Slightly tighter, friendlier hero on wide screens */ +.VPHero .name { + letter-spacing: -0.02em; +} + +/* ─── Home feature cards: glow on hover (clickable, no layout shift) ───── */ +.VPFeatures .VPFeature { + transition: + border-color 0.25s ease, + background-color 0.25s ease, + box-shadow 0.25s ease; +} + +.VPFeatures .VPFeature.link:hover { + border-color: var(--vp-c-brand-3); + box-shadow: 0 8px 24px rgba(65, 126, 51, 0.16); +} + +.dark .VPFeatures .VPFeature.link:hover { + box-shadow: 0 8px 24px rgba(125, 190, 108, 0.14); +} + +/* Make the "learn more" link adopt the brand color on card hover */ +.VPFeatures .VPFeature.link:hover .link-text-value { + color: var(--vp-c-brand-1); +} + +/* ─── Mermaid diagrams: centre them and let wide graphs scroll ─────────── */ +.mermaid { + display: flex; + justify-content: center; + overflow-x: auto; + margin: 20px 0; +} + +/** + * Mermaid sizes a node from its own measurement pass, which does not know about + * VitePress' global line-height (1.5). Multi-line labels then overflow the box + * they were fitted to. Pinning the label line-height back to mermaid's own + * assumption keeps the text inside the shape. + */ +.mermaid .nodeLabel, +.mermaid .edgeLabel, +.mermaid .cluster-label, +.mermaid foreignObject div, +.mermaid foreignObject span, +.mermaid foreignObject p { + line-height: 1.25; + margin: 0; +} + +/** + * Edge labels render on their own opaque rect, which mermaid fills from the page + * background it assumed at init — not the one in effect after a theme toggle. + * Pinning both to VitePress' own tokens keeps them readable in either theme. + */ +.mermaid .edgeLabel, +.mermaid .edgeLabel rect, +.mermaid .edgeLabel foreignObject div { + background-color: var(--vp-c-bg) !important; + fill: var(--vp-c-bg) !important; + color: var(--vp-c-text-1); +} diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index f373ab7..061e231 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,8 +1,10 @@ import DefaultTheme from 'vitepress/theme'; import type { Theme } from 'vitepress'; +import './custom.css'; -// Minimal skeleton: no brand overrides yet. Add custom.css here once the -// visual identity is decided, following the pattern in kerberos/docs/.vitepress/theme. +// Extends the default VitePress theme with our brand styling (see custom.css). +// This is exactly the pattern Pinia / Vite / Vue use to brand their docs — the +// layout and components stay the default theme, the look comes from CSS variables. export default { extends: DefaultTheme, } satisfies Theme; diff --git a/docs/guide/adapters/express.md b/docs/guide/adapters/express.md new file mode 100644 index 0000000..c57496d --- /dev/null +++ b/docs/guide/adapters/express.md @@ -0,0 +1,112 @@ +# Express + +```js +const express = require('express'); +const { createWrpc } = require('@alexify/wrpc/express'); + +const app = express(); +const wrpc = createWrpc({ router }); + +app.use(wrpc.handler); + +const httpServer = app.listen(8000); +httpServer.on('upgrade', wrpc.upgrade); +``` + +Nothing here owns a listener — the app does. `createWrpc()` hands back the +pieces and you wire them where they belong: + +| Member | What it is | +| --- | --- | +| `handler(req, res, next)` | express/connect middleware for the HTTP half. | +| `upgrade(req, socket, head)` | Wire to `httpServer.on('upgrade', …)`. | +| `rpc` | The `RpcServer` — rooms, clients, broadcast. | +| `engine` / `wsServer` | The WebSocket [engine](../../reference/engine) and its connection source. | +| `close()` | Closes the core and the engine. | + +It works with **bare `node:http`** too — `handler` is plain +`(req, res, next)`, with no express API involved: + +```js +const server = http.createServer((req, res) => { + wrpc.handler(req, res, () => { + res.writeHead(404).end(); + }); +}); +server.on('upgrade', wrpc.upgrade); +``` + +::: info express is injected, never depended on +`express` is a devDependency here, used only by the adapter tests. This +middleware is an ordinary function the app mounts; nothing under `src/` ever +`require`s a framework. +::: + +## Middleware semantics + +A request **outside** `basePath` is passed to `next()` rather than answered +with a 404 — wrpc composes with the rest of your app instead of swallowing its +routes. That is the whole difference from the batteries-included +[`Server`](../server), which owns every request that reaches it. + +`req.originalUrl` is used when present, so mounting under a prefix works: + +```js +app.use('/v1', wrpc.handler); // with basePath: '/v1/api' +``` + +## Body parsing + +Both orders work. If a parser upstream already drained the stream +(`express.json()`), the middleware uses `req.body`; otherwise it reads the +stream itself, capped at `maxBodySize` (10 MiB by default): + +```js +app.use(express.json()); // optional +app.use(wrpc.handler); +``` + +An oversized or unreadable body is answered with a `400` error packet. + +## Options + +Everything [`RpcServer`](../server#rpc-options) takes, plus: + +| Option | Default | Meaning | +| --- | --- | --- | +| `rpc` | — | Reuse an existing core instead of building one from `router`. | +| `engine` | `createNodeEngine(ws)` | Must be a **hosted** engine. | +| `ws` | `{}` | Forwarded to the engine's `attach()`. | +| `maxBodySize` | 10 MiB | Cap for bodies this adapter reads itself. | + +::: warning A standalone engine cannot be used here +[uWebSockets.js](./uws) owns its whole network stack, including `node:http` — +there is no express listener for it to attach to. `createWrpc({ engine })` +throws a clear error rather than half-working. Use +`new Server({ engine: createUwsEngine(...) })` instead. +::: + +## Upgrades on a listener you do not own + +`wrpc.upgrade` is the manual-handshake path. The engine is attached with **no** +server, so it binds to nothing and performs one handshake per call — which is +what lets it share a listener with everything else the app does with +`'upgrade'`. If you have several WebSocket consumers, route by path first: + +```js +httpServer.on('upgrade', (req, socket, head) => { + if (req.url.startsWith('/api')) return wrpc.upgrade(req, socket, head); + other.handleUpgrade(req, socket, head); +}); +``` + +## Shutdown + +```js +await wrpc.close(); +httpServer.closeAllConnections(); +await new Promise((resolve) => httpServer.close(resolve)); +``` + +The listener is yours, so closing it is yours too — `wrpc.close()` only +releases what wrpc opened. diff --git a/docs/guide/adapters/fastify.md b/docs/guide/adapters/fastify.md new file mode 100644 index 0000000..c362c91 --- /dev/null +++ b/docs/guide/adapters/fastify.md @@ -0,0 +1,221 @@ +# Fastify + +```js +const fastify = require('fastify')(); +const { wrpcFastify } = require('@alexify/wrpc/fastify'); + +await fastify.register(wrpcFastify, { router }); +await fastify.listen({ port: 8000 }); +``` + +That is the whole integration. The plugin registers the RPC HTTP routes under +`basePath`, attaches a WebSocket engine to whatever fastify is running on, and +decorates the instance with `fastify.wrpc` — the `RpcServer` — so handlers +outside wrpc can reach rooms and clients: + +```js +fastify.get('/admin/announce', async (request, reply) => { + fastify.wrpc.broadcast('system/announce', { text: 'maintenance in 5m' }); + return { ok: true }; +}); +``` + +::: info fastify is injected, never depended on +`fastify` is a devDependency here, used only by the adapter tests. The plugin +receives the instance from `register()` and duck-types it, so nothing under +`src/` ever `require`s a framework. +::: + +## Two backends, detected + +One plugin covers both ways of running fastify: + +| What fastify runs on | Engine | +| --- | --- | +| a real `http.Server` (plain `fastify()`) | the built-in [node engine](../../reference/engine), attached to its `upgrade` event | +| a `fastify-uws` server factory | the [uWebSockets.js engine](./uws) over the same uws app | + +```js +const { serverFactory } = require('fastify-uws'); +const app = require('fastify')({ serverFactory }); + +await app.register(wrpcFastify, { router }); +``` + +Detection is by feature, not by configuration. Pass `engine` explicitly to skip +it. + +## Options + +Everything [`RpcServer`](../server#rpc-options) takes, plus: + +| Option | Meaning | +| --- | --- | +| `rpc` | Reuse an existing `RpcServer` instead of building one from `router`. | +| `engine` | Skip backend detection. | +| `ws` | Forwarded to the engine's `attach()` — `path`, `protocols`, `verifyClient`, … | +| `maxBodySize` | A per-route `bodyLimit` for the RPC routes. | + +`logger` defaults to `fastify.log`. That is a pino, which wrpc detects as a +[structured logger](../logging) and calls natively — so your RPC entries land +in fastify's own stream, with its bindings, and no adapter in between. + +::: tip `maxBodySize` only narrows +Unlike the express and uws adapters, this plugin never reads the request +stream — fastify parses the body, so its own `bodyLimit` (1 MiB by default) +already guards these routes and answers `413 FST_ERR_CTP_BODY_TOO_LARGE`. Set +`maxBodySize` only to make that stricter; leaving it unset keeps the app's own +limit, because a plugin silently *raising* the host's body limit would be a +security regression the app never asked for. +::: + +## Hooks run first + +HTTP calls arrive through fastify's own routes, so the app's `onRequest`, +`preHandler`, authentication and error handling all run **before** wrpc sees +the call. That is the point of registering as a plugin rather than mounting a +second server: + +```js +fastify.addHook('onRequest', async (request) => { + request.tenant = await resolveTenant(request); +}); + +await fastify.register(wrpcFastify, { router }); +``` + +The routes registered are `POST {basePath}`, `{basePath}/:unit/:method` and +`{basePath}/events` — the SSE endpoint is a static segment on purpose, so +find-my-way prefers it over the parametric route it would otherwise fall into. + +A path outside `basePath` gets **fastify's** 404, not wrpc's error packet. That +difference is the whole reason to compose as a plugin. + +## Declarative REST routes {#declarative-rest-routes} + +A procedure with an [`http` mapping](../rest) becomes a **native fastify +route** under this plugin — not a proxy into the wrpc dispatcher. Fastify +owns routing, schema validation, serialization and (through +@fastify/swagger) documentation; wrpc supplies the per-request `Context` +(session, rooms, client lifecycle) and runs the bare handler under the +procedure's own queue/timeout semantics. The internal API over HTTP is +therefore the **same endpoint external consumers hit**. + +```js +const router = defineRouter({ + projects: { + create: procedure({ + http: { method: 'POST', path: '/projects/:orgId', status: 201 }, + schema: { body: { /* JSON Schema */ }, response: { 201: { /* … */ } }, tags: ['Projects'] }, + handler: async (context, { params, query, body }) => { /* ... */ }, + }), + }, +}); +``` + +`schema` is forwarded to `fastify.route.schema` verbatim (plus wrpc's +[default error responses](../rest#default-error-responses)), so swagger +sees everything. wrpc's lifecycle hooks map onto fastify's phases by name — +the naming was fastify's to begin with — with the payload differences +documented in the adapter source. + +Two things to know: + +- **Validation runs once, in fastify.** The wrpc-side compiled validators + are for the other transports; on this path the fastify pipeline already + ran. +- **Errors answer the wire shape** `{ message, code, details? }` with the + code as the status. Pass `restErrors: 'app'` to keep your app's own error + format instead (the default error responses are then not documented, so + your format survives serialization). + +A collision between a mapped path and an app route under `basePath` makes +find-my-way throw at registration — the correct failure. + +One combination is refused outright: [`codec.rest`](../codec#rest-bodies-codec-rest) +together with delegated REST routes throws at registration. Delegation +exists *for* fastify's serialization, schemas and swagger — a codec-framed +(possibly binary) body would silently bypass `fast-json-stringify` and +`preSerialization`, shipping msgpack from a route that documents JSON. +Serve binary REST from a core host (the node shell, express, uws — all +Buffer-native), or drop the `http` mappings under this plugin; `codec.rest` +without mappings registers normally. + +## Mirroring existing routes {#mirroring-existing-routes} + +The reverse direction: your **existing fastify routes** become wrpc +procedures, without rewriting any of them. + +```js +await fastify.register(wrpcFastify, { + router, + mirror: { + access: 'session', + headers: (context) => ({ authorization: `Bearer ${context.session?.state.token ?? ''}` }), + }, +}); + +// later, from a browser: +await client.load('projects'); +await client.api.projects.create({ params: { orgId: '42' }, body: { name: 'Alpha' } }); +``` + +The generated procedure dispatches through `fastify.inject()` +(light-my-request, no network), so the route's **whole pipeline** — your +onRequest hooks, auth, schema validation, serialization — runs exactly as +for a real request. Route errors flow back with their status, message and +`details`. + +Rules of the collection: + +- Register the plugin **before** the routes it should mirror — collection + happens via an `onRoute` hook. +- **Naming is reverse REST semantics**: `POST /projects` → `create`, + `GET /projects/:id` → `findById`, `GET /projects/slug/:slug` → + `findBySlug`, `GET /projects/archive` → `findAllArchive`, + `POST /projects/:orgId/archive/:id` → `createArchive`; `PATCH` → update, + `PUT` → replace, `DELETE` → delete. The unit is the last static segment + before the first param. Anything the semantics cannot express is what + `config.wrpc` is for: + +```js +fastify.route({ + method: 'DELETE', + url: '/projects/:orgId/archive/:id', + config: { wrpc: { name: 'unarchive' } }, // instead of deleteArchive + handler, +}); + +fastify.get('/internal/health', { config: { wrpc: false } }, handler); // opt out +``` + +- A naming collision throws at `onReady`, naming both routes. +- A derived unit name containing a dot registers as a versioned key, so it + must fit the `unit.vN` syntax — a path segment like `/api.internal/x` + throws loudly at `onReady` instead of being silently split into a version; + give such a route an explicit `config.wrpc.unit`. +- Mirrored procedures carry a `signature` distilled from the route's JSON + Schemas, so [`wrpc types`](../cli) types them; `meta.mirrored` records + the origin. +- Mirrored calls are calls only — no subscriptions or binary streams — and + `fastify.inject` makes this a colder path than a native procedure. + +## Shutdown + +`fastify.close()` is enough: the plugin closes the core and the engine on +fastify's `preClose` hook. + +## Types + +```ts +import fastify from 'fastify'; +import { wrpcFastify } from '@alexify/wrpc/fastify'; +import type { RpcServer } from '@alexify/wrpc'; + +declare module 'fastify' { + interface FastifyInstance { wrpc: RpcServer } +} +``` + +`findUwsApp(server)` is exported too — it digs the uWebSockets.js app out of a +fastify-uws server, and answers `null` for anything else. diff --git a/docs/guide/adapters/uws.md b/docs/guide/adapters/uws.md new file mode 100644 index 0000000..2a1ec21 --- /dev/null +++ b/docs/guide/adapters/uws.md @@ -0,0 +1,125 @@ +# uWebSockets.js + +```js +const { Server } = require('@alexify/wrpc'); +const { createUwsEngine } = require('@alexify/wrpc/uws'); + +const engine = createUwsEngine({ uws: require('uWebSockets.js') }); +const server = new Server({ router, engine, host: '127.0.0.1', port: 8000 }); + +await server.listen(); +``` + +Everything above the engine is unchanged: the same router, the same clients, +the same wire protocol. What changes is who owns the socket. + +::: info uWebSockets.js is injected, never depended on +It is not a dependency, not a peer dependency and not an optional dependency — +you `require` it and hand it over. Here it is a devDependency used only by the +adapter tests, pinned to a git tag. The adapter validates the injection +structurally (`app.ws`, `app.any`, `app.listen`), never with `instanceof`. +::: + +## Standalone: uws owns everything + +This is a **standalone** engine. uws owns the whole network stack, `node:http` +included, so the `Server` shell creates no HTTP server at all and routes its +HTTP calls into uws through the engine's `onHttpCall`. + +Two consequences worth remembering: + +- **`server.httpServer` is `null`.** Read the bound address through + `server.address()`, which answers for both engine kinds. +- **It cannot be used with the [express adapter](./express)**, which needs a + hosted engine to attach to a listener the app owns. `createWrpc({ engine })` + refuses one with a clear error rather than half-working. + +For fastify, use [`fastify-uws`](./fastify#two-backends-detected) — the plugin +detects it and puts this engine over the same uws app. + +## Options + +```js +createUwsEngine({ + uws: require('uWebSockets.js'), + ssl: { key_file_name: 'key.pem', cert_file_name: 'cert.pem' }, + idleTimeout: 120, + maxPayloadLength: 16 * 1024 * 1024, + maxBackpressure: 64 * 1024, + compression: null, +}); +``` + +| Option | Default | Meaning | +| --- | --- | --- | +| `uws` | — | The injected module. | +| `app` | — | An existing uws app to attach to, instead of creating one. | +| `ssl` | — | Passed to `SSLApp()` instead of `App()`. | +| `idleTimeout` | `120` | Seconds without traffic before uws closes the peer. | +| `maxPayloadLength` | 16 MiB | Largest inbound message. | +| `maxBackpressure` | uws default | Outbound buffer cap. | +| `closeOnBackpressureLimit` | uws default | Close instead of dropping. | +| `compression` | `null` | A uws compressor constant; `null` disables permessage-deflate. | +| `sendPingsAutomatically` | uws default | uws' own protocol ping. | +| `maxBodySize` | 10 MiB | Cap for HTTP bodies the engine reads. | + +## Capabilities + +Engines advertise what they provide, and this one differs from the built-in +engine in three ways: + +```js +engine.capabilities; +// { backpressure: true, ping: false, deflate: Boolean(compression), cork: true, pause: false } +``` + +- **`ping: false`** — uws owns peer liveness itself, through `idleTimeout` and + `sendPingsAutomatically`, so wrpc runs no protocol-ping loop over it. The + client's [application-level heartbeat](../client#heartbeat) is unaffected and + still works. +- **`deflate`** follows `compression`, which is off by default. +- **`pause: false`** — uws exposes no socket-level pause, so receive-side flow + control is missing: a fast uploader is not throttled by a slow + [stream](../streams) consumer the way it is on the built-in engine. Outbound + backpressure works normally. + +See [the engine reference](../../reference/engine) for the full contract. + +## Backpressure + +uws' `send()` answers with one of three statuses, and the adapter maps them +onto the boolean contract every wrpc transport speaks: + +| uws status | Meaning | `WrpcSocket.send()` | +| --- | --- | --- | +| `SUCCESS` | sent | `true` | +| `BACKPRESSURE` | queued, above the mark | `false` — wait for `'drain'` | +| `DROPPED` | **discarded** | `false`, plus an `'error'` and a terminate | + +That last row is the one to understand. With `maxBackpressure` reached and +`closeOnBackpressureLimit` off, uws silently discards the message — and a hole +in the frame stream corrupts the RPC protocol, since a caller would wait +forever for an answer that was thrown away. The adapter refuses to continue: +it raises an error and terminates the socket, which the client sees as a +disconnect and recovers from by reconnecting. + +## Stability + +`uWebSockets.js` is distributed from git rather than npm and ships a native +addon, so the version you get depends on your platform having a matching +build. The adapter tests **skip** (never fail) when the binary is unavailable, +and CI pins the tag. + +One trap worth knowing if you mix it with `fastify-uws`: two different +`uWebSockets.js` builds loaded in one process cannot coexist — the process +segfaults during environment cleanup, after your assertions have already +passed. `tests/adapters/boots.js` seeds the module cache so both resolve to one +binary; do the same if you hit it. + +## Contract-tested + +The [engine contract suite](../../reference/engine#contract-testing) +(`tests/engine/engineContract.js`) runs against this engine and the built-in +one alike — handshake, echo, binary, backpressure booleans, drain ordering, +close codes, ping/pong. Anywhere the two genuinely differ, the difference is +asserted rather than papered over. diff --git a/docs/guide/auth.md b/docs/guide/auth.md new file mode 100644 index 0000000..f108ab1 --- /dev/null +++ b/docs/guide/auth.md @@ -0,0 +1,82 @@ +# Authentication + +wrpc's auth story is three seams and one subpath that pre-wires them. This +page is the map; the deep material lives where each seam is defined — +[Sessions](./sessions) for the server half, the +[client guide](./client#authenticating) for the hooks. + +## The lifecycle + +```mermaid +sequenceDiagram + participant C as client + participant S as server + C->>S: connect (credential rides the carrier) + Note over C: authenticate hook — awaited,
BEFORE subscriptions restore + S-->>C: session restored (or signIn runs) + C->>S: re-subscribe / load — already authorized + Note over C,S: … credential expires mid-session … + S-->>C: 401/403 on a call or a re-subscribe + Note over C: refresh hook — single-flight,
one re-issue per caller +``` + +Three client options make it work end to end: + +| Option | When it runs | What it is for | +| --- | --- | --- | +| [`headers`](./metadata#declared-headers-the-headers-client-option) | resolved on **every** open | presents the stored credential, so the server restores the session before any packet is dispatched | +| [`authenticate`](./client#authenticating) | awaited on first connect and on every reconnect, **before** the restore re-sends subscriptions | signs in when there is nothing to present | +| [`refresh`](./client#refreshing-a-credential) | on a refusal with a listed code (calls **and** re-subscribes) | single-flight credential renewal with a one-shot retry | + +## The server half: token carriers + +*Where* the token lives on the wire is the injected +[`sessions.transport`](./sessions#pluggable-token-carriers) — the HttpOnly +cookie is the byte-identical default. Two ready-made strategies ship in +**`@alexify/wrpc/auth`**: + +- **`bearerTransport()`** — `Authorization: Bearer ` on http/sse; on + browser ws the token rides a `wrpc.bearer.` **subprotocol offer**, + a real upgrade header, so it never lands in the connect URL or the access + logs that keep URLs. +- **`payloadTransport({ field })`** — a field of the declared + [`meta`](./metadata) bag, either `x-wrpc-meta` spelling. + +Both are non-ambient (`ambient: false`): the credential is script-attached, +so the safe-method CSRF rule the cookie needs does not apply to them. + +## The client half: stores and `bearerAuth()` + +The same subpath carries token **stores** (`get`/`set`/`delete`, sync or +async — a `Map` qualifies): `memoryStore()`, `webStorage(localStorage)`, +`cookieStorage(document)` (Secure by default). `bearerAuth()` composes a +store with your `signIn`/`refresh` calls into the three options above: + +```js +const { bearerAuth, webStorage } = require('@alexify/wrpc/auth'); + +const client = await connect(url, { + ...bearerAuth({ + store: webStorage(localStorage), + signIn: (c) => c.call('auth/signIn', credentials()), + refresh: (c, tokens) => c.call('auth/refresh', { token: tokens.refresh }), + }), +}); +``` + +See [the full walk-through](./sessions#the-client-half-stores-and-bearerauth) +for the store contract, rotation and the server procedures these calls pair +with. + +## What heals what + +| Failure | What handles it | +| --- | --- | +| First connect, nothing stored | `authenticate` → `signIn` | +| Reconnect with a live stored token | the carrier restores the session before the re-subscribe — no hook code runs | +| A call refused 401/403 mid-session | `refresh`, single-flight; the call re-issues once | +| A **re-subscribe** refused after a long outage | the same `refresh`, then the subscription re-opens once — feeds heal exactly like calls | +| The refresh's own call refused | surfaces its refusal (never deadlocks the run it belongs to); a **throwing** refresh clears the store, so the next connect signs in fresh | + +Observability: a failing run logs `refresh.failed`, emits `'refresh-failed'` +and counts on `wrpc.client.refreshes` — see [Telemetry](./telemetry). diff --git a/docs/guide/browser.md b/docs/guide/browser.md new file mode 100644 index 0000000..9e16ab1 --- /dev/null +++ b/docs/guide/browser.md @@ -0,0 +1,148 @@ +# Browser & bundling + +The same protocol implementation runs in Node.js and in a browser — not a +reimplementation, not a subset of the wire format. What differs is which +*files* the bundler picks, and this page is about that machinery: how the swap +happens, what lands in your bundle, and what is deliberately absent. + +## Nothing to configure + +```js +import { WrpcClient, connect } from '@alexify/wrpc'; + +const client = await connect('wss://api.example/api'); +await client.load('chat'); +``` + +Any bundler that honours the `browser` field or the `browser` export condition +resolves that import to the browser build automatically: webpack, Vite, esbuild +with `platform: 'browser'`, Rollup (`@rollup/plugin-node-resolve` with +`browser: true`), Parcel, Bun. + +## What gets swapped + +Two mechanisms, both declared in `package.json`: + +| Node file | Browser file | Why | +| --- | --- | --- | +| `index.js` | `browser.js` | The browser barrel excludes the server half entirely. | +| `sse.js` | `sse.browser.js` | A browser needs the [SSE](./sse) client transport, never the channel registry. | +| `src/chunks.js` | `src/chunks.browser.js` | `Buffer` vs `TextEncoder`/`TextDecoder` for [binary framing](./streams). | +| `src/runtime/node.js` | `src/runtime/browser.js` | `node:crypto` vs `globalThis.crypto` for id generation. | + +The `exports` map carries the same choices under the `browser` condition, so +resolvers that read `exports` and ignore the legacy `browser` field get the +identical result. + +## What is in the browser entry + +```js +Emitter, WrpcClient, WrpcClientProxy, WrpcError, connect, +WrpcReadable, WrpcWritable, chunkEncode, chunkDecode, +EventStream, createEventStream +``` + +That is the whole runtime surface — and it contains **no Node builtins**, so +nothing pulls in a polyfill for `node:http`, `node:crypto`, `node:zlib` or +`Buffer`. + +::: warning Server exports are not in the browser build +`Server`, `RpcServer`, `defineRouter`, `procedure`, `RoomRegistry`, +`MemorySessionStore`, `ServerTransport` and friends exist only in the Node +entry. Importing one in browser code is a build error, not a runtime surprise — +which is the point of splitting the barrels rather than tree-shaking one. +::: + +### Types without `@types/node` + +`browser.d.ts` re-exports `client.d.ts` and nothing else, so browser consumers +never inherit the Node type dependencies that `index.d.ts` carries. That split +is why a front-end `tsconfig` with no `"types": ["node"]` type-checks cleanly +against `@alexify/wrpc`. + +Note the asymmetry: the browser *types* include wire-packet interfaces and +other type-only declarations that have no runtime counterpart. Types are +erased; imports are not. + +## Bundle size + +Measured with `pnpm size`, which bundles each entry with esbuild and reports +min+gzip. Browser-reachable entries carry a **budget**, and exceeding one fails +the run — it is a ratchet, and it runs in CI's lint job. + +| Entry | min | min+gzip | budget | +| --- | ---: | ---: | ---: | +| `@alexify/wrpc` — browser | 44.3 KB | **14.9 KB** | 15.0 KB | +| `@alexify/wrpc/sse` — browser | 47.2 KB | **15.9 KB** | 16.0 KB | +| `@alexify/wrpc/query` | 2.7 KB | **1.1 KB** | 2.0 KB | +| `@alexify/wrpc/auth` | 3.3 KB | **1.5 KB** | 2.0 KB | +| `@alexify/wrpc` — node | 158.2 KB | 52.2 KB | — | + +The Node-only entries carry no budget because their gzip size is not a shipping +cost; they are measured so a regression is *visible*, not gated. + +The SSE entry is the browser entry **plus** the SSE transport — you pay the +extra ~1 KB only if you import it. +[`@alexify/wrpc/query`](./query) requires nothing at all (that is what keeps it +at 1 KB); it takes the client and your `QueryClient` by injection. + +::: tip Raising a budget +A budget is a ratchet with a documented escape hatch: if bytes are genuinely +earned, raise the number in `scripts/size.js` in the same change, with a +comment saying why. +::: + +## Transports in a browser + +| Transport | Use it when | +| --- | --- | +| `ws` (default) | Normal. Full duplex, binary streams, everything on this site. | +| `http` | One-shot calls with no connection — no events, no subscriptions, no streams. | +| `sse` | WebSockets are blocked by a proxy or corporate network. Text only. | +| `event` | The connection lives in a Service Worker; the page talks over a `MessagePort`. | + +`WrpcClient.transport` is a plain lookup table on purpose, and a subpath +entrypoint registers into it at require time — which is how `@alexify/wrpc/sse` +adds `'sse'` without the core knowing it exists. Registering your own works the +same way. + +## Service Workers and offline + +The `event` transport is how one socket serves every tab and survives a page +reload: the worker holds the connection, the page talks to the worker. + +```js +// in the Service Worker +const proxy = new WrpcClientProxy({ callTimeout: 7000 }); +await proxy.open(); + +// in the page +const client = await WrpcClient.connect(url, { worker: navigator.serviceWorker.controller }); +``` + +The packets are identical on both hops, so nothing above the transport changes. +See [Client → Service Workers](./client#service-workers). + +The client also listens to `online`/`offline`: going offline stops the +reconnect timer instead of burning retries, and coming back reconnects +immediately rather than waiting out the backoff. + +## Frameworks + +The client is framework-agnostic and has no React/Vue/Svelte bindings by +design. For data fetching, [`@alexify/wrpc/query`](./query) returns TanStack +Query **option factories** — not hooks — so one 1 KB file serves React, Solid, +Svelte and Vue Query alike. + +One thing to know when wiring a framework: `client.api` does not exist until +`load()` resolves, and it is **rebuilt on every reconnect**. Resolve paths +lazily inside the query function rather than capturing `client.api.chat.list` +at module scope — which is exactly what the query bindings do. + +## CommonJS in an ESM world + +The package is CommonJS and ships without a build step. Every modern bundler +handles that, and `import { WrpcClient } from '@alexify/wrpc'` works through +interop. If you are running a bundler-free ` diff --git a/examples/chat/server.js b/examples/chat/server.js new file mode 100644 index 0000000..aae90d8 --- /dev/null +++ b/examples/chat/server.js @@ -0,0 +1,29 @@ +'use strict'; + +const { Server, defineRouter, procedure } = require('../../index.js'); + +const router = defineRouter({ + chat: { + join: procedure({ + access: 'public', + handler: async (context, { room }) => { + context.client.join(room); + return { ok: true }; + }, + }), + shout: procedure({ + access: 'public', + handler: async (context, { room, text }) => { + context.server.to(room).emit('chat/message', { text }); + return { ok: true }; + }, + }), + }, +}); + +const server = new Server({ router, host: '127.0.0.1', port: 8000, protocol: 'http' }); + +server.listen().then(() => { + console.log('wrpc chat example listening on http://127.0.0.1:8000/api'); + console.log('run `node build.js` once, then open public/index.html in a browser'); +}); diff --git a/express.d.ts b/express.d.ts new file mode 100644 index 0000000..b1160f3 --- /dev/null +++ b/express.d.ts @@ -0,0 +1,39 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Duplex } from 'node:stream'; +import type { Engine, EngineAttachOptions, EngineConnectionSource } from './engine.js'; +import type { RpcServer, RpcServerOptions } from './index.js'; + +export interface CreateWrpcOptions extends Partial { + /** Reuse an existing core instead of constructing one from `router`. */ + rpc?: RpcServer; + /** Must be a hosted engine — standalone engines own their own listener. */ + engine?: Engine; + /** Forwarded to the engine's attach() (path, protocols, verifyClient, ...). */ + ws?: Omit; + /** Cap for bodies this adapter reads itself. Default 10 MiB. */ + maxBodySize?: number; +} + +export interface WrpcMiddleware { + rpc: RpcServer; + engine: Engine; + wsServer: EngineConnectionSource; + /** + * express/connect middleware. Requests outside `basePath` fall through to + * next() instead of 404 — wrpc composes with the app's other routes. + */ + handler(req: IncomingMessage, res: ServerResponse, next: () => void): void; + /** Wire to `httpServer.on('upgrade', wrpc.upgrade)`. */ + upgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void; + close(): Promise; +} + +/** + * Builds an express/connect (or bare node:http) integration: nothing here + * owns the listener, the app does. + * + * const wrpc = createWrpc({ router }); + * app.use(wrpc.handler); + * app.listen(8000).on('upgrade', wrpc.upgrade); + */ +export declare function createWrpc(options?: CreateWrpcOptions): WrpcMiddleware; diff --git a/express.js b/express.js new file mode 100644 index 0000000..8f3cdda --- /dev/null +++ b/express.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./src/adapters/express.js'); diff --git a/fastify.d.ts b/fastify.d.ts new file mode 100644 index 0000000..db305ea --- /dev/null +++ b/fastify.d.ts @@ -0,0 +1,98 @@ +import type { Engine, EngineAttachOptions } from './engine.js'; +import type { Context, RpcServer, RpcServerOptions, WrpcLogger } from './index.js'; +import type { UwsApp } from './uws.js'; + +/** + * Minimal structural view of the fastify instance the plugin needs. Typed + * this way so `fastify` stays a devDependency-only type: the plugin never + * imports it at runtime. + */ +export interface FastifyLike { + server: unknown; + /** + * fastify's logger, used as the core's logger when `logger` is not passed. + * fastify's default is a pino, which the core detects as structured and + * calls natively — no adaptation in between. + */ + log?: WrpcLogger; + /** + * Loosely typed on purpose: fastify's own RouteOptions is generic over the + * server/request/reply/schema quintet, so a concrete parameter type here + * would stop real FastifyInstance values from matching this view. + */ + route(options: any): unknown; + decorate(name: string, value: unknown): unknown; + addHook(name: string, hook: (...args: any[]) => unknown): unknown; + /** Used by the mirror feature to dispatch through the route pipeline. */ + inject?(options: any): Promise; +} + +export interface WrpcFastifyOptions extends Partial { + /** Reuse an existing core instead of constructing one from `router`. */ + rpc?: RpcServer; + /** Skips backend detection. */ + engine?: Engine; + /** Forwarded to the engine's attach() (path, protocols, verifyClient, ...). */ + ws?: Omit; + /** + * Per-route `bodyLimit` for the RPC routes. Unlike the express and uws + * adapters this plugin never reads the request stream — fastify parses the + * body, so its own `bodyLimit` (1 MiB by default) already guards these + * routes and answers `413 FST_ERR_CTP_BODY_TOO_LARGE`. Set this only to + * narrow that limit; leaving it unset keeps the app's own. + */ + maxBodySize?: number; + /** Mirror the app's own routes as wrpc procedures; `true` takes defaults. */ + mirror?: MirrorOptions | boolean; + /** + * Error format on delegated REST routes: 'wrpc' (default) answers the wire + * error object `{ message, code, details? }`; 'app' leaves errors to the + * app's own fastify error handling. + */ + restErrors?: 'wrpc' | 'app'; +} + +/** + * Fastify plugin. Registers the RPC HTTP routes under `basePath` and + * attaches a WebSocket engine chosen from what fastify runs on: a real + * node http.Server gets the built-in engine, a fastify-uws server gets the + * uWebSockets.js engine over the same app. + * + * Decorates the instance with `wrpc` (the RpcServer) and closes it on + * fastify's `preClose` hook. + */ +export declare function wrpcFastify( + fastify: FastifyLike, + options?: WrpcFastifyOptions, +): Promise; + +/** A route as the mirror's filters and naming callbacks see it. */ +export interface MirroredRoute { + method: string; + url: string; + schema: object | null; + config: Record; +} + +/** + * Reverse engineering: existing fastify routes become wrpc procedures, + * dispatched through `fastify.inject()` so the route's whole pipeline + * (hooks, auth, validation, serialization) keeps running. Routes registered + * AFTER the plugin are collected; per-route `config.wrpc` refines + * (`{ unit, name }`) or opts out (`false`). + */ +export interface MirrorOptions { + /** Extra filter on top of the defaults (own routes, HEAD/OPTIONS, wildcards). */ + include?(route: MirroredRoute): boolean; + /** Overrides the default unit (the last static segment before the first param). */ + unit?(route: MirroredRoute): string | undefined; + /** Overrides the reverse-REST default name (create/findAll/findById/...). */ + name?(route: MirroredRoute): string | undefined; + /** Maps the wrpc context into headers for the injected request (auth). */ + headers?(context: Context): Record | Promise>; + /** Access level of the generated procedures. Default 'session'. */ + access?: 'public' | 'session'; +} + +/** Digs the uWebSockets.js app out of a fastify-uws server; null otherwise. */ +export declare function findUwsApp(server: unknown): UwsApp | null; diff --git a/fastify.js b/fastify.js new file mode 100644 index 0000000..d179d81 --- /dev/null +++ b/fastify.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./src/adapters/fastify.js'); diff --git a/index.d.ts b/index.d.ts index 23e0dae..2655fd2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,435 +1,1192 @@ -import { EventEmitter } from 'node:events'; import { IncomingMessage, Server as HttpServer, ServerResponse, } from 'node:http'; -import { Server as HttpsServer } from 'node:https'; -import { Socket } from 'node:net'; -import { Writable } from 'node:stream'; +import type { Connection } from './ws.js'; +import type { Engine, EngineConnectionSource, WrpcSocket, EngineAttachOptions } from './engine.js'; +import type { Backplane } from './scaling.js'; +import { + Emitter, + WrpcError, + WrpcReadable, + WrpcWritable, + WrpcLogger, + WrpcLogWriter, + WrpcTelemetryOptions, + WrpcCodec, +} from './client.js'; + +// The browser-safe half of the surface lives in client.d.ts (which is what +// the `browser` types condition serves); this file is that plus the server. +export * from './client.js'; -export declare class Emitter { - constructor(options?: { maxListeners?: number }); - emit(eventName: PropertyKey, value?: unknown): Promise; - on(eventName: PropertyKey, listener: (value: any) => void): void; - once(eventName: PropertyKey, listener: (value: any) => void): void; - off(eventName: PropertyKey, listener?: (value: any) => void): void; - clear(eventName?: PropertyKey): void; - listeners(eventName: PropertyKey): Array<(value: any) => void>; - listenerCount(eventName: PropertyKey): number; - eventNames(): Array; +// --------------------------------------------------------------------------- +// Router / procedures + +export type EventName = PropertyKey; + +export interface State { + [key: string]: unknown; } -export class WrpcError extends Error { - code: number; - constructor(options: { message: string; code: number }); +/** + * A validator is a plain `(value) => value | throws` function (returning + * undefined keeps the original value) or a Standard Schema object. + */ +export type Validator = + | ((value: T) => T | undefined | Promise) + | { '~standard': { validate(value: unknown): unknown } }; + +/** + * One handler signature for both kinds, so a bare function still gets its + * parameters contextually typed. A call ignores the third argument; a + * subscription (an async generator) reads `lastEventId` and `signal` from it. + */ +export type ProcedureHandler = ( + context: Context, + args: any, + subscription: SubscriptionOptions, +) => unknown | Promise | AsyncIterable; + +/** A handler that answers with a stream of values. */ +export type SubscriptionHandler = ProcedureHandler; + +export interface QueueOptions { + concurrency: number; + size?: number; + timeout?: number; } -export interface ReadableOptions { - highWaterMark?: number; +/** + * One node of a {@link Signature}: a type name (`'string'`, `'number[]'`, + * `'string|null'`), a field map whose keys may end in `?`, or a one-element + * array meaning "an array of that". Deliberately closed — it crosses the wire + * and ends up in a file someone compiles, so `wrpc types` renders anything it + * does not recognise as `unknown`. See docs/reference/protocol.md. + */ +export type SignatureShape = string | [SignatureShape] | { [field: string]: SignatureShape }; + +/** + * What a procedure looks like, for codegen. Not validation — `input`/`output` + * are what enforce anything. + */ +export interface Signature { + args?: SignatureShape; + /** A call's result. Ignored on a subscription, which yields `data`. */ + returns?: SignatureShape; + /** A subscription's value. Ignored on a call, which answers `returns`. */ + data?: SignatureShape; } -export class WrpcReadable extends Emitter { +// --------------------------------------------------------------------------- +// Subscriptions + +/** A value labelled with the id a resuming client will send back. */ +export interface Tracked { id: string; - name: string; - size: number; - queue: Array; - streaming: boolean; - status: string; - bytesRead: number; - highWaterMark: number; - constructor( - id: string, - name: string, - size: number, - options?: ReadableOptions, - ); - push(data: ArrayBufferView): Promise; - finalize(writable: Writable): Promise; - pipe(writable: Writable): Writable; - toBlob(type?: string): Promise; - close(): Promise; - terminate(): Promise; - read(): Promise; - [Symbol.asyncIterator](): AsyncIterableIterator; + data: T; } -export interface Transport { - send(obj: object): void; - write(data: string | ArrayBufferView): void; -} +/** Labels one yielded value so a reconnect can resume after it. */ +export declare function tracked(eventId: string | number, data: T): Tracked; -export class WrpcWritable extends Emitter { - id: string; - name: string; - size: number; - transport: Transport; - constructor(id: string, name: string, size: number, transport: Transport); - write(data: ArrayBufferView): boolean; - end(): void; - terminate(): void; +export declare function isTracked(value: unknown): value is Tracked; + +/** + * A bounded replay buffer. `since()` answers with what a client missed — + * or `null` when the id has fallen out of the buffer, so a caller can + * choose between a snapshot and an error instead of silently skipping a gap. + */ +export declare class EventLog { + /** Which log incarnation mints this log's ids (the `.` prefix). */ + readonly epoch: string; + constructor(options?: { size?: number; start?: number }); + readonly size: number; + readonly length: number; + readonly lastEventId: string | null; + push(data: T): string; + since(lastEventId?: string | null): Array> | null; + clear(): void; } -export interface BlobUploader { - id: string; - upload(): Promise; +/** + * Ids are `.`: random epoch per instance by default, so a + * lastEventId from another process (or a restart) is a foreign epoch and + * since() answers null — an honest "cannot resume" — instead of a numeric + * coincidence. A persisted/shared log passes its own stable `epoch`. + */ +export declare function createEventLog(options?: { + size?: number; + start?: number; + epoch?: string; +}): EventLog; + +export interface SubscriptionOptions { + /** What the client says it last saw; undefined on a fresh subscribe. */ + lastEventId?: string; + /** Aborted on unsubscribe or disconnect. Honour it, or the feed leaks. */ + signal: AbortSignal; } -export class ClientTransport extends Emitter { - url: string; - active: boolean; - constructor(url: string); - open(options?: WrpcClientOptions): Promise; - close(): void; - send(obj: object): void; - write(data: string | ArrayBufferView): void; - online(): void; - offline(): void; -} - -export class WrpcClient extends Emitter { - static connections: Set; - static isOnline: boolean; - static online(): void; - static offline(): void; - static initialize(): void; - static connect( - url: string, - options?: WrpcClientOptions, - ): Promise; - static transport: { - ws: new (url: string) => Transport; - http: new (url: string) => Transport; - event: { - getInstance(url: string): Transport; - }; - }; - url: string; - api: Record; - readonly active: boolean; +/** + * A lifecycle hook: named phases, fastify-style, with no `next`. A hook + * runs and either returns (the pipeline continues) or throws an error whose + * numeric `code` becomes the wire code. "After" is a later phase, not code + * after a next() call. The payload depends on the phase: the packet for + * onRequest/onSend/onResponse/onSubscribe, the args for + * preValidation/preHandler, the result for preSerialization (returning a + * value replaces it), the error for onError/onTimeout, the terminal packet + * for onUnsubscribe. + */ +export type Hook = (context: Context, payload: unknown) => unknown | Promise; +/** + * Connection lifecycle hook; observational and contained. `onConnect` + * receives null; `onDisconnect` receives `{ rooms }` — a snapshot of the + * client's rooms taken before destroy() emptied the registry. + */ +export type ConnectionHook = (client: Client, payload: { rooms: Set } | null) => unknown | Promise; - constructor(url: string, transport: Transport, options?: WrpcClientOptions); - open(): Promise; - close(): void; - load(...units: Array): Promise; - getStream(id: string): WrpcReadable | WrpcWritable; - createStream(name: string, size: number): WrpcWritable; - createBlobUploader(blob: Blob): BlobUploader; - send(obj: object): void; - write(data: string | ArrayBufferView): void; +/** The phases a router (or a unit's reserved `hooks` key) may register. */ +export interface RouterHooks { + onRequest?: Hook | Array; + preValidation?: Hook | Array; + preHandler?: Hook | Array; + preSerialization?: Hook | Array; + onSend?: Hook | Array; + onResponse?: Hook | Array; + onError?: Hook | Array; + onTimeout?: Hook | Array; + onSubscribe?: Hook | Array; + onUnsubscribe?: Hook | Array; + /** Router-level only. */ + onConnect?: ConnectionHook | Array; + /** Router-level only. */ + onDisconnect?: ConnectionHook | Array; } -export interface WrpcClientOptions { - callTimeout?: number; - reconnectTimeout?: number; - worker?: ServiceWorker; - packetHandler?: (data: string) => void | Promise; - binaryHandler?: ( - input: ArrayBuffer | ArrayBufferView | Blob, - ) => void | Promise; +/** The subset a unit's reserved `hooks` key accepts (no connection phases). */ +export type UnitHooks = Omit; + +/** A declarative REST mapping: this procedure IS `method path` under basePath. */ +export interface HttpRoute { + method: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + /** Relative to the server's basePath; segments are static or `:name`. */ + path: string; + /** Success status; 204 discards the result body by contract. */ + status?: number; } -export class WrpcClientProxy extends Emitter { - constructor(options?: WrpcClientOptions); - open(): Promise; - close(): void; +/** + * The fastify.route.schema shape: the parts wrpc understands plus any + * passthrough keys (tags, summary, security, ...) forwarded to hosts + * verbatim. `query` and `querystring` are interchangeable spellings. + */ +export interface ProcedureSchema { + params?: object; + querystring?: object; + /** Alias of `querystring`; setting both to different objects throws. */ + query?: object; + body?: object; + headers?: object; + /** Keyed by status code; `false` removes a default wrpc error entry. */ + response?: Record; + [passthrough: string]: unknown; } -export interface ApplicationContext { - console: Console; - auth: Auth; - static: { - constructor: { name: string }; - serve(path: string, transport: Transport): void; - }; - getMethod(unit: string, version: string, methodName: string): object | null; +export interface ProcedureOptions { + handler: ProcedureHandler; + access?: 'public' | 'session'; + input?: Validator; + output?: Validator; + /** Maps this procedure onto a real REST endpoint. Calls only. */ + http?: HttpRoute; + /** Declarative validation/serialization/docs; excludes input/output. */ + schema?: ProcedureSchema; + /** Milliseconds; the call fails with code 408 when exceeded. */ + timeout?: number; + /** Concurrency limit; overflow/starvation fails with code 503. */ + queue?: QueueOptions; + meta?: Record; + /** Descriptor consumed by `wrpc types`; see {@link Signature}. */ + signature?: Signature; + /** Inferred from an async generator handler; rarely written by hand. */ + kind?: 'call' | 'subscription'; + /** This procedure's own slice of the pipeline. */ + preValidation?: Hook | Array; + preHandler?: Hook | Array; + preSerialization?: Hook | Array; + onError?: Hook | Array; } -export interface Options { - host: string; - port: number; - kind?: 'server' | 'balancer'; - protocol?: string; - ports?: Array; - cors?: { origin: string }; - nagle?: boolean; - key?: string; - cert?: string; - SNICallback?: Function; - timeouts?: { bind: number }; - retry?: number; - websocketPath?: string; +/** Same as ProcedureOptions minus the three a stream cannot mean. */ +export type SubscriptionProcedureOptions = Omit; + +export declare class Procedure { + handler: ProcedureHandler; + access: string; + input: Validator | null; + output: Validator | null; + timeout: number; + meta: Record; + signature: Signature | null; + http: HttpRoute | null; + /** Normalized: `query` folded into `querystring`. */ + schema: ProcedureSchema | null; + kind: 'call' | 'subscription'; + readonly subscription: boolean; + constructor(options: ProcedureOptions); + invoke(context: Context, args: unknown, hooks?: Readonly>>): Promise; + /** + * The host-delegated entry: queue/timeout semantics without hooks or + * validators — the host (a fastify route) already ran its own. + */ + invokeBare(context: Context, args: unknown): Promise; + /** The value stream behind `{type:'subscribe'}`. */ + subscribe( + context: Context, + args: unknown, + options?: Partial, + hooks?: Readonly>>, + ): AsyncIterableIterator; } -export interface ErrorOptions { - id?: string; - error?: Error; +export interface ProcedureFactory { + (options: ProcedureOptions | ProcedureHandler): Procedure; + /** + * Explicit spelling for a subscription — redundant when the handler is an + * async generator (which is detected), required when it is a plain + * function returning an async iterable. + */ + subscription(options: SubscriptionProcedureOptions | SubscriptionHandler): Procedure; } -export interface Auth { - generateToken(): string; - saveSession(token: string, data: object): Promise; - createSession(token: string, data: object, fields?: object): Promise; - readSession(token: string): Promise; - deleteSession(token: string): Promise; - registerUser(login: string, password: string): Promise; - getUser(login: string): Promise; +export declare const procedure: ProcedureFactory; + +export type MethodDefinition = Procedure | ProcedureHandler | ProcedureOptions; + +/** + * Inbound (client -> server) event handlers. An event is a call that never + * answers, so handlers are procedures too: access, input validation and + * queueing all work the same way. + */ +export type EventsDefinition = Record; + +/** + * A unit's methods, plus two reserved keys: `on` holds its inbound event + * handlers and `hooks` its slice of the lifecycle pipeline. Neither is + * usable as a method name. + */ +export interface UnitDefinition { + on?: EventsDefinition; + hooks?: UnitHooks; + [method: string]: MethodDefinition | EventsDefinition | UnitHooks | undefined; } -export type EventName = PropertyKey; +/** + * Unit keys are 'unit' or 'unit.vN' ('auth.v1'); method values are procedures, + * bare handler functions, or procedure option objects. + */ +export type RouterDefinition = Record; -export class Client extends Emitter { - source: string; - session: Session | null; - streams: Map; - error(code: number, options?: ErrorOptions): void; - send(obj: object, options?: { code?: number; method?: string }): void; - createContext(): Context; - emit(name: EventName, data: unknown): Promise; - sendEvent(name: string, data?: unknown): void; - getStream(id: string): WrpcReadable | WrpcWritable; - createStream(name: string, size: number): WrpcWritable; - initializeSession(token: string, data?: object): boolean; - finalizeSession(): boolean; - startSession(token: string, data?: object): boolean; - restoreSession(token: string): boolean; - close(): void; - destroy(): void; +export interface MethodInfo { + access: string; + /** Present only on subscriptions; a client scaffolds a call otherwise. */ + kind?: 'subscription'; + meta?: Record; + signature?: Signature; + /** The declarative REST mapping, when the procedure carries one. */ + http?: HttpRoute; + /** Input schema parts (params/querystring/body) for client pre-validation. */ + schema?: { params?: object; querystring?: object; body?: object }; } -export interface TransportOptions { - headers?: Record; +/** + * Injected JSON Schema compilers, structural: `ajv` is anything with + * compile(schema) -> validateFn (ajv-shaped: boolean answer, `.errors` on + * failure), `serializer` anything with compile(schema) -> (value) -> string + * (fast-json-stringify-shaped). wrpc imports neither. + */ +export interface ValidationOptions { + ajv?: { compile(schema: object): (value: unknown) => boolean }; + serializer?: { compile(schema: object): (value: unknown) => string }; } -export class ServerTransport extends Emitter { - static transport: { - http: typeof ServerHttpTransport; - ws: typeof ServerWsTransport; - event: typeof ServerEventTransport; - }; - source: string; - constructor(source: string); - error(code?: number, options?: ErrorOptions): void; - send(obj: object, code?: number): void; +/** What the injected compilers produced for one procedure. */ +export interface CompiledArtifacts { + input?: Validator; + output?: Validator; + serialize?: (value: unknown) => string; } -export class ServerHttpTransport extends ServerTransport { - req: IncomingMessage; - res: ServerResponse; - headers: Record; +/** + * Router-level REST options. `version: 'path'` maps a versioned unit's + * declared routes under a `/vN` prefix (`auth.v1` + `/auth/signIn` → + * `/v1/auth/signIn`); a function receives the version token (`'v1'`) and the + * declared path and returns the effective path. The default version stays + * unprefixed, and `proc.http` (the declaration) is never mutated. + */ +export interface RestOptions { + version?: 'path' | ((version: string, path: string) => string); +} + +export declare class Router { constructor( - req: IncomingMessage, - res: ServerResponse, - options?: TransportOptions, + definition?: RouterDefinition, + options?: { hooks?: RouterHooks; validation?: ValidationOptions; rest?: RestOptions }, ); - write(data: string | Buffer, httpCode?: number): void; - options(): void; - getCookies(): Record; - sendSessionCookie(token: string): void; - removeSessionCookie(): void; + /** + * Adds a unit after construction (how the fastify mirror lands units + * discovered at onReady). Refuses an already-registered unit key. + */ + addUnit(unitKey: string, definition: UnitDefinition): this; + /** Adds a router-level hook after construction. Returns the router. */ + addHook(name: keyof RouterHooks, fn: Hook | ConnectionHook): this; + /** The flattened pipeline for one procedure (router + unit + procedure). */ + hooksFor(proc: Procedure): Readonly>>; + /** The compiled { input?, output?, serialize? } for one procedure. */ + compiledFor(proc: Procedure): CompiledArtifacts | null; + /** True when any procedure compiled a response serializer. */ + readonly hasSerializers: boolean; + /** Router-level connection lifecycle hooks, consumed by RpcServer. */ + readonly connectionHooks: { onConnect: ReadonlyArray; onDisconnect: ReadonlyArray }; + getProcedure( + unit: string, + version: string | undefined, + method: string, + ): Procedure | null; + /** Handler for an inbound `{ type: 'event' }` packet, if the unit declares one. */ + getEventHandler( + unit: string, + version: string | undefined, + name: string, + ): Procedure | null; + introspect(units?: Array | null, options?: { schemas?: boolean }): Record>; + /** True when at least one procedure declares an `http` mapping. */ + readonly hasRestRoutes: boolean; + /** + * Matches a verb + decoded path segments against the REST table: null + * (unknown path), `{ allowed }` (known path, wrong verb — a 405), or the + * full route. + */ + matchRest( + method: string, + segments: Array, + ): + | { proc: Procedure; unitKey: string; methodName: string; params: Record; http: HttpRoute } + | { allowed: Array } + | null; + /** Every declared REST route — what a host adapter registers natively. */ + restRoutes(): Array<{ unitKey: string; methodName: string; proc: Procedure; http: HttpRoute }>; + /** Returns a NEW router; on collision the other router's procedure wins. */ + merge(other: Router): Router; } -export class ServerWsTransport extends ServerTransport { - connection: Connection; - constructor(req: IncomingMessage, connection: Connection); - write(data: string | Buffer): void; +/** + * The fastify-shaped schema a host receives: the user's declaration with + * wrpc's own lifecycle error statuses documented underneath (overridable, + * removable with `false`). + */ +export declare function effectiveSchema(proc: Procedure): ProcedureSchema; + +export declare function defineRouter( + definition: RouterDefinition, + options?: { hooks?: RouterHooks; validation?: ValidationOptions; rest?: RestOptions }, +): Router; + +// --------------------------------------------------------------------------- +// Sessions + +/** Structural store contract — anything with this shape plugs in. */ +export interface SessionStore { + /** + * Optional sliding expiry: called on restore, because restoring IS active + * use. A store without it keeps absolute TTLs — a valid policy too. + */ + touch?(token: string): Promise | void; + get(token: string): Promise; + set(token: string, data: State): Promise; + delete(token: string): Promise; } -export class ServerEventTransport extends ServerTransport { - port: MessagePort; - constructor(port: MessagePort); - write(data: string | Buffer): void; +export interface MemorySessionStoreOptions { + /** LRU cap; 0 disables it. Default 10000. */ + maxSessions?: number; + /** Entry lifetime in ms; 0 disables expiry. Default 24h. */ + ttl?: number; + now?: () => number; } -export function buildHeaders(cors?: { origin: string }): Record; +/** + * Bounded in-memory store (LRU + TTL). Sessions outlive their connection, + * so production deployments should inject a real store instead. + */ +export declare class MemorySessionStore implements SessionStore { + touch(token: string): Promise; + constructor(options?: MemorySessionStoreOptions); + readonly size: number; + get(token: string): Promise; + set(token: string, data: State): Promise; + delete(token: string): Promise; +} -export interface CallPacket { - type: 'call'; - id: string; - method: string; - args: object; +export declare class Session { + token: string; + state: State; + constructor(token: string, data: State, save?: (data: State) => void); } -export interface StreamPacket { - type: 'stream'; - id: string; +export interface CookieOptions { name?: string; - size?: number; - status?: 'end' | 'terminate'; + path?: string; + httpOnly?: boolean; + secure?: boolean; + sameSite?: 'Strict' | 'Lax' | 'None'; + maxAge?: number | null; } -export class Server { - httpServer: HttpServer; - wsServer: WebsocketServer | null; - constructor(context: ApplicationContext, options: Options); - listen(): Promise; - close(): Promise; +/** + * Where the session token lives on the wire — injected like a codec or a + * logger, checked structurally (`isTokenTransport`). The cookie default + * keeps the behaviour wrpc always had; a bearer or payload strategy is one + * object. + */ +export interface TokenTransport { + /** The token this request presents, or null. */ + read(request: { headers?: Record | undefined>; url?: string }): string | null; + /** + * A Set-Cookie-style response header value to stamp (HTTP transports + * only), or null when the carrier cannot stamp one — a bearer strategy + * returns null and the signIn handler hands tokens back in its result. + */ + write(token: string): string | null; + /** Optional: the deleting stamp. */ + clear?(): string | null; + /** + * True when the BROWSER attaches the credential without script (a + * cookie) — which is what the safe-method CSRF rule exists for. A + * non-ambient carrier is exempt from that rule. + */ + ambient?: boolean; } -export declare const MAGIC: string; -export declare const CLOSE_TIMEOUT: number; - -export declare const OPCODES: { - readonly CONTINUATION: 0x00; - readonly TEXT: 0x01; - readonly BINARY: 0x02; - readonly CLOSE: 0x08; - readonly PING: 0x09; - readonly PONG: 0x0a; -}; - -export declare const CLOSE_CODES: { - readonly NORMAL_CLOSE: 1000; - readonly GOING_AWAY: 1001; - readonly PROTOCOL_ERROR: 1002; - readonly UNSUPPORTED_DATA: 1003; - readonly RESERVED: 1004; - readonly NO_CODE_RECEIVED: 1005; - readonly CONNECTION_CLOSED_ABNORMALLY: 1006; - readonly INVALID_PAYLOAD: 1007; - readonly POLICY_VIOLATED: 1008; - readonly MESSAGE_TOO_BIG: 1009; - readonly MANDATORY_EXTENSION: 1010; - readonly INTERNAL_SERVER_ERROR: 1011; - readonly TLS_HANDSHAKE: 1015; -}; - -export interface VerifyClientInfo { - req: IncomingMessage; - socket: Socket; - head: Buffer; -} - -export interface WebsocketServerOptions { - server: HttpServer | HttpsServer; - pingInterval?: number; - maxBuffer?: number; - closeTimeout?: number; - path?: string; - verifyClient?: (info: VerifyClientInfo) => boolean; +export declare function isTokenTransport(value: unknown): value is TokenTransport; + +export interface SessionsOptions { + store?: SessionStore; + cookie?: CookieOptions; + generateToken?: () => string; + /** The injected token carrier; the cookie default is byte-identical. */ + transport?: TokenTransport; +} + +declare class SessionManager { + store: SessionStore; + generateToken: () => string; + cookie: CookieOptions & { name: string; path: string }; + /** The active token carrier (the cookie default unless injected). */ + transport: TokenTransport; + create(token?: string, data?: State): Session; + restore(token: string): Promise; + destroy(token: string): Promise; + cookieHeader(token: string): string; + cookieDeleteHeader(): string; + readToken(cookies: Record): string | null; } +export type { SessionManager }; -export declare class WebsocketServer extends EventEmitter { - constructor(options: WebsocketServerOptions); +export function createProxy( + data: T, + save?: (data: T) => void, +): T; - on( - event: 'connection', - listener: (ws: Connection, req: IncomingMessage) => void, - ): this; +// --------------------------------------------------------------------------- +// Rooms - on(event: 'error', listener: (error: Error) => void): this; - on(event: 'close', listener: () => void): this; - on(event: string | symbol, listener: (...args: unknown[]) => void): this; +/** + * Named groups of clients, on top of the ordinary `{ type: 'event' }` + * packets. The registry owns both directions — which clients a room holds + * and which rooms a client joined — so a disconnect only has to call + * `leaveAll`. + */ +export declare class RoomRegistry { + constructor(options?: { + /** Fires when a room gains its first member (backplane subscribe). */ + onSubscribe?: (room: string) => void; + /** Fires when a room loses its last member (backplane unsubscribe). */ + onUnsubscribe?: (room: string) => void; + /** Fires on EVERY successful join — the cluster's presence deltas. */ + onJoin?: (room: string, client: Client) => void; + /** Fires on EVERY successful leave. */ + onLeave?: (room: string, client: Client) => void; + }); + /** Number of non-empty rooms. */ + readonly size: number; + list(): Array; + members(room: string): Set; + count(room: string): number; + has(room: string): boolean; + roomsOf(client: Client): Set; + /** True when the client was not already a member. */ + join(client: Client, room: string): boolean; + leave(client: Client, room: string): boolean; + leaveAll(client: Client): void; + clear(): void; } -export interface ConnectionOptions { - isClient?: boolean; - maxBuffer?: number; - closeTimeout?: number; +/** + * An immutable, chainable delivery target: every modifier returns a NEW + * Broadcast, so a stored `server.to('chat')` cannot be mutated by a later + * `.except()` elsewhere. + */ +export declare class Broadcast { + /** + * Union, not intersection: `to('a').to('b')` reaches either room. + * `to()` with no rooms narrows to NOBODY — a computed room list that came + * back empty must not fall back to every connected client. + */ + to(...rooms: Array): Broadcast; + except(...clients: Array): Broadcast; + /** Suppresses the backplane publish; the event stays on this instance. */ + local(): Broadcast; + /** The targeted rooms, or null when the target is every client. */ + readonly rooms: Array | null; + /** + * Sends `{ type: 'event', name, data }` and returns how many clients + * received it LOCALLY — remote instances are reached through the + * backplane, whose delivery this number says nothing about. + */ + emit(name: string, data?: unknown): number; + /** + * Emits to every matching client — every instance's members included, + * unless `local()` — and waits for each one's answer (registered + * client-side with `client.respond(name, fn)`). Never rejects: + * per-client failures are collected in `errors`. + */ + ask(name: string, data?: unknown, options?: AskOptions): Promise; } -export declare class Connection extends EventEmitter { - constructor(socket: Socket, head: Buffer, options?: ConnectionOptions); +export interface AskOptions { + /** Per-client answer timeout in ms; default 7000. */ + timeout?: number; +} - send(data: string | Buffer): boolean; - sendText(message: string): boolean; - sendBinary(buffer: Buffer): boolean; - sendPing(payload?: Buffer | string): boolean; - sendPong(payload?: Buffer | string): boolean; - sendClose(code?: number, reason?: string): boolean; - terminate(): void; +export interface AskResult { + /** Values the responders returned, in settlement order. */ + answers: Array; + /** Per-client failures: 501 no responder, 408 timeout, 503 disconnect. */ + errors: Array<{ message: string; code: number; details?: unknown }>; + /** How many clients were asked, cluster-wide. */ + expected: number; + /** True when a remote instance never answered inside the timeout. */ + incomplete: boolean; +} + +// --------------------------------------------------------------------------- +// Cluster - on( - event: 'message', - listener: (data: Buffer, isBinary: boolean) => void, - ): this; - on(event: 'error', listener: (error: Error) => void): this; - on(event: 'close', listener: () => void): this; - on(event: 'pong', listener: (payload: Buffer) => void): this; +/** Narrows a cluster operation: one room, or every persistent client. */ +export interface ClusterSelector { + room?: string; } -export declare class Frame { - fin: boolean; - rsv: number; - opcode: number; - masked: boolean; - payload: Buffer; - mask: Buffer | null; - constructor( - fin: boolean, - opcode: number, - masked: boolean, - payload: Buffer, - mask: Buffer | null, - rsv?: number, - ); - static text(message: string, fin?: boolean, masked?: boolean): Frame; - static binary(buffer: Buffer, fin?: boolean, masked?: boolean): Frame; - static ping(payload?: Buffer | string): Frame; - static pong(payload?: Buffer | string): Frame; - static close(code?: number | null, reason?: string): Frame; - unmaskPayload(): void; - maskPayload(): void; - toString(): string | null; - toBuffer(): Buffer; - readonly header: Buffer; - readonly isControlFrame: boolean; -} - -export declare class ParseError extends Error { - code: string; - constructor(code: string, message: string); -} - -export declare const PARSE_ERR_CODES: { - readonly MESSAGE_TOO_BIG: 'MESSAGE_TOO_BIG'; - readonly PROTOCOL_ERROR_COMMON: 'PROTOCOL_ERROR-COMMON'; - readonly PROTOCOL_ERROR_RSV: 'PROTOCOL_ERROR-RSV'; - readonly PROTOCOL_ERROR_CTRL_TOO_LONG: 'PROTOCOL_ERROR-CTRL_TOO_LONG'; - readonly INVALID_PAYLOAD: 'INVALID_PAYLOAD'; -}; - -export declare class FrameParser { - static parse(buffer: Buffer): { - value: { frame: Frame; bytesUsed: number } | null; - error: ParseError | null; - }; - static checkControlFrame(frame: Frame): { - value: boolean | null; - error: ParseError | null; - }; - static checkDataFrame(frame: Frame): { - value: boolean | null; - error: ParseError | null; - }; +/** What fetchClients answers, one per matching client, cluster-wide. */ +export interface ClientDescriptor { + id: string; + /** The instance holding the connection. */ + instance: string; + rooms: Array; + /** The application's `client.data` bag. */ + data: Record; + /** 'ws' | 'sse' | 'event'. */ + transport: string; + /** Whether the client currently carries a session. */ + session: boolean; } -export interface State { - [key: string]: unknown; +export interface ClusterOptions { + /** + * How often the corrective presence DIGEST is published; default 5000. + * Raising it also delays eviction (presenceTimeout defaults to 3× it). + */ + presenceInterval?: number; + /** Silence after which a node is evicted; default 3× presenceInterval. */ + presenceTimeout?: number; + /** Backstop for cluster requests (fetchClients, ask); default 2000. */ + requestTimeout?: number; + /** + * Which rooms replicate through presence: an array, predicate or RegExp. + * Excluding high-cardinality families (the per-user `user:` pattern) + * keeps their deltas and digests off the wire entirely. Default: all. + */ + rooms?: Array | ((room: string) => boolean) | RegExp; + /** + * Per-node ceiling on one fetchClients reply; a node over it answers its + * first `maxFetch` descriptors and the result carries `truncated: true`. + * `0` disables. Default 1000. + */ + maxFetch?: number; + /** + * Opt-in HMAC-SHA256 envelope authentication: with the same secret on + * every node, an unsigned or mis-signed cluster message is dropped and + * logged — "can publish on the broker" stops being "can command every + * node". Room events travel unsigned; ACL the broker for those. + */ + secret?: string; } -export declare class Session { - token: string; - state: State; - constructor(token: string, data: State, context: ApplicationContext); +export interface RoomsOptions { + /** + * How long an emptied room's backplane channel stays subscribed, in ms — + * the grace window that absorbs reconnect churn for single-member rooms + * and keeps the between-subscriptions loss window shut for the common + * bounce. Default 5000; `0` unsubscribes immediately. + */ + linger?: number; +} + +export interface ClusterAskResult { + /** One answer per node that had a responder. */ + answers: Array; + /** Per-node failures (no responder, responder threw). */ + errors: Array; + /** True when a node never answered inside the timeout. */ + incomplete: boolean; +} + +/** + * Presence, introspection and node-to-node messaging across every instance + * sharing a backplane. Always present on an RpcServer: without a backplane + * every operation degrades to its local half, so application code never + * branches on the deployment. + * + * Presence is replicated — deltas plus corrective snapshots — so `count()` + * and `presence()` are local reads with no network round-trip. + */ +export declare class Cluster extends Emitter { + readonly instanceId: string; + /** The boot marker distinguishing a restart from a live node. */ + readonly epoch: string; + /** False without a backplane: every operation is local-only. */ + readonly connected: boolean; + /** + * False while a channel subscribe is failing and being retried: the node + * can publish but cannot hear. 'degraded'/'recovered' fire on the + * transitions — wire them to a readiness probe. + */ + readonly healthy: boolean; + /** Cluster-wide membership of `room`: a local sum, no network. */ + count(room: string): number; + /** Per-instance breakdown of `room`; zero-count instances are omitted. */ + presence(room: string): { total: number; instances: Record }; + /** Ids of the live instances, this one first. */ + instances(): Array; + /** + * Descriptors of matching clients across the cluster. Resolves early the + * moment every live node answered; on timeout the partial array carries a + * non-enumerable `incomplete: true`. + */ + fetchClients( + sel?: ClusterSelector, + options?: { timeout?: number }, + ): Promise & { incomplete?: boolean; truncated?: boolean }>; + /** + * `target` is a client id (addressed: ONE instance hears it) or a + * selector (`{ room }` / `{}`: applied on every instance). Commands are + * fire-and-forget with the backplane's at-most-once delivery. + */ + join(target: string | ClusterSelector, ...rooms: Array): void; + leave(target: string | ClusterSelector, ...rooms: Array): void; + disconnect(target: string | ClusterSelector): void; + /** Fire-and-forget to every OTHER node's `cluster.on(name, ...)`. */ + sendEvent(name: string, data?: unknown): void; + /** The LOCAL Emitter emit — remote nodes are reached by sendEvent. */ + emit(name: EventName, data?: unknown): Promise; + /** + * Asks every other node and collects their answers — each node answers + * through its `cluster.respond(name, fn)` responder, or contributes an + * error when it has none. + */ + ask(name: string, data?: unknown, options?: { timeout?: number }): Promise; + /** One responder per name; a duplicate registration throws. */ + respond(name: string, handler: (data: unknown, from: string) => unknown): void; + unrespond(name: string): boolean; +} + +// --------------------------------------------------------------------------- +// Server core + +export interface CorsOptions { + origins?: Array | ((origin: string) => boolean); + credentials?: boolean; + /** + * `Access-Control-Allow-Headers`. Replaces the default, + * `'Content-Type, x-wrpc-channel, last-event-id, x-wrpc-meta'` — none of + * the last three is CORS-safelisted, so dropping `x-wrpc-channel` or + * `last-event-id` disables cross-origin SSE, and dropping `x-wrpc-meta` + * disables cross-origin connection metadata. + * + * An array is joined with `', '` — the same value, spelled as a list. + */ + headers?: string | Array; + /** + * Meta keys a cross-origin client may send as per-key + * `x-wrpc-meta-` headers (the client's `metaFormat: 'prefixed'`). + * CORS has no wildcard for header names, so each key must be named; + * `['userId']` grants `x-wrpc-meta-user-id`, kebab-normalized to match + * what the client actually sends. Appended to `headers`, never replacing + * it. Unnecessary with the default `metaFormat: 'json'`, which sends the + * one already-allowed `x-wrpc-meta` header whatever the keys are. + */ + metaHeaders?: Array; + /** `Access-Control-Allow-Methods`; default `'POST, GET, OPTIONS'`. */ + methods?: string; +} + +export interface ErrorOptions { + id?: string; + error?: Error; +} + +/** + * What the peer presented when the connection was made. Frozen. `headers` + * and `data` are PEER-CONTROLLED — labels for logs, metrics and feature + * gates, never an authorization input: authorization is the session's job. + */ +export interface ClientMeta { + /** Client-declared metadata (the client's `meta` option); `{}` when none. */ + readonly data: Readonly>; + /** Request/upgrade headers; `{}` on a worker port. */ + readonly headers: Readonly | undefined>>; + /** The request/upgrade URL with its query string; `''` on a worker port. */ + readonly url: string; + readonly remoteAddress: string; + /** The negotiated WebSocket subprotocol; `''` off ws. */ + readonly protocol: string; } export declare class Context { client: Client; uuid: string; state: Record; + /** + * Aborted when the caller cancels, unsubscribes, or disconnects. A handler + * that awaits anything long-lived should pass it along; one that ignores + * it runs to completion and has its result dropped. + */ + readonly signal: AbortSignal | null; + readonly session: Session | null; + /** + * The server this call arrived on — how a handler reaches rooms + * (`context.server.to(room).emit(...)`) without closing over a server + * that could not exist before the router it was built from. + */ + readonly server: RpcServer | null; + /** + * A child of the connection's logger bound to this call's uuid — the + * documented way to log from a handler: `context.log.info(...)`. + */ + readonly log: WrpcLogWriter; + /** + * The wire target of this invocation — `'unit/name'` or `'unit.vN/name'` + * for calls and subscriptions, the event name verbatim for inbound + * events; null on a context built without a target. + */ + readonly method: string | null; + /** The procedure (or event handler) resolved for this invocation. */ + readonly procedure: Procedure | null; + /** The connection's presented metadata — `client.meta`, mirrored. */ + readonly meta: ClientMeta; + /** + * The caller's per-invocation metadata (the packet's optional `meta` + * field, sanitized: plain object, size-capped, `__proto__` dropped, + * frozen). A frozen EMPTY object when the packet carried none — never + * null, so `context.callMeta.idem` needs no `?.`. Deliberately outside + * schema validation; a label, never an authorization input. + */ + readonly callMeta: Readonly>; + constructor( + client: Client, + signal?: AbortSignal | null, + target?: { method?: string; procedure?: Procedure; callMeta?: Record | null } | null, + ); +} + +export class Client extends Emitter { + /** + * Instance-prefixed (`.`), so the id itself + * addresses the instance holding the connection — what lets a cluster + * command for one client travel as one message to one node. + */ + readonly id: string; + /** + * The application's bag, carried by cluster descriptors (fetchClients). + * wrpc itself never reads it — socket.io's `socket.data`. + */ + data: Record; + source: string; session: Session | null; - constructor(client: Client); + /** True for transports that stay open (WebSocket, worker port). */ + readonly persistent: boolean; + /** The RpcServer this client belongs to; null for a standalone Client. */ + readonly server: RpcServer | null; + /** + * What the peer presented when the connection was made (headers, url, + * negotiated subprotocol, client-declared metadata). Frozen; the + * peer-controlled parts are labels, never authorization inputs. + */ + readonly meta: ClientMeta; + /** Settles once the cookie-based session restore (if any) finished. */ + sessionReady: Promise; + /** + * What dispatch gates on: the session restore PLUS the settled onConnect + * hooks. Two promises on purpose — an onConnect hook may `await + * client.sessionReady`, so folding the hooks into that same promise would + * make such a hook wait for itself. Never rejects. + */ + ready: Promise; + streams: Map; + /** In-flight calls, by id — what `{type:'cancel'}` reaches. */ + calls: Map; + /** Live subscriptions, by id — what `{type:'unsubscribe'}` reaches. */ + subscriptions: Map; + maxSubscriptions: number; + maxCalls: number; + /** Context uuids and server-side stream ids; injectable via RpcServerOptions. */ + generateId: () => string; + /** The connection-scoped log writer (peer binding included). */ + readonly log: WrpcLogWriter; + /** The server's telemetry writer; disabled-shaped when unconfigured. */ + readonly otel: object; + /** 'ws' | 'http' | 'sse' | 'event' — a metric attribute and a log field. */ + readonly transportKind: string; + /** False on a text-only transport (SSE), where binary streams cannot go. */ + readonly binary: boolean; + /** Resolves when the transport drained, or when it closed. */ + drain(): Promise; + error(code: number, options?: ErrorOptions): void; + /** Returns false when the transport is above its high-water mark. */ + send(obj: object, options?: { code?: number; method?: string }): boolean; + /** + * Writes an ALREADY-serialized packet — the fan-out seam: a broadcast + * stringifies once and hands every recipient the same text. + */ + sendRaw(text: string): boolean; + createContext(signal?: AbortSignal | null): Context; + /** The LOCAL Emitter emit — nothing reaches the wire; that is sendEvent. */ + emit(name: EventName, data?: unknown): Promise; + /** Sends a `{type:'event'}` packet to this peer; `name` is 'unit/event'. */ + sendEvent(name: string, data?: unknown): void; + /** + * A call in the other direction: sends `{type:'event', name, data, id}` + * and resolves with what the peer's responder returns (registered + * client-side with `client.respond(name, fn)`). Rejects with 408 on + * timeout, 503 when the connection drops, 501 when the peer has no + * responder. + */ + ask(name: string, data?: unknown, options?: AskOptions): Promise; + /** + * The bookkeeping half of ask(), for a caller that writes the packet + * itself (Broadcast.ask's encode-once fan-out). + */ + expectAnswer(id: string, timeout?: number): Promise; + /** Routes an inbound `callback` to its pending ask; false when none. */ + settleAnswer(packet: { id: string; result?: unknown; error?: { message: string; code: number; details?: unknown } }): boolean; + /** Diagnostics for inbound packets with no id to answer on. */ + warn(message: string): void; + /** Joins a room; false when already a member. */ + join(room: string): boolean; + leave(room: string): boolean; + in(room: string): boolean; + /** The rooms this client is in — a copy, safe to iterate while leaving. */ + readonly rooms: Set; + getStream(id: string): WrpcReadable | WrpcWritable; + createStream(name: string, size: number): WrpcWritable; + initializeSession(token?: string, data?: State): boolean; + finalizeSession(): Promise; + startSession(token?: string, data?: State): boolean; + restoreSession(token: string): Promise; + close(): void; + destroy(): void; } -export function createProxy( - data: T, - save?: (data: T) => void, -): T; +/** Abstract HTTP call description consumed by RpcServer.handleHttpCall. */ +export interface HttpCall { + method: string; + url: string; + headers: Record; + body?: string | Buffer | null; + remoteAddress?: string; + respond(response: { + status: number; + headers: Record>; + body?: Buffer; + }): void; + /** + * Registers a listener for a request that ends without a response, so + * the core can evict its client. Adapters should wire it to their + * request/response close signal. + */ + onAbort?(listener: () => void): void; + /** + * Keeps the response open and writes into it, instead of answering with a + * body. This is what SSE rides on; a host that cannot stream simply omits + * it and the events endpoint answers 501. + */ + stream?(response: { + status: number; + headers: Record>; + }): import('./sse.js').SseWriter | null; +} + +// --------------------------------------------------------------------------- +// Logging + +export interface RpcServerOptions { + router: Router; + sessions?: SessionsOptions; + cors?: CorsOptions | null; + /** Default '/api'; '' serves from the root. */ + basePath?: string; + /** Defaults to the global console; `false` silences the server. */ + logger?: WrpcLogger | boolean; + /** Off unless a tracer, a meter, or the OTel api module is supplied. */ + telemetry?: WrpcTelemetryOptions | null; + /** + * Carries room events between instances. Optional: without one, rooms + * work identically inside a single instance. + */ + backplane?: Backplane | null; + /** + * Identifies this instance on the backplane; a uuid by default. Must not + * contain '.' — it prefixes every client id (`.`). + */ + instanceId?: string; + /** + * Context uuids, server-side stream ids and synthetic REST packet ids; + * uuid v4 unless the app brings its own. Correlation ids, not secrets. + */ + generateId?: () => string; + /** + * system/introspect exposure: `true` (default) mounts it public, + * `'session'` gates it behind a session, `false` leaves the API surface + * unadvertised. A router defining its own introspect always wins. + */ + introspection?: + | boolean + | 'session' + | { access?: boolean | 'session'; schemas?: boolean }; + /** Packets accepted in one batch frame; default 128. */ + maxBatch?: number; + /** Concurrent subscriptions per client; default 256. */ + maxSubscriptions?: number; + /** In-flight calls per client; past it a call answers 429. Default 1000. */ + maxCalls?: number; + /** SSE channel options, or `false` to remove the events endpoint. */ + sse?: import('./sse.js').SseOptions | false; + /** + * Presence/request tuning for the cluster layer, or `false` to opt out: + * presence, commands and asks then degrade to their local halves while + * the rooms backplane keeps working. + */ + cluster?: ClusterOptions | false; + /** Rooms-backplane tuning (see the scaling guide). */ + rooms?: RoomsOptions; + /** + * Pluggable query-string codec (qs and friends) for REST-mode requests. + * The injected parser takes over prototype-pollution responsibility. + */ + querystring?: { parse(text: string): Record; stringify?(query: object): string }; + /** + * Pluggable wire codec for wrpc packets (ws, packet-mode HTTP, SSE data, + * worker ports). Text-only: encode must produce single-line text. REST + * mode, SSE control frames and binary chunks stay outside it. Mutually + * exclusive with compiled response serializers. + */ + codec?: WrpcCodec; + /** + * Cap on peer-declared metadata, measured on the ENCODED input: the ws + * `wrpc_h` connect-URL parameter and the per-packet `meta` field. Over + * the cap the label is refused (a warn is logged), never the connection. + * Default 2048. + */ + metaMaxBytes?: number; +} + +// The codec types (WrpcCodec, WrpcPacketCodec, WrpcRestCodec) and the +// isCodec predicate live in client.d.ts — the client accepts the same +// shape — and arrive here through the `export *`. One declaration: the +// browser and node entries used to present DIFFERENT signatures for the +// same runtime function. + +export declare class RpcServer extends Emitter { + readonly router: Router; + readonly sessions: SessionManager; + readonly rooms: RoomRegistry; + readonly instanceId: string; + readonly basePath: string; + /** The injected codec option, verbatim; null without one. */ + readonly codec: WrpcCodec | null; + /** Where the SSE stream lives: `${basePath}/events`. */ + readonly eventsPath: string; + /** The SSE channel registry, or null when `sse: false`. */ + readonly sse: import('./sse.js').SseChannels | null; + readonly clients: Set; + /** + * A host-delegated REST route runs its procedure outside handleHttpCall: + * the host owns routing/validation/serialization, wrpc the session and + * client lifecycle. Call `release()` when the response closes. + */ + delegatedContext( + request?: { method?: string; headers?: Record; remoteAddress?: string; url?: string }, + target?: { method?: string; procedure?: Procedure } | null, + ): Promise<{ + client: Client; + context: Context; + transport: ServerTransport; + release: () => void; + }>; + /** Cluster-wide presence, introspection and node-to-node messaging. */ + readonly cluster: Cluster; + constructor(options: RpcServerOptions); + /** The local client with this id; undefined when not on this instance. */ + getClient(id: string): Client | undefined; + /** Everyone in any of `rooms`, each client once; with no rooms, nobody. */ + to(...rooms: Array): Broadcast; + /** Everyone connected, minus `clients`. */ + except(...clients: Array): Broadcast; + /** Everyone connected; returns the number of LOCAL recipients. */ + broadcast(name: string, data?: unknown): number; + attachSocket( + socket: WrpcSocket | Connection, + meta?: { headers?: Record; remoteAddress?: string }, + ): Client; + attachPort(port: MessagePort): Client; + handleHttpCall(call: HttpCall): Promise; + matchPath(pathname: string): { mode: 'packet' | 'rest'; rest?: string } | null; + /** True while drain() runs: new calls are refused with 503. */ + readonly draining: boolean; + /** + * False while a backplane channel subscribe is failing and being retried + * (rooms or cluster): the node can publish but cannot HEAR — drain it + * from rotation. Always true without a backplane. + */ + readonly healthy: boolean; + /** + * The server telemetry writer, for hosts that run procedures outside the + * dispatcher (the fastify adapter's delegated routes bracket invokeBare + * with it). @experimental — the writer's shape may change in a minor. + */ + readonly otel: unknown; + /** + * The graceful half of a shutdown: refuse new calls (503) and wait up to + * `timeout` ms for in-flight ones to settle. Subscriptions are not waited + * for — a live feed has no natural end. Resolves early when idle. + */ + drain(timeout?: number): Promise; + close(): Promise; +} + +export interface ServerOptions extends RpcServerOptions { + host?: string; + port?: number; + protocol?: string; + nagle?: boolean; + key?: string; + cert?: string; + SNICallback?: Function; + timeouts?: { bind?: number }; + retry?: number; + engine?: Engine; + /** Request-body cap in bytes for the built-in HTTP path. Default 10 MiB. */ + maxBodySize?: number; + /** Options forwarded to the engine's attach() (path, protocols, ...). */ + ws?: Omit; +} + +export class Server extends Emitter { + /** Null with a standalone engine, which owns the network stack itself. */ + httpServer: HttpServer | null; + wsServer: EngineConnectionSource | null; + rpc: RpcServer; + constructor(options: ServerOptions); + /** The bound address, whichever side owns the listener. */ + address(): { address: string; family: string; port: number } | string | null; + /** Rooms, forwarded to the core. */ + readonly rooms: RoomRegistry; + readonly clients: Set; + to(...rooms: Array): Broadcast; + except(...clients: Array): Broadcast; + broadcast(name: string, data?: unknown): number; + /** Cluster-wide presence, introspection and node-to-node messaging. */ + readonly cluster: Cluster; + /** The local client with this id; undefined when not on this instance. */ + getClient(id: string): Client | undefined; + listen(): Promise; + /** + * With `drain` (ms): stop intake, let in-flight calls settle up to the + * window, send every peer 1001 "going away", then tear down what remains. + */ + close(options?: { drain?: number }): Promise; +} + +// --------------------------------------------------------------------------- +// Transports + +export interface TransportOptions { + headers?: Record; +} + +export class ServerTransport extends Emitter { + static transport: { + http: typeof ServerHttpTransport; + ws: typeof ServerWsTransport; + event: typeof ServerEventTransport; + }; + source: string; + constructor(source: string); + error(code?: number, options?: ErrorOptions): boolean; + /** Returns the transport's backpressure signal (false = above the mark). */ + send(obj: object, code?: number): boolean; +} + +declare class ServerHttpTransport extends ServerTransport { + call: HttpCall; + headers: Record; + readonly responded: boolean; + constructor(call: HttpCall, options?: TransportOptions); + write(data: string | Buffer, httpCode?: number): boolean; + /** True when this transport collects a batch frame's answers. */ + readonly batched: boolean; + getCookies(): Record; + sendSessionCookie(cookieHeader: string): void; + close(): void; +} +export type { ServerHttpTransport }; + +declare class ServerWsTransport extends ServerTransport { + connection: WrpcSocket | Connection; + constructor( + connection: WrpcSocket | Connection, + meta?: { remoteAddress?: string }, + ); + write(data: string | Buffer): boolean; + close(): void; +} +export type { ServerWsTransport }; + +declare class ServerEventTransport extends ServerTransport { + port: MessagePort; + constructor(port: MessagePort); + write(data: string | Buffer): void; + close(): void; +} +export type { ServerEventTransport }; + +/** Per-request response headers: security defaults + CORS for `origin`. */ +export function buildHeaders( + cors?: CorsOptions | null, + origin?: string, +): Record; -export function chunkEncode(id: string, payload: Uint8Array): Uint8Array; -export function chunkDecode(chunk: Uint8Array): { - id: string; - payload: Uint8Array; -}; diff --git a/package.json b/package.json index d5aee00..ab119b6 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "@alexify/wrpc", "version": "1.0.0", - "description": "Fast and low overhead, zero-dependency, WebSocket-based RPC protocol for Node.js and browsers", + "description": "Fast, zero-dependency, WebSocket-based RPC protocol for Node.js and browsers: one router over WebSocket, HTTP, SSE and REST - typed client, subscriptions, rooms, cluster and binary streams", "main": "index.js", "browser": { "./index.js": "./browser.js", + "./sse.js": "./sse.browser.js", "./src/chunks.js": "./src/chunks.browser.js", "./src/runtime/node.js": "./src/runtime/browser.js" }, @@ -14,10 +15,57 @@ "types": "index.d.ts", "exports": { ".": { + "browser": { + "types": "./browser.d.ts", + "default": "./browser.js" + }, "types": "./index.d.ts", - "browser": "./browser.js", "default": "./index.js" - } + }, + "./ws": { + "types": "./ws.d.ts", + "default": "./ws.js" + }, + "./engine": { + "types": "./engine.d.ts", + "default": "./engine.js" + }, + "./uws": { + "types": "./uws.d.ts", + "default": "./uws.js" + }, + "./fastify": { + "types": "./fastify.d.ts", + "default": "./fastify.js" + }, + "./express": { + "types": "./express.d.ts", + "default": "./express.js" + }, + "./scaling": { + "types": "./scaling.d.ts", + "default": "./scaling.js" + }, + "./sse": { + "browser": { + "types": "./sse.browser.d.ts", + "default": "./sse.browser.js" + }, + "types": "./sse.d.ts", + "default": "./sse.js" + }, + "./query": { + "types": "./query.d.ts", + "default": "./query.js" + }, + "./auth": { + "types": "./auth.d.ts", + "default": "./auth.js" + }, + "./package.json": "./package.json" + }, + "bin": { + "wrpc": "bin/wrpc.js" }, "directories": { "test": "tests" @@ -26,10 +74,11 @@ "test": "node --test", "test:coverage": "c8 --all --include 'src/**' --check-coverage --lines 95 --statements 95 --branches 90 --functions 95 --reporter text --reporter lcov node --test", "test:types": "tsd", + "test:perf": "node tests/perf/stream-memory.perf.js", "bench": "node bench/run-all.js", - "lint": "oxlint src tests scripts bench", - "format": "oxfmt src tests scripts bench", - "format:check": "oxfmt --check src tests scripts bench", + "lint": "oxlint src tests scripts bench bin", + "format": "oxfmt src tests scripts bench bin", + "format:check": "oxfmt --check src tests scripts bench bin", "size": "node scripts/size.js", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", @@ -50,8 +99,8 @@ "nodejs", "browser", "fast", - "low-overhead", "zero-dependency", + "authentication", "realtime", "communication", "messaging", @@ -61,7 +110,18 @@ "typescript", "javascript", "node", - "browser" + "opentelemetry", + "observability", + "sse", + "server-sent-events", + "rest", + "typed-client", + "codegen", + "tanstack-query", + "subscriptions", + "pubsub", + "cluster", + "streams" ], "author": "Alex Dolid ", "license": "MIT", @@ -71,15 +131,27 @@ "homepage": "https://wrpc.vercel.app", "devDependencies": { "@fastify/websocket": "^11.3.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.10.0", + "@opentelemetry/core": "^2.10.0", + "@opentelemetry/sdk-metrics": "^2.10.0", + "@opentelemetry/sdk-trace-base": "^2.10.0", + "@tanstack/query-core": "^5.101.4", + "@trpc/client": "^11.18.0", + "@trpc/server": "^11.18.0", "@types/node": "^22.10.0", "c8": "^10.1.3", "esbuild": "^0.28.1", + "express": "^5.2.1", "fastify": "^5.11.3", "fastify-uws": "^2.0.0", + "ioredis": "^6.0.0", "mermaid": "^11.16.0", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "pino": "^10.3.1", + "socket.io": "^4.8.3", + "socket.io-client": "^4.8.3", "tsd": "^0.33.0", "typescript": "^7.0.2", "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.69.0", @@ -97,7 +169,30 @@ "files": [ "index.js", "index.d.ts", + "client.d.ts", "browser.js", + "browser.d.ts", + "ws.js", + "ws.d.ts", + "engine.js", + "engine.d.ts", + "uws.js", + "uws.d.ts", + "fastify.js", + "fastify.d.ts", + "express.js", + "express.d.ts", + "scaling.js", + "scaling.d.ts", + "sse.js", + "sse.browser.js", + "sse.d.ts", + "sse.browser.d.ts", + "query.js", + "query.d.ts", + "auth.js", + "auth.d.ts", + "bin/", "src/", "LICENSE", "README.md", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b1e4e2..0f6c30e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,30 @@ importers: '@fastify/websocket': specifier: ^11.3.0 version: 11.3.0 + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 + '@opentelemetry/context-async-hooks': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@tanstack/query-core': + specifier: ^5.101.4 + version: 5.101.4 + '@trpc/client': + specifier: ^11.18.0 + version: 11.18.0(@trpc/server@11.18.0(typescript@7.0.2))(typescript@7.0.2) + '@trpc/server': + specifier: ^11.18.0 + version: 11.18.0(typescript@7.0.2) '@types/node': specifier: ^22.10.0 version: 22.20.1 @@ -20,12 +44,18 @@ importers: esbuild: specifier: ^0.28.1 version: 0.28.2 + express: + specifier: ^5.2.1 + version: 5.2.1 fastify: specifier: ^5.11.3 version: 5.11.3 fastify-uws: specifier: ^2.0.0 version: 2.0.0 + ioredis: + specifier: ^6.0.0 + version: 6.0.0 mermaid: specifier: ^11.16.0 version: 11.16.1 @@ -38,6 +68,12 @@ importers: pino: specifier: ^10.3.1 version: 10.3.1 + socket.io: + specifier: ^4.8.3 + version: 4.8.3 + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 tsd: specifier: ^0.33.0 version: 0.33.0 @@ -519,6 +555,9 @@ packages: '@iconify/utils@3.1.4': resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} + '@ioredis/commands@2.0.0': + resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -566,6 +605,50 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/context-async-hooks@2.10.0': + resolution: {integrity: sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.10.0': + resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/resources@2.10.0': + resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.10.0': + resolution: {integrity: sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.10.0': + resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.10.0': + resolution: {integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@oxfmt/binding-android-arm-eabi@0.62.0': resolution: {integrity: sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -982,10 +1065,32 @@ packages: '@sinclair/typebox@0.27.12': resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@tanstack/query-core@5.101.4': + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} + + '@trpc/client@11.18.0': + resolution: {integrity: sha512-wOqeg3Fvl25V1ZisQhUD3K8G60ZJDlSGJNSyeXrLH24xAo5w6GSR2Kzb1cSNY9Y+IQ2YZvYGZstBU+V/ulo/ow==} + hasBin: true + peerDependencies: + '@trpc/server': 11.18.0 + typescript: '>=5.7.2' + + '@trpc/server@11.18.0': + resolution: {integrity: sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q==} + hasBin: true + peerDependencies: + typescript: '>=5.7.2' + '@tsd/typescript@5.9.3': resolution: {integrity: sha512-JSSdNiS0wgd8GHhBwnMAI18Y8XPhLVN+dNelPfZCXFhy9Lb3NbnFyp9JKxxr54jSUkEJPk3cidvCoHducSaRMQ==} engines: {node: '>=14.17'} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1352,6 +1457,14 @@ packages: abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -1429,9 +1542,17 @@ packages: bare-abort-controller: optional: true + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + brace-expansion@2.1.4: resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} @@ -1443,6 +1564,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + c8@10.1.3: resolution: {integrity: sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==} engines: {node: '>=18'} @@ -1453,6 +1578,14 @@ packages: monocart-coverage-reports: optional: true + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + camelcase-keys@6.2.2: resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==} engines: {node: '>=8'} @@ -1478,6 +1611,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1496,9 +1633,29 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -1507,6 +1664,10 @@ packages: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -1679,6 +1840,15 @@ packages: dayjs@1.11.21: resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decamelize-keys@1.1.1: resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} engines: {node: '>=0.10.0'} @@ -1690,6 +1860,14 @@ packages: delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1708,12 +1886,19 @@ packages: dompurify@3.4.13: resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} @@ -1723,9 +1908,24 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + engine.io-client@6.6.6: + resolution: {integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.9: + resolution: {integrity: sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==} + engines: {node: '>=10.2.0'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -1733,10 +1933,18 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + es-toolkit@1.50.0: resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} @@ -1754,6 +1962,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + eslint-formatter-pretty@4.1.0: resolution: {integrity: sha512-IsUTtGxF1hrH6lMWiSl1WbGaiP01eT6kzywdY1U+zLc0MP+nwEnUiS9UI8IaOTUhTeQJLlCEWIbXINBH4YJbBQ==} engines: {node: '>=10'} @@ -1764,12 +1975,20 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -1814,6 +2033,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-my-way@9.7.0: resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} engines: {node: '>=20'} @@ -1833,6 +2056,14 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1845,6 +2076,14 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1858,6 +2097,10 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -1869,6 +2112,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -1895,10 +2142,18 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1920,6 +2175,14 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ioredis@6.0.0: + resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==} + engines: {node: '>=20.0.0'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + ipaddr.js@2.5.0: resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} engines: {node: '>= 10'} @@ -1955,6 +2218,9 @@ packages: resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} engines: {node: '>=0.10.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -2069,13 +2335,25 @@ packages: engines: {node: '>= 20'} hasBin: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + meow@9.0.0: resolution: {integrity: sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==} engines: {node: '>=10'} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2102,6 +2380,22 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -2128,11 +2422,22 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + non-layered-tidy-tree-layout@2.0.2: resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} @@ -2143,10 +2448,22 @@ packages: resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==} engines: {node: '>=10'} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2209,6 +2526,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -2227,6 +2548,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -2286,6 +2610,14 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2296,6 +2628,14 @@ packages: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -2322,6 +2662,10 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -2366,6 +2710,10 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2401,9 +2749,20 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2415,6 +2774,22 @@ packages: shiki@2.5.0: resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2423,6 +2798,21 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + socket.io-adapter@2.5.8: + resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} + + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.7: + resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -2453,6 +2843,13 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} @@ -2534,6 +2931,10 @@ packages: resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} engines: {node: '>=20'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -2566,6 +2967,10 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -2576,7 +2981,7 @@ packages: version: 20.69.0 uWebSockets.js@https://codeload.github.com/uNetworking/uWebSockets.js/tar.gz/fcfc622a4286909593b7f390056d89e0ca3b56b9: - resolution: {gitHosted: true, integrity: sha512-jqhmfgWjK666yl7yeIsZYjFtPU1wL+4+H4BZZ9vxAk7ntzA+EC6juu9xuD6aOa6jtoC6EzUZAlPBojgZtzXXCA==, tarball: https://codeload.github.com/uNetworking/uWebSockets.js/tar.gz/fcfc622a4286909593b7f390056d89e0ca3b56b9} + resolution: {gitHosted: true, tarball: https://codeload.github.com/uNetworking/uWebSockets.js/tar.gz/fcfc622a4286909593b7f390056d89e0ca3b56b9} version: 20.57.0 undici-types@6.21.0: @@ -2597,6 +3002,10 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2611,6 +3020,10 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -2702,6 +3115,10 @@ packages: utf-8-validate: optional: true + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3091,6 +3508,8 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@ioredis/commands@2.0.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3145,6 +3564,46 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/context-async-hooks@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/semantic-conventions@1.43.0': {} + '@oxfmt/binding-android-arm-eabi@0.62.0': optional: true @@ -3381,8 +3840,25 @@ snapshots: '@sinclair/typebox@0.27.12': {} + '@socket.io/component-emitter@3.1.2': {} + + '@tanstack/query-core@5.101.4': {} + + '@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@7.0.2))(typescript@7.0.2)': + dependencies: + '@trpc/server': 11.18.0(typescript@7.0.2) + typescript: 7.0.2 + + '@trpc/server@11.18.0(typescript@7.0.2)': + dependencies: + typescript: 7.0.2 + '@tsd/typescript@5.9.3': {} + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.20.1 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -3722,6 +4198,16 @@ snapshots: abstract-logging@2.0.1: {} + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -3785,8 +4271,24 @@ snapshots: bare-events@2.9.1: {} + base64id@2.0.0: {} + birpc@2.9.0: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + brace-expansion@2.1.4: dependencies: balanced-match: 1.0.2 @@ -3799,6 +4301,8 @@ snapshots: dependencies: fill-range: 7.1.1 + bytes@3.1.2: {} + c8@10.1.3: dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -3813,6 +4317,16 @@ snapshots: yargs: 17.7.3 yargs-parser: 21.1.1 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + camelcase-keys@6.2.2: dependencies: camelcase: 5.3.1 @@ -3838,6 +4352,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cluster-key-slot@1.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3850,14 +4366,29 @@ snapshots: commander@8.3.0: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} copy-anything@4.0.5: dependencies: is-what: 5.5.0 + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -4060,6 +4591,10 @@ snapshots: dayjs@1.11.21: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + decamelize-keys@1.1.1: dependencies: decamelize: 1.2.0 @@ -4071,6 +4606,10 @@ snapshots: dependencies: robust-predicates: 3.0.3 + denque@2.1.0: {} + + depd@2.0.0: {} + dequal@2.0.3: {} devlop@1.1.0: @@ -4087,6 +4626,12 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + duplexify@4.1.3: dependencies: end-of-stream: 1.4.5 @@ -4096,24 +4641,65 @@ snapshots: eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} + emoji-regex-xs@1.0.0: {} emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 + engine.io-client@6.6.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.3 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + + engine.io@6.6.9: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 22.20.1 + '@types/ws': 8.18.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + entities@7.0.1: {} error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 + es-define-property@1.0.1: {} + es-errors@1.3.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + es-toolkit@1.50.0: {} esbuild@0.21.5: @@ -4173,6 +4759,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + eslint-formatter-pretty@4.1.0: dependencies: '@types/eslint': 7.29.0 @@ -4188,6 +4776,8 @@ snapshots: estree-walker@2.0.2: {} + etag@1.8.1: {} + eventemitter3@5.0.4: {} events-universal@1.0.1: @@ -4196,6 +4786,39 @@ snapshots: transitivePeerDependencies: - bare-abort-controller + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -4270,6 +4893,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-my-way@9.7.0: dependencies: fast-deep-equal: 3.1.3 @@ -4295,6 +4929,10 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true @@ -4302,6 +4940,24 @@ snapshots: get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -4324,12 +4980,16 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + gopd@1.2.0: {} + hachure-fill@0.5.2: {} hard-rejection@2.1.0: {} has-flag@4.0.0: {} + has-symbols@1.1.0: {} + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -4364,10 +5024,22 @@ snapshots: html-void-elements@3.0.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} import-meta-resolve@4.2.0: {} @@ -4380,6 +5052,19 @@ snapshots: internmap@2.0.3: {} + ioredis@6.0.0: + dependencies: + '@ioredis/commands': 2.0.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + ipaddr.js@1.9.1: {} + ipaddr.js@2.5.0: {} irregular-plurals@3.5.0: {} @@ -4402,6 +5087,8 @@ snapshots: is-plain-obj@1.1.0: {} + is-promise@4.0.0: {} + is-unicode-supported@0.1.0: {} is-what@5.5.0: {} @@ -4503,6 +5190,8 @@ snapshots: marked@16.4.2: {} + math-intrinsics@1.1.0: {} + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.5 @@ -4515,6 +5204,8 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + media-typer@1.1.1: {} + meow@9.0.0: dependencies: '@types/minimist': 1.2.5 @@ -4530,6 +5221,8 @@ snapshots: type-fest: 0.18.1 yargs-parser: 20.2.9 + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} mermaid@11.16.1: @@ -4578,6 +5271,18 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + min-indent@1.0.1: {} minimatch@10.2.6: @@ -4600,8 +5305,14 @@ snapshots: mitt@3.0.1: {} + ms@2.1.3: {} + nanoid@3.3.18: {} + negotiator@0.6.3: {} + + negotiator@1.0.0: {} + non-layered-tidy-tree-layout@2.0.2: optional: true @@ -4619,8 +5330,16 @@ snapshots: semver: 7.8.5 validate-npm-package-license: 3.0.4 + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -4706,6 +5425,8 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parseurl@1.3.3: {} + path-data-parser@0.1.0: {} path-exists@4.0.0: {} @@ -4719,6 +5440,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} perfect-debounce@1.0.0: {} @@ -4778,12 +5501,31 @@ snapshots: property-information@7.2.0: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} quick-lru@4.0.1: {} + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + react-is@18.3.1: {} read-pkg-up@7.0.1: @@ -4814,6 +5556,8 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -4882,6 +5626,16 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -4906,8 +5660,35 @@ snapshots: semver@7.8.5: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-cookie-parser@2.7.2: {} + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4925,10 +5706,79 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + signal-exit@4.1.0: {} slash@3.0.0: {} + socket.io-adapter@2.5.8: + dependencies: + debug: 4.4.3 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.6 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.7: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.6 + debug: 4.4.3 + engine.io: 6.6.9 + socket.io-adapter: 2.5.8 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -4955,6 +5805,10 @@ snapshots: split2@4.2.0: {} + standard-as-callback@2.1.0: {} + + statuses@2.0.2: {} + stream-shift@1.0.3: {} streamx@2.28.0: @@ -5044,6 +5898,8 @@ snapshots: toad-cache@3.7.4: {} + toidentifier@1.0.1: {} + trim-lines@3.0.1: {} trim-newlines@3.0.1: {} @@ -5068,6 +5924,12 @@ snapshots: type-fest@0.8.1: {} + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -5120,6 +5982,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unpipe@1.0.0: {} + util-deprecate@1.0.2: {} uuid@14.0.1: {} @@ -5135,6 +5999,8 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 + vary@1.1.2: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -5241,6 +6107,8 @@ snapshots: ws@8.21.3: {} + xmlhttprequest-ssl@2.1.2: {} + y18n@5.0.8: {} yallist@4.0.0: {} diff --git a/query.d.ts b/query.d.ts new file mode 100644 index 0000000..d9826d5 --- /dev/null +++ b/query.d.ts @@ -0,0 +1,209 @@ +/** + * `@alexify/wrpc/query` — TanStack Query bindings as option factories. + * + * Framework-agnostic and dependency-free: this subpath produces the plain + * `{ queryKey, queryFn }` / `{ mutationKey, mutationFn }` objects that + * `useQuery`/`useMutation` (React, Solid, Svelte, Vue) or query-core's + * observers accept, and imports neither React nor `@tanstack/*`. The wrpc + * client and the `QueryClient` are injected by the caller. + * + * With a contract type the paths, arguments and results are all checked: + * + * ```ts + * const wq = createQueryUtils(client, { queryClient }); + * useQuery(wq.queryOptions(['chat', 'list'], { room: 'a' })); + * ``` + */ + +import type { + FirstArg, + IsAny, + Subscription, + SubscriptionContract, + UntypedApi, + WrpcClient, + WrpcError, +} from './index.js'; + +/** Units of a contract, as string keys. */ +export type ApiUnits = Extract; + +/** Methods of one unit, as string keys. */ +export type ApiMethods> = Extract; + +/** One member of a contract, looked up by path. */ +export type ApiMember, string]> = Path[1] extends keyof Api[Path[0]] + ? Api[Path[0]][Path[1]] + : never; + +type PathsWhere = { + [Unit in ApiUnits]: { + [Method in ApiMethods]: IsAny extends true ? [Unit, Method] + : Api[Unit][Method] extends SubscriptionContract ? (Subscriptions extends true ? [Unit, Method] : never) + : Subscriptions extends true ? never + : [Unit, Method]; + }[ApiMethods]; +}[ApiUnits]; + +/** Every `[unit, method]` of the contract that is a call. */ +export type CallPath = PathsWhere; + +/** Every `[unit, method]` of the contract that is a subscription. */ +export type SubscriptionPath = PathsWhere; + +/** + * What the call at `Path` takes. Projected from the parameter tuple through + * {@link FirstArg}, so a zero-argument procedure is `void` rather than the + * `unknown` naive inference produces. + */ +export type CallArgs, string]> = IsAny> extends true ? any + : ApiMember extends (...args: infer Params) => any ? FirstArg + : unknown; + +/** What the call at `Path` answers with. */ +export type CallResult, string]> = IsAny> extends true ? any + : ApiMember extends (...args: Array) => infer Result ? Awaited + : unknown; + +/** + * `[args]` when the procedure needs them, `[args?]` when it does not — so a + * forgotten argument object is a compile error rather than a 400 from the + * server's input validator. + */ +export type CallParams, string], Rest extends Array> = [ + CallArgs, +] extends [void] ? [args?: CallArgs, ...rest: Rest] + : [args: CallArgs, ...rest: Rest]; + +/** What the subscription at `Path` takes. */ +export type SubscriptionArgs, string]> = IsAny> extends true ? any + : ApiMember extends SubscriptionContract ? Args + : unknown; + +/** What the subscription at `Path` yields. */ +export type SubscriptionData, string]> = IsAny> extends true ? any + : ApiMember extends SubscriptionContract ? Data + : unknown; + +/** A cache key: the optional prefix, the path, then the arguments. */ +export type WrpcQueryKey = Array; + +/** The subset of `QueryFunctionContext` this uses; TanStack's own is a superset. */ +export interface QueryFnContext { + signal?: AbortSignal; +} + +/** + * Assignable to TanStack's `UseQueryOptions`/`QueryObserverOptions`: the two + * fields TanStack requires, plus whatever was passed through as `extra`. + */ +export interface WrpcQueryOptions { + queryKey: WrpcQueryKey; + queryFn(context?: QueryFnContext): Promise; +} + +export interface WrpcMutationOptions { + mutationKey: WrpcQueryKey; + mutationFn(args?: Args): Promise; +} + +/** What useInfiniteQuery's queryFn receives; structural, like QueryFnContext. */ +export interface InfiniteQueryFnContext extends QueryFnContext { + pageParam?: unknown; +} + +export interface WrpcInfiniteQueryOptions { + queryKey: WrpcQueryKey; + queryFn(context?: InfiniteQueryFnContext): Promise; +} + +/** The one method of `QueryClient` this needs. Structural: no import. */ +export interface QueryCache { + setQueryData(queryKey: any, updater: (previous: any) => any): unknown; +} + +export interface SubscriptionHandlerOptions { + /** Required unless one was given to {@link createQueryUtils}. */ + queryClient?: QueryCache; + /** Defaults to `queryKey(path, args)`. */ + queryKey?: WrpcQueryKey; + /** + * How a value enters the cache. The default REPLACES the entry; append with + * `(previous, data) => [...(previous ?? []), data]` when the feed is a list. + */ + update?(previous: Cached | undefined, data: Data): Cached; + /** Where to resume from; the server decides what that means. */ + lastEventId?: string; + onData?(data: Data): void; + onError?(error: WrpcError): void; + onEnd?(): void; +} + +export interface CreateQueryUtilsOptions { + /** Prepended to every key, so several clients can share one cache. */ + prefix?: Array; + /** The default for {@link QueryUtils.subscriptionHandler}. */ + queryClient?: QueryCache; +} + +export interface QueryUtils { + /** The cache key for a path; prefix-matchable, which is how invalidation works. */ + queryKey, string]>(path: Path, args?: unknown): WrpcQueryKey; + /** + * `{ queryKey, queryFn }` for useQuery. `extra` is merged in first and is + * deliberately NOT checked against TanStack's option types — that would + * require depending on them. Assign the result to your framework's option + * type (as `tests/query.test-d.ts` does) if you want a typo in `staleTime` + * caught. + */ + queryOptions>( + path: Path, + ...params: CallParams]> + ): WrpcQueryOptions>; + /** + * `{ queryKey, queryFn }` for useInfiniteQuery: the page cursor rides as + * an ordinary args field (`cursorKey`, default 'cursor') merged over + * `args`. `initialPageParam`/`getNextPageParam` and the rest of `extra` + * pass through untouched, same non-checking rule as queryOptions. + */ + infiniteQueryOptions>( + path: Path, + args?: Partial>, + extra?: Record & { cursorKey?: string }, + ): WrpcInfiniteQueryOptions>; + mutationOptions>( + path: Path, + extra?: Record, + ): WrpcMutationOptions, CallResult>; + /** + * Opens the subscription and writes every value into the cache. Returns the + * wrpc handle: `unsubscribe()` on teardown — one call opens one + * subscription, and nothing here de-duplicates two callers of the same feed. + * + * Two things to know about the cache entry, both TanStack's behaviour rather + * than this bridge's: + * - The **default `queryKey` is the subscription's own path**, where no + * `queryFn` can exist (`queryOptions` refuses subscription paths). Read it + * with `enabled: false`/`skipToken`, or pass `queryKey` to write into the + * query that already holds the data. + * - An entry written only by `setQueryData` has no observers, so it is + * **garbage-collected after `gcTime`** (5 minutes in a browser; never on a + * server). An accumulating `update` therefore loses its history unless + * something is observing the key. + */ + subscriptionHandler, Cached = unknown>( + path: Path, + args?: SubscriptionArgs, + options?: SubscriptionHandlerOptions, Cached>, + ): Subscription; +} + +/** + * Binds a wrpc client to TanStack Query. Paths are resolved lazily, when a + * query actually runs — `client.api` only exists after `load()`, and it is + * rebuilt on every reconnect. + */ +export declare function createQueryUtils( + client: WrpcClient, + options?: CreateQueryUtilsOptions, +): QueryUtils; diff --git a/query.js b/query.js new file mode 100644 index 0000000..04fea0b --- /dev/null +++ b/query.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./src/query/index.js'); diff --git a/scaling.d.ts b/scaling.d.ts new file mode 100644 index 0000000..d78deda --- /dev/null +++ b/scaling.d.ts @@ -0,0 +1,110 @@ +/** + * `@alexify/wrpc/scaling` — the rooms backplane. + * + * A backplane carries room events between wrpc instances, so + * `server.to('chat').emit(...)` reaches everyone in the room across every + * process rather than only the ones this process holds. It is optional: + * without one, rooms work exactly the same inside a single instance. + * + * Delivery is **at-most-once**. A message published while an instance is + * between subscriptions, or dropped by the broker, is gone — rooms are a + * fan-out mechanism, not a queue. + */ + +import type { WrpcLogger } from './index.js'; + +/** Removes one subscription; safe to call more than once. */ +export type Unsubscribe = () => void | Promise; + +export type BackplaneHandler = (message: string) => void; + +/** + * The structural contract — anything with this shape plugs into + * `new Server({ backplane })` / `new RpcServer({ backplane })`. + * + * `message` is always a string (the rooms layer serializes its own + * envelope), so an adapter never has to know the payload shape. + */ +export interface Backplane { + name?: string; + publish(channel: string, message: string): void | Promise; + subscribe( + channel: string, + handler: BackplaneHandler, + ): Unsubscribe | Promise; + close(): void | Promise; +} + +export interface MemoryBackplaneOptions { + /** Channel namespace; default 'wrpc'. */ + prefix?: string; + /** Defaults to the global console; `false` silences the backplane. */ + logger?: WrpcLogger | boolean; +} + +/** + * In-process backplane: the reference implementation, and what makes + * multi-instance fan-out testable without a broker — two RpcServer + * instances sharing one MemoryBackplane behave like two processes sharing + * a Redis. Delivery is deferred to a microtask, like a real broker's. + */ +export declare class MemoryBackplane implements Backplane { + constructor(options?: MemoryBackplaneOptions); + readonly name: string; + /** Number of channels with at least one subscriber. */ + readonly size: number; + publish(channel: string, message: string): void; + subscribe(channel: string, handler: BackplaneHandler): Unsubscribe; + close(): void; +} + +export declare function createMemoryBackplane( + options?: MemoryBackplaneOptions, +): MemoryBackplane; + +/** + * The subset of an ioredis client this adapter uses. Injected, never + * required — any client with this shape works. + */ +export interface RedisPublisher { + publish(channel: string, message: string): unknown; + duplicate?(): RedisSubscriber; +} + +export interface RedisSubscriber { + subscribe(channel: string): unknown; + unsubscribe?(channel: string): unknown; + on(event: 'message', listener: (channel: string, message: string) => void): unknown; + off?(event: 'message', listener: (...args: Array) => void): unknown; + removeListener?(event: 'message', listener: (...args: Array) => void): unknown; + /** Used only on a subscriber the adapter duplicated for itself. */ + quit?(): unknown; + disconnect?(): unknown; +} + +export interface RedisAdapterOptions { + pub: RedisPublisher; + /** Defaults to `pub.duplicate()`; a subscribed client cannot publish. */ + sub?: RedisSubscriber; + /** Channel namespace; default 'wrpc'. */ + prefix?: string; + /** Defaults to the global console; `false` silences the adapter. */ + logger?: WrpcLogger | boolean; +} + +/** + * Redis backplane over injected ioredis-shaped clients. + * + * `close()` releases this adapter's subscriptions and its listener. An + * INJECTED client is never quit — its lifetime belongs to the caller — but a + * subscriber the adapter created for itself through `pub.duplicate()` is, + * since no one else holds a reference to close it. + */ +export declare function createRedisAdapter( + options: RedisAdapterOptions, +): Backplane; + +/** Structural check used by RpcServer to reject a malformed backplane. */ +export declare function isBackplane(value: unknown): value is Backplane; + +export declare const DEFAULT_PREFIX: string; diff --git a/scaling.js b/scaling.js new file mode 100644 index 0000000..330c699 --- /dev/null +++ b/scaling.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./src/scaling/index.js'); diff --git a/scripts/autobahn/echo-server.js b/scripts/autobahn/echo-server.js new file mode 100644 index 0000000..e71e6be --- /dev/null +++ b/scripts/autobahn/echo-server.js @@ -0,0 +1,71 @@ +'use strict'; + +/** + * Autobahn Testsuite echo target. + * + * Started by `scripts/autobahn/run.js` (or manually: `node scripts/autobahn/echo-server.js`) + * as the server the crossbario/autobahn-testsuite fuzzing client connects to. + * Every received message is echoed back: binary messages via sendBinary(), + * text messages via sendText(). + * + * Backpressure: send*() returns false when the socket buffered past its + * high-water mark, but the bytes are still queued on the socket (Node's + * socket.write() never drops data), so no app-level echo buffer is needed. + * Instead of buffering, on false we pause() the connection (stop reading + * frames from the peer) and resume() on 'drain' — pure TCP backpressure, + * which is safe because the Autobahn fuzzer reads echoes promptly. + * + * Options: pingInterval 30s, maxBuffer 64 MiB (Autobahn sends cases up to + * 16 MiB), perMessageDeflate enabled (cases 12/13). + */ + +const http = require('node:http'); +const { WebsocketServer } = require('../../src/websocket/ws.js'); + +const PORT = Number(process.env.PORT ?? 9001); +const PING_INTERVAL = 30000; +const MAX_BUFFER = 64 * 1024 * 1024; + +const server = http.createServer((req, res) => { + res.writeHead(426, { 'content-type': 'text/plain' }); + res.end('WebSocket upgrade required'); +}); + +const wss = new WebsocketServer({ + server, + pingInterval: PING_INTERVAL, + maxBuffer: MAX_BUFFER, + perMessageDeflate: true, +}); + +wss.on('connection', (ws) => { + let paused = false; + ws.on('drain', () => { + if (!paused) return; + paused = false; + ws.resume(); + }); + ws.on('message', (data, isBinary) => { + const ok = isBinary ? ws.sendBinary(data) : ws.sendText(data.toString('utf8')); + if (!ok && !paused) { + paused = true; + ws.pause(); + } + }); + // The fuzzer sends intentionally malformed frames; per-connection errors + // are expected and already answered by the protocol layer (close frames). + ws.on('error', () => {}); +}); + +wss.on('error', (error) => { + console.error(`WebsocketServer error: ${error.message}`); +}); + +server.on('error', (error) => { + console.error(`HTTP server error: ${error.message}`); + process.exitCode = 1; +}); + +server.listen(PORT, () => { + console.log(`Autobahn echo target listening on ws://127.0.0.1:${PORT}`); +}); diff --git a/scripts/autobahn/fuzzingclient.json b/scripts/autobahn/fuzzingclient.json new file mode 100644 index 0000000..6efeec2 --- /dev/null +++ b/scripts/autobahn/fuzzingclient.json @@ -0,0 +1,7 @@ +{ + "outdir": "/reports", + "servers": [{ "agent": "wrpc", "url": "ws://127.0.0.1:9001" }], + "cases": ["*"], + "exclude-cases": [], + "exclude-agent-cases": {} +} diff --git a/scripts/autobahn/run.js b/scripts/autobahn/run.js new file mode 100644 index 0000000..01deb0e --- /dev/null +++ b/scripts/autobahn/run.js @@ -0,0 +1,138 @@ +'use strict'; + +/** + * Autobahn Testsuite orchestrator. + * + * Run locally: `node scripts/autobahn/run.js` (requires docker; the full + * suite of 500+ cases takes several minutes). It starts echo-server.js as a + * child, waits for it to listen, runs the crossbario/autobahn-testsuite + * fuzzing client against it in a container, then grades reports/index.json: + * 'OK', 'NON-STRICT', 'INFORMATIONAL', and 'UNIMPLEMENTED' behaviors pass; + * 'FAILED' and 'WRONG CODE' fail the run (exit code 1). The 6.4.* fail-fast + * timing cases are deliberately not excluded — non-strict results there are + * acceptable. + * + * Path scheme (one consistent scheme, container-side paths in the config): + * scripts/autobahn -> /config (base fuzzingclient.json, for reference/manual runs) + * scripts/autobahn/reports -> /reports (outdir AND the generated effective config) + * The checked-in fuzzingclient.json is the base; every run generates + * reports/fuzzingclient.generated.json from it so the servers url can be + * adjusted per platform, and wstest is pointed at the generated file. + * + * macOS: docker cannot use --network host (the flag targets the Linux VM, + * not the Mac), so on process.platform === 'darwin' the generated config + * rewrites the servers url to ws://host.docker.internal: and the + * --network host flag is dropped; on Linux it keeps ws://127.0.0.1: + * with --network host. No manual edit of fuzzingclient.json is needed. + * + * Note: reports/ is generated output — do not commit it. + */ + +const { spawn } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const AUTOBAHN_DIR = __dirname; +const REPORTS_DIR = path.join(AUTOBAHN_DIR, 'reports'); +const BASE_CONFIG = path.join(AUTOBAHN_DIR, 'fuzzingclient.json'); +const GENERATED_CONFIG = 'fuzzingclient.generated.json'; +const IMAGE = 'crossbario/autobahn-testsuite'; +const PORT = Number(process.env.PORT ?? 9001); +const FAIL_BEHAVIORS = new Set(['FAILED', 'WRONG CODE']); + +const isDarwin = process.platform === 'darwin'; + +const writeEffectiveConfig = () => { + const base = JSON.parse(fs.readFileSync(BASE_CONFIG, 'utf8')); + const host = isDarwin ? 'host.docker.internal' : '127.0.0.1'; + const config = { + ...base, + servers: base.servers.map((server) => ({ ...server, url: `ws://${host}:${PORT}` })), + }; + const generatedPath = path.join(REPORTS_DIR, GENERATED_CONFIG); + fs.writeFileSync(generatedPath, `${JSON.stringify(config, null, 2)}\n`); +}; + +const startEchoServer = () => + new Promise((resolve, reject) => { + const script = path.join(AUTOBAHN_DIR, 'echo-server.js'); + const child = spawn(process.execPath, [script], { + env: { ...process.env, PORT: String(PORT) }, + stdio: ['ignore', 'pipe', 'inherit'], + }); + let ready = false; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + process.stdout.write(chunk); + if (!ready && chunk.includes('listening')) { + ready = true; + resolve(child); + } + }); + child.once('error', (error) => { + if (!ready) reject(error); + }); + child.once('exit', (code) => { + if (!ready) reject(new Error(`echo-server exited before listening (code ${code})`)); + }); + }); + +const runDocker = () => + new Promise((resolve, reject) => { + const args = ['run', '--rm']; + if (!isDarwin) args.push('--network', 'host'); + args.push('-v', `${AUTOBAHN_DIR}:/config`); + args.push('-v', `${REPORTS_DIR}:/reports`); + args.push(IMAGE, 'wstest', '-m', 'fuzzingclient', '-s', `/reports/${GENERATED_CONFIG}`); + console.log(`> docker ${args.join(' ')}`); + const child = spawn('docker', args, { stdio: 'inherit' }); + child.once('error', (error) => { + reject(new Error(`failed to start docker (is it installed and running?): ${error.message}`)); + }); + child.once('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`docker exited with code ${code}`)); + }); + }); + +const collectFailures = () => { + const indexPath = path.join(REPORTS_DIR, 'index.json'); + if (!fs.existsSync(indexPath)) { + throw new Error(`missing ${indexPath} — wstest produced no report`); + } + const index = JSON.parse(fs.readFileSync(indexPath, 'utf8')); + const failures = []; + for (const agent of Object.values(index)) { + for (const [caseId, result] of Object.entries(agent)) { + if (FAIL_BEHAVIORS.has(result.behavior) || FAIL_BEHAVIORS.has(result.behaviorClose)) { + failures.push(`${caseId} (behavior: ${result.behavior}, close: ${result.behaviorClose})`); + } + } + } + return failures; +}; + +const main = async () => { + fs.rmSync(REPORTS_DIR, { recursive: true, force: true }); + fs.mkdirSync(REPORTS_DIR, { recursive: true }); + writeEffectiveConfig(); + const echo = await startEchoServer(); + try { + await runDocker(); + const failures = collectFailures(); + if (failures.length > 0) { + console.error(`Autobahn: ${failures.length} failing case(s):`); + for (const failure of failures) console.error(` ${failure}`); + process.exitCode = 1; + } else { + console.log(`Autobahn: all cases passed (report: ${path.join(REPORTS_DIR, 'index.html')})`); + } + } finally { + echo.kill('SIGTERM'); + } +}; + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/brand.js b/scripts/brand.js new file mode 100644 index 0000000..f1fdff5 --- /dev/null +++ b/scripts/brand.js @@ -0,0 +1,91 @@ +/** + * Regenerates the raster brand assets from `docs/public/logo-mark.svg`: + * + * docs/public/favicon.png 512x512, transparent — the PNG icon fallback + * docs/public/logo.png 1200x630 — the og:image / twitter:image card + * + * Run it after changing the mark: node scripts/brand.js + * + * macOS only, and deliberately dependency-free: rendering goes through + * `qlmanage`, the system QuickLook thumbnailer (WebKit under the hood), because + * this package has no dependencies and a brand asset is not a reason to grow + * one. On any other platform, render the two SVGs this script writes with + * whatever you have (rsvg-convert, resvg, a headless browser) at those sizes. + */ + +'use strict'; + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const ROOT = path.join(__dirname, '..'); +const PUBLIC = path.join(ROOT, 'docs', 'public'); +const MARK = path.join(PUBLIC, 'logo-mark.svg'); + +const INK = '#1B1C1E'; +const GREEN = '#5FA04E'; +const GREEN_LIGHT = '#9BD08C'; +const MUTED = '#8B8D90'; + +/** The mark's own drawing, without its wrapper, so it can be nested. */ +const markBody = () => { + const svg = fs.readFileSync(MARK, 'utf8'); + const open = svg.indexOf('>', svg.indexOf('')).trim(); +}; + +// 1200x630 is the size every social card scaler expects; anything else is +// re-cropped by the platform, usually through the wordmark. +// +// Authored on a 1200x1200 canvas with the card drawn in the middle band, because +// qlmanage's -s is a square bounding box: a 1200x630 source would be scaled to +// FIT that square (1.9x too big) before anything could be cropped. Square in, +// square out at 1:1, then the padding comes off. +const ogCard = (body) => ` + + + + + + + + + + + ${body} + + wrpc + Fast, zero-dependency WebSocket RPC + Node.js and the browser · 0 runtime dependencies + + + +`; + +// qlmanage's -s is a SQUARE bounding box: a 1200x630 source comes back as a +// 1200x1200 canvas with the render centred in it. `crop` asks sips to cut the +// padding back off, centred, which is exactly where qlmanage put the content. +const render = (svg, size, out, crop = null) => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'wrpc-brand-')); + const source = path.join(temp, `${path.basename(out, '.png')}.svg`); + fs.writeFileSync(source, svg); + const result = spawnSync('qlmanage', ['-t', '-s', String(size), '-o', temp, source], { encoding: 'utf8' }); + const produced = path.join(temp, `${path.basename(source)}.png`); + if (result.status !== 0 || !fs.existsSync(produced)) { + throw new Error(`qlmanage failed for ${out}\n${result.stderr ?? ''}`); + } + fs.copyFileSync(produced, out); + fs.rmSync(temp, { recursive: true, force: true }); + if (crop) { + const [width, height] = crop; + const cropped = spawnSync('sips', ['-c', String(height), String(width), out], { encoding: 'utf8' }); + if (cropped.status !== 0) throw new Error(`sips failed for ${out}\n${cropped.stderr ?? ''}`); + } + console.log(`${path.relative(ROOT, out)} ${(fs.statSync(out).size / 1024).toFixed(1)} KB`); +}; + +const body = markBody(); +render(fs.readFileSync(MARK, 'utf8'), 512, path.join(PUBLIC, 'favicon.png')); +render(ogCard(body), 1200, path.join(PUBLIC, 'logo.png'), [1200, 630]); diff --git a/scripts/size.js b/scripts/size.js index ee1388c..a7a78a9 100644 --- a/scripts/size.js +++ b/scripts/size.js @@ -1,13 +1,20 @@ /** * Honest bundle-size measurement for the README "Bundle size" table. * - * Bundles the BROWSER variant of each entry the way a consumer's bundler - * would, then reports: + * Every entry from package.json#exports is bundled the way a consumer's + * bundler would (browser condition for the main entry, plain Node + * resolution for the server-only subpaths), then reports: * - raw: bundled, un-minified * - min: fully minified (whitespace + identifier mangling + syntax, * comments stripped) * - min+gzip: the number that matters for shipping to browsers * + * The Node-only subpaths (ws/engine/uws/fastify/express) never ship to a + * browser — they are reported for visibility into how much each one pulls + * in, not because their gzip size is a real shipping cost. Only the entries + * that DO reach a browser carry a `budget` (min+gzip KB), and exceeding one + * fails the run, which is what keeps CI honest about bundle growth. + * * Run with `pnpm size`. */ @@ -17,15 +24,80 @@ const esbuild = require('esbuild'); const ROOT = path.join(__dirname, '..'); -const ENTRIES = [{ label: 'main entry (@alexify/wrpc)', entry: 'browser.js' }]; +// One row per package.json#exports entry. `platform: 'browser'` is the only +// one that actually ships to a browser bundle (via the `browser` condition); +// everything else is Node-only and bundled with `platform: 'node'` so +// `require('node:...')` stays a real require instead of failing to resolve. +const ENTRIES = [ + // Raised 10 -> 11 deliberately (the ratchet's documented escape hatch): + // the v1 hardening added client-side substance a browser genuinely ships — + // in-flight rejection on disconnect, restore decoupling, the wrpc.v1 + // subprotocol offer, pluggable generateId, and synthesized answers for + // failed HTTP batches. + // Raised 11 -> 12 for the REST bridge: the client's REST leg for mapped + // procedures (URL building, plain-result/wire-error handling, pluggable + // querystring), error.details on WrpcError, plus the transport-fallback + // and codec seams of the same release. 12 -> 13 for static introspection + // (client.use() + the #scaffoldUnit extraction it shares with load()); + // the same raise absorbs the codec.rest client bytes of this release. + // 13 -> 14 for the auth + metadata batch: the authenticate hook (the + // pre-restore seam), single-flight refresh with its one-shot retry, + // public client.call(), the connection-phase headers/meta options across + // all four transports, and per-call meta (withMeta) — measured together + // at ~+750 B min+gzip. + // 14 -> 15 for the resilience batch: connectTimeout (the handshake race), + // the reconnect stability window (stableAfter), refresh-aware subscribe + // restore, the joined-run refresh guard, coded 408/503 rejections and the + // shared failPackets settlement — measured together at ~+450 B min+gzip. + { label: 'main entry — browser (@alexify/wrpc)', entry: 'browser.js', platform: 'browser', budget: 15 }, + { label: 'main entry — node (@alexify/wrpc)', entry: 'index.js', platform: 'node' }, + { label: 'websocket engine (@alexify/wrpc/ws)', entry: 'ws.js', platform: 'node' }, + { label: 'engine port (@alexify/wrpc/engine)', entry: 'engine.js', platform: 'node' }, + { label: 'uWebSockets.js adapter (@alexify/wrpc/uws)', entry: 'uws.js', platform: 'node' }, + { label: 'fastify adapter (@alexify/wrpc/fastify)', entry: 'fastify.js', platform: 'node' }, + { label: 'express adapter (@alexify/wrpc/express)', entry: 'express.js', platform: 'node' }, + { label: 'rooms backplane (@alexify/wrpc/scaling)', entry: 'scaling.js', platform: 'node' }, + // 12 -> 13 alongside the main-entry raise: the sse entry bundles the same + // client core, so the REST-bridge bytes land here too. 13 -> 14 with the + // main entry's static-introspection raise, for the same reason; 14 -> 15 + // with its auth + metadata raise, again for the same shared core (plus + // the sse transport's own declared-headers/meta legs); 15 -> 16 with its + // resilience raise (same shared core, plus the sse POST settling its own + // refused calls through failPackets). + { label: 'sse — browser (@alexify/wrpc/sse)', entry: 'sse.browser.js', platform: 'browser', budget: 16 }, + { label: 'sse — node (@alexify/wrpc/sse)', entry: 'sse.js', platform: 'node' }, + { label: 'query bindings (@alexify/wrpc/query)', entry: 'query.js', platform: 'browser', budget: 2 }, + // Browser-reachable like query (stores + bearerAuth ship to pages), and + // deliberately OUTSIDE the main entry so only apps that opt into the + // strategies pay for them. + { label: 'auth strategies (@alexify/wrpc/auth)', entry: 'auth.js', platform: 'browser', budget: 2 }, +]; + +// A browser entry has to be self-contained: no node builtins, and no packages +// either — esbuild resolves devDependencies happily, so a stray +// `require('@tanstack/query-core')` would be inlined into the bundle and only +// show up as a few extra KB. Failing at RESOLVE time names the offending +// import and its importer, which a substring search over the output cannot. +const selfContained = { + name: 'self-contained', + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + if (args.kind === 'entry-point') return null; + if (args.path.startsWith('.') || path.isAbsolute(args.path)) return null; + const text = `browser bundle must not import '${args.path}' (from ${path.relative(ROOT, args.importer)})`; + return { errors: [{ text }] }; + }); + }, +}; -async function bundle(entry, minify) { +async function bundle(entry, platform, minify) { const result = await esbuild.build({ entryPoints: [path.join(ROOT, entry)], bundle: true, minify, - platform: 'browser', - conditions: ['browser'], + platform, + conditions: platform === 'browser' ? ['browser'] : [], + plugins: platform === 'browser' ? [selfContained] : [], write: false, logLevel: 'silent', }); @@ -38,28 +110,47 @@ function kb(bytes) { async function main() { const rows = []; - for (const { label, entry } of ENTRIES) { - const raw = await bundle(entry, false); - const min = await bundle(entry, true); - if (raw.includes('node:crypto') || raw.includes('node:perf_hooks')) { - throw new Error(`${entry}: browser bundle unexpectedly contains Node builtins`); + const over = []; + for (const { label, entry, platform, budget } of ENTRIES) { + // The browser entries are bundled through the self-contained plugin, so an + // import that does not belong fails the build here rather than inflating a + // number nobody reads. + const raw = await bundle(entry, platform, false); + const min = await bundle(entry, platform, true); + const gzip = gzipSync(min, { level: 9 }).length; + // A budget only makes sense where the number is a real shipping cost, so + // only the browser-reachable entries carry one. It is a ratchet against + // accidental bloat, not a target: raise it deliberately, in the same + // change that earns the bytes. + if (budget !== undefined && gzip > budget * 1024) { + over.push(`${entry}: ${kb(gzip)} min+gzip is over its ${budget} KB budget`); } rows.push({ label, raw: kb(raw.length), min: kb(min.length), - gzip: kb(gzipSync(min, { level: 9 }).length), + gzip: kb(gzip), + budget: budget === undefined ? '—' : `${budget}.0 KB`, }); } - console.log('| Entry | raw | min | min+gzip |'); - console.log('| ----- | ---:| ---:| --------:|'); + console.log('| Entry | raw | min | min+gzip | budget |'); + console.log('| ----- | ---:| ---:| --------:| ------:|'); for (const row of rows) { - console.log(`| ${row.label} | ${row.raw} | ${row.min} | ${row.gzip} |`); + console.log(`| ${row.label} | ${row.raw} | ${row.min} | ${row.gzip} | ${row.budget} |`); } + + // Reported after the table: seeing every number is what makes a budget + // failure actionable. + if (over.length > 0) throw new Error(`bundle size budget exceeded\n ${over.join('\n ')}`); } main().catch((error) => { - console.error(error); + // esbuild rejects with an object whose useful part is `errors[].text`; the + // object itself prints as a wall of getters, which is how a clear "you + // imported a package into the browser bundle" turns into noise. + const reported = Array.isArray(error?.errors) ? error.errors : null; + if (reported) for (const { text } of reported) console.error(text); + else console.error(error); process.exitCode = 1; }); diff --git a/src/adapters/common.js b/src/adapters/common.js new file mode 100644 index 0000000..1e08ff3 --- /dev/null +++ b/src/adapters/common.js @@ -0,0 +1,119 @@ +'use strict'; + +// The streaming half of the abstract HTTP call, implemented once for every +// host that hands over a node ServerResponse (the built-in shell, express +// and fastify all do). Returning null means the response is already gone. +// +// It is what SSE rides on: `respond` answers with a body, `stream` keeps the +// response open and writes into it. +const nodeStream = + (res) => + ({ status, headers }) => { + if (res.writableEnded || res.destroyed) return null; + res.writeHead(status, headers); + // Without this the first frame can sit in node's header buffer until + // enough body accumulates — which for a live stream is forever. + res.flushHeaders?.(); + return { + write: (chunk) => res.write(chunk), + end: () => res.end(), + onClose: (listener) => void res.on('close', listener), + onDrain: (listener) => void res.on('drain', listener), + }; + }; + +const http = require('node:http'); + +const { isOriginAllowed } = require('../transport.js'); + +// Shared plumbing for everything that turns a host framework's request into +// the abstract call description RpcServer.handleHttpCall consumes: +// { method, url, headers, body, remoteAddress, respond, onAbort }. +// Node-only by construction — adapters are never bundled for the browser. + +const MAX_BODY_SIZE = 10 * 1024 * 1024; + +const getPathname = (url) => (url ? url.split('?')[0] : '/'); + +// The one upgrade gate every host shares. With an explicit ws.path the +// engine already gates the pathname, and the default RPC-path gate would +// 403 every upgrade to a custom path — keep only the origin check then. +// A ws.verifyClient supplied by the app replaces the gate entirely. +const createUpgradeGate = ({ rpc, cors = null, ws = {} }) => { + if (ws.verifyClient) return ws.verifyClient; + const checkPath = ws.path === undefined; + return ({ req }) => { + if (checkPath) { + const pathname = getPathname(req.url); + if (pathname !== '/' && rpc.matchPath(pathname) === null) return false; + } + return isOriginAllowed(cors, req.headers.origin); + }; +}; + +// The shared 400 for a body that could not be received (too big, aborted): +// an id-less callback packet, same shape on every host. +const respondBodyError = (respond, error) => { + const packet = { type: 'callback', id: '', error: { message: error.message, code: 400 } }; + respond({ status: 400, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(packet) }); +}; + +const receiveBody = async (stream, limit = MAX_BODY_SIZE) => { + if (!Number.isSafeInteger(limit) || limit < 0) { + throw new TypeError('Body size limit must be a non-negative safe integer'); + } + const chunks = []; + let size = 0; + for await (const chunk of stream) { + size += chunk.length; + if (size > limit) throw new Error('Body size limit exceeded'); + chunks.push(chunk); + } + if (chunks.length === 0) return null; + if (chunks.length === 1) return chunks[0]; + return Buffer.concat(chunks, size); +}; + +// Frameworks that own body parsing (fastify, express + express.json()) hand +// back an already-parsed value, but the core re-parses the JSON packet +// itself — so give it back the JSON text rather than an object it would +// stringify-by-accident inside jsonParse. +const normalizeBody = (body) => { + if (body === undefined || body === null) return null; + if (typeof body === 'string' || Buffer.isBuffer(body)) return body; + if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength); + try { + return JSON.stringify(body); + } catch { + return null; // circular / non-serializable: the core sees "no body" + } +}; + +const statusLine = (code) => `${code} ${http.STATUS_CODES[code] ?? 'Unknown'}`; + +// The core answers with `Set-Cookie` as an array and Content-Length as a +// number; frameworks want strings and repeated header lines. +const eachHeader = (headers, visit) => { + // for...in over the core's own header literal: Object.entries allocated an + // array plus a [name, value] pair per header, per HTTP response. + for (const name in headers) { + const value = headers[name]; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) visit(name, String(value[i])); + continue; + } + visit(name, String(value)); + } +}; + +module.exports = { + MAX_BODY_SIZE, + receiveBody, + normalizeBody, + statusLine, + eachHeader, + nodeStream, + getPathname, + createUpgradeGate, + respondBodyError, +}; diff --git a/src/adapters/express.js b/src/adapters/express.js new file mode 100644 index 0000000..048a0c8 --- /dev/null +++ b/src/adapters/express.js @@ -0,0 +1,102 @@ +'use strict'; + +const { RpcServer, rpcOptions } = require('../rpc/core.js'); +const { createNodeEngine, isEngine } = require('../engine/index.js'); +const { + receiveBody, + normalizeBody, + nodeStream, + getPathname, + createUpgradeGate, + respondBodyError, + MAX_BODY_SIZE, +} = require('./common.js'); + +// express (and bare node:http) adapter. Unlike the batteries-included +// Server, nothing here owns the listener — the app does: +// +// const wrpc = createWrpc({ router }); +// app.use(wrpc.handler); +// const httpServer = app.listen(8000); +// httpServer.on('upgrade', wrpc.upgrade); +// +// Middleware semantics: a request outside basePath is passed to next() +// rather than answered with 404, so wrpc composes with the rest of the app +// instead of swallowing its routes. + +const createWrpc = (options = {}) => { + const { cors = null, logger = globalThis.console, ws = {}, maxBodySize = MAX_BODY_SIZE } = options; + const rpc = options.rpc ?? new RpcServer(rpcOptions({ ...options, cors, logger })); + const engine = options.engine ?? createNodeEngine(ws); + if (!isEngine(engine)) { + throw new TypeError('createWrpc: options.engine does not implement the Engine contract'); + } + if (engine.standalone) { + throw new TypeError( + 'createWrpc: a standalone engine (uWebSockets.js) owns its own network stack ' + + 'and cannot be driven from express middleware — use Server({ engine }) instead', + ); + } + + // No `server`: this engine is driven by hand from the app's own 'upgrade' + // listener (see `upgrade` below). + const source = engine.attach({ ...ws, verifyClient: createUpgradeGate({ rpc, cors, ws }) }); + source.on('connection', (socket, req) => { + rpc.attachSocket(socket, { + headers: req.headers, + url: req.url ?? '', + remoteAddress: req.socket?.remoteAddress ?? socket.remoteAddress, + }); + }); + if (typeof source.handleUpgrade !== 'function') { + engine.close(); // the attach above already claimed resources + throw new TypeError('createWrpc: the engine does not support manual upgrades (no handleUpgrade on its source)'); + } + + const handler = (req, res, next) => { + // originalUrl survives express' mount-path rewriting; basePath alone + // decides which paths belong to wrpc. + const url = req.originalUrl ?? req.url ?? '/'; + if (rpc.matchPath(getPathname(url)) === null) return void next(); + + const respond = ({ status, headers, body }) => { + if (res.writableEnded) return; + res.writeHead(status, headers); + res.end(body); + }; + + const dispatch = (body) => + rpc.handleHttpCall({ + method: req.method, + url, + headers: req.headers, + body, + // req.ip honours express's own `trust proxy` setting, so behind a + // configured proxy the SSE per-address cap counts real clients + // instead of the balancer's one IP; without the setting it IS the + // socket address, same as the fallback. + remoteAddress: req.ip ?? req.socket?.remoteAddress, + respond, + stream: nodeStream(res), + onAbort: (listener) => void res.on('close', listener), + }); + + // A body parser upstream (express.json()) already drained the stream; + // without one, read it here. + if (req.body !== undefined) return void dispatch(normalizeBody(req.body)); + receiveBody(req, maxBodySize).then(dispatch, (error) => respondBodyError(respond, error)); + }; + + const upgrade = (req, socket, head) => { + source.handleUpgrade(req, socket, head); + }; + + const close = async () => { + await rpc.close(); + engine.close(); + }; + + return { rpc, engine, wsServer: source, handler, upgrade, close }; +}; + +module.exports = { createWrpc }; diff --git a/src/adapters/fastify.js b/src/adapters/fastify.js new file mode 100644 index 0000000..f31d953 --- /dev/null +++ b/src/adapters/fastify.js @@ -0,0 +1,392 @@ +'use strict'; + +const http = require('node:http'); +const { performance } = require('node:perf_hooks'); + +const { RpcServer, rpcOptions } = require('../rpc/core.js'); +const { TRACEPARENT, TRACESTATE } = require('../telemetry/shared.js'); +const { effectiveSchema } = require('../rpc/router.js'); +const { publicErrorMessage, publicErrorDetails } = require('../transport.js'); +const { createNodeEngine, isEngine } = require('../engine/index.js'); +const { createUwsEngine } = require('./uws.js'); +const { normalizeBody, eachHeader, nodeStream, createUpgradeGate } = require('./common.js'); +const { setupMirror } = require('./mirror.js'); + +// Fastify plugin. One plugin, two backends, picked by looking at what +// fastify is actually running on: +// +// fastify() -> a real node http.Server -> node engine, +// attached to its 'upgrade' event +// fastify({ serverFactory }) -> a uws-backed fake server (fastify-uws) +// -> uws engine over the same uws app +// +// HTTP calls go through fastify's own routes, so the app's hooks, auth and +// error handling run BEFORE wrpc sees the call ("upgrade-through-router"). + +// fastify-uws keeps its uWebSockets.js app on a private symbol. Reaching for +// it by description is deliberate: the package exports no accessor, and the +// alternative is refusing to support the only uws-backed fastify there is. +const findUwsApp = (server) => { + if (!server || typeof server !== 'object') return null; + const key = Object.getOwnPropertySymbols(server).find((symbol) => symbol.description === 'uws.app'); + const app = key ? server[key] : null; + return app && typeof app.ws === 'function' ? app : null; +}; + +const resolveEngine = (fastify, options) => { + const { engine } = options; + if (engine) { + if (!isEngine(engine)) { + throw new TypeError('wrpcFastify: options.engine does not implement the Engine contract'); + } + return engine; + } + const { server } = fastify; + if (server instanceof http.Server) return createNodeEngine(options.ws); + const app = findUwsApp(server); + if (app) return createUwsEngine({ ...options.ws, app }); + throw new TypeError( + 'wrpcFastify: could not detect a WebSocket backend for this fastify instance — ' + + 'pass one explicitly via options.engine', + ); +}; + +// ---- Delegated REST routes ------------------------------------------------ +// +// A procedure with an `http` mapping becomes a REAL fastify route: fastify +// owns routing, schema validation, serialization and swagger; wrpc supplies +// the per-request Context (session, rooms, client lifecycle) and runs the +// bare handler under the procedure's own queue/timeout semantics +// (Procedure.invokeBare). The internal API over HTTP is therefore the SAME +// endpoint external consumers hit — one route, two audiences. +// +// wrpc's lifecycle hooks map onto fastify's phases by name (the naming was +// fastify's to begin with). Payload shapes differ where fastify's do: +// onRequest -> fastify onRequest, payload = the request +// preValidation -> fastify preValidation, payload = { params, query, body } (raw) +// preHandler -> fastify preHandler, payload = the same args, validated +// preSerialization -> fastify preSerialization, payload = the result (return replaces) +// onSend -> fastify onSend, payload = the serialized body +// onError -> fastify onError, payload = the error (observational) +// onResponse -> fastify onResponse, payload = null +// onTimeout has no fastify phase; a 408 still reaches onError. + +const argsOf = (request) => ({ params: request.params, query: request.query, body: request.body }); + +const forbidden = () => { + const error = new Error('Forbidden'); + error.code = 403; + // fastify's own error handler reads statusCode; wrpc's reads code. Both + // are set so `restErrors: 'app'` keeps the status too. + error.statusCode = 403; + error.expose = true; + return error; +}; + +// The wrpc wire error shape, as a fastify route errorHandler: the code is +// the status, the body is { message, code, details? } under the same +// redaction rule every other transport applies. Fastify's own schema +// validation failures land here too — their issue list becomes `details`. +const wireErrorHandler = (error, request, reply) => { + let code = + typeof error.code === 'number' ? error.code : typeof error.statusCode === 'number' ? error.statusCode : 500; + if (!Number.isInteger(code) || code < 200 || code > 599) code = 500; + const body = { message: publicErrorMessage(code, error), code }; + if (Array.isArray(error.validation)) { + const issues = []; + for (const issue of error.validation) { + issues.push({ message: issue.message, path: issue.instancePath }); + } + body.details = { issues }; + } else { + const details = publicErrorDetails(code, error); + if (details !== undefined) body.details = details; + } + reply.code(code).send(body); +}; + +const registerRestRoutes = (fastify, rpc, options) => { + const routes = rpc.router.restRoutes(); + if (routes.length === 0) return; + const contexts = new WeakMap(); + // One request runs exactly one route, so the first caller's target wins — + // and every caller for a given request passes that route's own target. + const contextOf = (request, target) => { + let pending = contexts.get(request); + if (!pending) { + pending = rpc.delegatedContext( + { + method: request.method, + headers: request.headers, + remoteAddress: request.ip, + url: request.url, + }, + target, + ); + contexts.set(request, pending); + } + return pending; + }; + const base = rpc.basePath; + for (const route of routes) { + const { proc, http } = route; + const hooks = rpc.router.hooksFor(proc); + // The same call identity a packet-mode Context carries, so cross-cutting + // hooks read context.method/context.procedure on the delegated path too. + const rpcTarget = { method: `${route.unitKey}/${route.methodName}`, procedure: proc }; + // Arity matters to fastify: an async hook with a third parameter is + // read as callback-style and refused — so the payload-less phases get + // two-parameter wrappers, and only the payload phases take three. + const wrap = (list, payloadOf) => { + const wrapped = []; + for (const hook of list) { + wrapped.push(async (request, reply) => { + const { context } = await contextOf(request, rpcTarget); + return void (await hook(context, payloadOf(request))); + }); + } + return wrapped; + }; + const wrapPayload = (list) => { + const wrapped = []; + for (const hook of list) { + wrapped.push(async (request, reply, payload) => { + const { context } = await contextOf(request, rpcTarget); + return void (await hook(context, payload)); + }); + } + return wrapped; + }; + // Replacement semantics for the two payload-shaping phases: a wrpc hook + // returning undefined keeps the payload, anything else replaces it — + // translated to fastify's "the returned value IS the payload". + const wrapShaping = (list) => { + const wrapped = []; + for (const hook of list) { + wrapped.push(async (request, reply, payload) => { + const { context } = await contextOf(request, rpcTarget); + const replaced = await hook(context, payload); + return replaced === undefined ? payload : replaced; + }); + } + return wrapped; + }; + const init = async (request, reply) => { + const { release } = await contextOf(request, rpcTarget); + // The response's close is the eviction signal, whether the reply was + // sent, hijacked or the peer vanished. + reply.raw?.on?.('close', release); + }; + // wrpc order: onRequest -> session restore -> ACCESS -> preValidation. + // The session is restored inside delegatedContext (init), so the gate + // closes the onRequest phase. + const accessGuard = async (request) => { + const { client } = await contextOf(request, rpcTarget); + if (proc.access !== 'public' && !client.session) throw forbidden(); + }; + const status = http.status ?? 200; + // With `restErrors: 'app'` the app owns the error format, so the + // wire-shaped default error responses must not be documented — they + // would re-serialize the app's error bodies into wrpc's shape. + const schema = options.restErrors === 'app' ? (proc.schema ?? undefined) : effectiveSchema(proc); + fastify.route({ + method: http.method, + url: `${base}${http.path === '/' ? '' : http.path}` || '/', + config: { wrpc: true }, + schema, + onRequest: [init, ...wrap(hooks.onRequest, (request) => request), accessGuard], + preValidation: wrap(hooks.preValidation, argsOf), + preHandler: wrap(hooks.preHandler, argsOf), + preSerialization: wrapShaping(hooks.preSerialization), + onSend: wrapShaping(hooks.onSend), + onError: wrapPayload(hooks.onError), + onResponse: wrap(hooks.onResponse, () => null), + ...(options.restErrors === 'app' ? {} : { errorHandler: wireErrorHandler }), + handler: async (request, reply) => { + const { client, context, transport } = await contextOf(request, rpcTarget); + const otel = rpc.otel; + // The dispatcher's bracket, reproduced: a delegated route bypasses + // the packet path entirely, so without this the server emitted no + // span and no call metric for its whole REST surface — an + // instrumented-looking server with a hole where fastify routes are. + // Trace context arrives as real headers; mapped onto the synthetic + // packet's tp/ts, the writer's ordinary extract picks it up. + const packet = { type: 'call' }; + const parent = request.headers.traceparent; + if (typeof parent === 'string' && parent.length > 0) { + packet[TRACEPARENT] = parent; + const state = request.headers.tracestate; + if (typeof state === 'string' && state.length > 0) packet[TRACESTATE] = state; + } + const started = otel.enabled ? performance.now() : 0; + return otel.withSpan({ client, packet, target: rpcTarget.method }, async (handle) => { + let result; + try { + result = await proc.invokeBare(context, argsOf(request)); + } catch (error) { + // Mirror the numeric wrpc code onto fastify's statusCode so an + // app-owned error handler (restErrors: 'app') keeps the status. + if (error && typeof error.code === 'number' && error.statusCode === undefined) { + error.statusCode = error.code; + } + const code = typeof error?.code === 'number' ? error.code : 500; + otel.recordError(handle, error, code); + otel.endSpan(handle, { 'wrpc.status': 'error', 'rpc.wrpc.status_code': code }); + otel.recordCall(rpcTarget.method, 'error', code, otel.enabled ? performance.now() - started : undefined); + throw error; + } + otel.endSpan(handle, { 'wrpc.status': 'ok' }); + otel.recordCall(rpcTarget.method, 'ok', undefined, otel.enabled ? performance.now() - started : undefined); + // A login that called startSession queued its cookie on the + // transport nothing will flush — copy it onto the real reply. + if (transport.pendingCookies.length > 0) reply.header('set-cookie', transport.pendingCookies); + reply.code(status); + // 204 promises "no content": the result is discarded by contract. + if (status === 204) return reply.send(); + return result === undefined ? null : result; + }); + }, + }); + } +}; + +const wrpcFastify = async (fastify, options = {}) => { + const { cors = null, ws = {}, maxBodySize } = options; + // fastify's logger IS a pino, so it goes straight in: the core detects the + // structured shape and calls child()/info(entry, message) natively. + const logger = options.logger ?? fastify.log; + const rpc = options.rpc ?? new RpcServer(rpcOptions({ ...options, cors, logger })); + // Delegated REST routes exist FOR fastify's own serialization, schema and + // swagger; a codec.rest body (possibly a raw Buffer) would silently bypass + // fjs and preSerialization. Refusing loudly beats a route that documents + // JSON and ships msgpack — the core hosts serve binary REST natively. + // Checked on the resolved rpc so the options.rpc path is covered too. + if (rpc.codec?.rest && rpc.router.hasRestRoutes) { + throw new TypeError( + 'wrpcFastify: codec.rest and delegated REST routes (procedures with http mappings) are mutually exclusive — ' + + 'serve binary REST from a core host, or drop the http mappings under this plugin', + ); + } + const base = rpc.basePath; + const engine = resolveEngine(fastify, options); + + // ---- HTTP: fastify routes feeding the engine-agnostic core ------------- + + const handle = async (request, reply) => { + // Fastify already parsed the body; the core re-parses the JSON packet, + // so hand it back the text rather than an object. + const body = normalizeBody(request.body); + let settled = false; + await new Promise((resolve) => { + const call = { + method: request.method, + url: request.url, + headers: request.headers, + body, + remoteAddress: request.ip, + respond({ status, headers, body: payload }) { + if (settled) return; + settled = true; + reply.code(status); + eachHeader(headers ?? {}, (name, value) => { + // fastify derives Content-Length from the payload (and a + // compression plugin would invalidate a fixed one). + if (name.toLowerCase() === 'content-length') return; + reply.header(name, value); + }); + reply.send(payload ?? ''); + resolve(); + }, + // reply.raw, NOT request.raw: fastify closes the request stream as + // soon as the body is consumed, which is before the handler runs — + // wiring eviction to it destroyed every HTTP client at call start + // (rpc.clients was empty mid-call and handlers saw a false abort). + // The response closes when it is written or the peer disconnects, + // which is the signal the node shell uses too. + // Streaming bypasses fastify's reply machinery and writes straight + // to the raw response: an SSE body has no end for `reply.send` to + // wait for. `hijack()` tells fastify not to answer it as well. + stream(options) { + if (typeof reply.hijack === 'function') reply.hijack(); + settled = true; + resolve(); + return nodeStream(reply.raw)(options); + }, + onAbort(listener) { + reply.raw?.on?.('close', listener); + }, + }; + // A call that never answers (a stream packet over HTTP is rejected, + // but a handler can still hang) must not park the route forever — + // the core evicts the client through onAbort either way. + reply.raw?.on?.('close', resolve); + void rpc.handleHttpCall(call); + }); + return reply; + }; + + // Unlike the express and uws adapters, nothing here reads the request + // stream — fastify parses the body and hands over `request.body`, so its + // own `bodyLimit` (1 MiB by default) already guards these routes and + // rejects an oversized call with 413 FST_ERR_CTP_BODY_TOO_LARGE before the + // handler runs. `maxBodySize` only narrows that per route; left unset, the + // app's limit stands, because a plugin silently RAISING the host's body + // limit would be a security regression the app never asked for. + // The SSE endpoint is a static segment, so find-my-way prefers it over + // the parametric '/:unit/:method' it would otherwise fall into — where the + // core never sees it as an events request. + const routes = + base === '' ? ['/', rpc.eventsPath, '/:unit/:method'] : [base, rpc.eventsPath, `${base}/:unit/:method`]; + for (const url of routes) { + fastify.route({ + method: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + url, + // The marker the mirror feature skips: wrpc must not mirror itself. + config: { wrpc: true }, + ...(maxBodySize === undefined ? {} : { bodyLimit: maxBodySize }), + handler: handle, + }); + } + + // Procedures with an `http` mapping become native fastify routes — the + // full delegation described above. Registered AFTER the conventional + // routes; find-my-way prefers the more specific static/parametric shape + // per segment, so `${base}/projects/:orgId` wins over the generic + // `${base}/:unit/:method` where both could match. + registerRestRoutes(fastify, rpc, options); + + // Reverse engineering: the app's own routes become wrpc procedures + // (collected from here on — register this plugin before the routes it + // should mirror), dispatched through fastify.inject so the route's whole + // pipeline keeps running. + if (options.mirror) setupMirror(fastify, rpc, options); + + // ---- WebSocket: engine attach ------------------------------------------ + + const source = engine.attach({ + ...(engine.standalone ? {} : { server: fastify.server }), + ...ws, + verifyClient: createUpgradeGate({ rpc, cors, ws }), + }); + source.on('connection', (socket, req) => { + rpc.attachSocket(socket, { + headers: req.headers, + url: req.url ?? '', + remoteAddress: req.socket?.remoteAddress ?? socket.remoteAddress, + }); + }); + + fastify.decorate('wrpc', rpc); + fastify.addHook('preClose', async () => { + await rpc.close(); + engine.close(); + }); +}; + +// Escape fastify's encapsulation the way fastify-plugin does, without taking +// fastify-plugin as a dependency: the decorator and the routes belong to the +// app that registered the plugin, not to a child scope. +wrpcFastify[Symbol.for('skip-override')] = true; +wrpcFastify[Symbol.for('fastify.display-name')] = '@alexify/wrpc'; + +module.exports = { wrpcFastify, findUwsApp }; diff --git a/src/adapters/mirror.js b/src/adapters/mirror.js new file mode 100644 index 0000000..974b978 --- /dev/null +++ b/src/adapters/mirror.js @@ -0,0 +1,260 @@ +'use strict'; + +// Reverse engineering for the fastify adapter: existing fastify routes +// become wrpc procedures, without rewriting a single route. The generated +// procedure dispatches through `fastify.inject()` (light-my-request), so the +// route's WHOLE pipeline — onRequest, auth, schema validation, +// serialization — runs exactly as it does for a network request; wrpc adds +// addressability (`client.api.projects.create(...)` over ws/sse/worker) on +// top. +// +// Routes are collected with an `onRoute` hook while the app registers them +// (so the wrpc plugin must be registered BEFORE the routes it should +// mirror), and the units land on the router at `onReady` through the public +// `Router.addUnit`. +// +// Mirrored procedures deliberately carry NO `http` mapping and NO `schema`: +// the real endpoint already exists at the route's own path (outside +// basePath), and validation is the route's — wrpc validating again would be +// the double-validation this design exists to avoid. The route's origin is +// recorded in `meta` instead, and its JSON Schemas are distilled into a +// `signature` so `wrpc types` still types the mirrored methods. + +const { procedure } = require('../rpc/router.js'); + +const HTTP_METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']); + +const capitalize = (text) => (text.length === 0 ? text : text[0].toUpperCase() + text.slice(1)); + +// 'org-users' / 'org_users' -> 'orgUsers' +const toCamelCase = (text) => text.replace(/[-_]+(\w)/g, (_, letter) => letter.toUpperCase()); + +const isParam = (segment) => segment.startsWith(':'); + +// The default unit, two-pass. A parametric URL names its own unit: the +// LAST static segment before the first param — '/workspace/projects/:id' +// -> 'projects' — with one refinement: when the URL ends in +// '/:' the static is a qualifier of the param +// ('/projects/slug/:slug'), so the unit is the segment BEFORE it and the +// qualifier stays in the tail for naming. A parameterless URL is ambiguous +// ('/workspace/projects/archive': unit 'projects' + tail, or unit +// 'archive'?), so it consults the units the parametric routes established: +// the RIGHTMOST segment naming a known unit wins, and only without one +// does the last segment fall back to being the unit. Returns null for a +// URL with no static segment to name a unit after. +const defaultUnit = (segments, knownUnits = null) => { + let end = segments.length; + for (let i = 0; i < segments.length; i++) { + if (isParam(segments[i])) { + end = i; + break; + } + } + if (end === 0) return null; + if (end === segments.length && knownUnits) { + for (let i = segments.length - 1; i >= 0; i--) { + if (knownUnits.has(toCamelCase(segments[i]))) { + return { unit: toCamelCase(segments[i]), tailIndex: i + 1 }; + } + } + } + const qualifier = + end >= 2 && end === segments.length - 1 && capitalize(segments[end - 1]) === capitalize(segments[end].slice(1)); + const unitIndex = qualifier ? end - 2 : end - 1; + if (unitIndex < 0) return null; + return { unit: toCamelCase(segments[unitIndex]), tailIndex: unitIndex + 1 }; +}; + +// Reverse REST semantics: the verb implies the action, the tail refines it. +// POST /projects -> create GET /projects -> findAll +// GET /projects/:id -> findById GET /projects/archive -> findAllArchive +// GET /projects/slug/:slug -> findBySlug POST /:orgId/archive/:id -> createArchive +// PATCH -> update..., PUT -> replace..., DELETE -> delete... +// A name the semantics cannot express is what `config.wrpc.name` is for. +const defaultName = (method, tail) => { + const statics = []; + let lastParam = null; + for (const segment of tail) { + if (isParam(segment)) lastParam = segment.slice(1); + else statics.push(toCamelCase(segment)); + } + const endsWithParam = tail.length > 0 && isParam(tail[tail.length - 1]); + // '/slug/:slug' collapses: the static names the param, not a sub-resource. + if (endsWithParam && statics.length > 0 && capitalize(statics[statics.length - 1]) === capitalize(lastParam)) { + statics.pop(); + } + const suffix = statics.map(capitalize).join(''); + if (method === 'GET' || method === 'HEAD') { + return endsWithParam ? `find${suffix}By${capitalize(lastParam)}` : `findAll${suffix}`; + } + const verbs = { POST: 'create', PUT: 'replace', PATCH: 'update', DELETE: 'delete' }; + return `${verbs[method]}${suffix}`; +}; + +// --------------------------------------------------------------------------- +// JSON Schema -> the closed `signature` format (protocol.md), best-effort: +// anything the format cannot express becomes 'unknown', never a guess. + +const SIGNATURE_DEPTH = 4; + +const signatureType = (schema, depth = 0) => { + if (!schema || typeof schema !== 'object' || depth >= SIGNATURE_DEPTH) return 'unknown'; + const { type } = schema; + if (type === 'string' || type === 'number' || type === 'boolean' || type === 'null') return type; + if (type === 'integer') return 'number'; + if (type === 'array') return [signatureType(schema.items, depth + 1)]; + if (type === 'object') { + if (!schema.properties) return 'object'; + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + const fields = {}; + for (const key of Object.keys(schema.properties)) { + const name = required.has(key) ? key : `${key}?`; + Object.defineProperty(fields, name, { + value: signatureType(schema.properties[key], depth + 1), + enumerable: true, + writable: true, + configurable: true, + }); + } + return fields; + } + return 'unknown'; +}; + +const routeSignature = (schema) => { + if (!schema || typeof schema !== 'object') return null; + const args = {}; + if (schema.params) args.params = signatureType(schema.params); + if (schema.querystring ?? schema.query) args['query?'] = signatureType(schema.querystring ?? schema.query); + if (schema.body) args.body = signatureType(schema.body); + const success = schema.response?.[200] ?? schema.response?.[201] ?? schema.response?.['2xx']; + const signature = {}; + if (Object.keys(args).length > 0) signature.args = args; + if (success) signature.returns = signatureType(success); + return Object.keys(signature).length > 0 ? signature : null; +}; + +// --------------------------------------------------------------------------- + +const buildUrl = (url, params, query, stringify) => { + const segments = url.split('/'); + const parts = new Array(segments.length); + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + if (!isParam(segment)) { + parts[i] = segment; + continue; + } + const name = segment.slice(1); + const value = params?.[name]; + if (value === undefined) { + const error = new Error(`Missing path param '${name}' for ${url}`); + error.code = 400; + error.expose = true; + throw error; + } + parts[i] = encodeURIComponent(String(value)); + } + let built = parts.join('/'); + if (query && Object.keys(query).length > 0) { + const text = stringify ? stringify(query) : String(new URLSearchParams(query)); + if (text) built += `?${text}`; + } + return built; +}; + +const mirrorHandler = (fastify, route, mirror, stringify) => { + const { method, url } = route; + return async (context, { params = {}, query = {}, body, headers } = {}) => { + const response = await fastify.inject({ + method, + url: buildUrl(url, params, query, stringify), + ...(body === undefined ? {} : { payload: body }), + headers: { ...(mirror.headers ? await mirror.headers(context) : {}), ...headers }, + }); + const json = (response.headers['content-type'] ?? '').includes('json'); + const parsed = json ? response.json() : response.body; + if (response.statusCode >= 400) { + const error = new Error((json && parsed?.message) || `HTTP ${response.statusCode}`); + error.code = response.statusCode; + error.expose = true; + if (json && parsed?.details !== undefined) error.details = parsed.details; + throw error; + } + if (response.statusCode === 204) return undefined; + return parsed; + }; +}; + +// Wired by wrpcFastify when `options.mirror` is set. `mirror: true` takes +// every default; an object refines them. +const setupMirror = (fastify, rpc, options) => { + const mirror = options.mirror === true ? {} : options.mirror; + const stringify = options.querystring?.stringify ?? null; + const collected = []; + + fastify.addHook('onRoute', (route) => { + // The adapter's own routes carry config.wrpc === true; an OBJECT there + // is the app's per-route mirror config ({ unit, name }), and `false` + // opts a route out. HEAD twins and OPTIONS are transport furniture. + if (route.config?.wrpc === true) return; + const methods = Array.isArray(route.method) ? route.method : [route.method]; + for (const method of methods) { + if (method === 'HEAD' || method === 'OPTIONS' || !HTTP_METHODS.has(method)) continue; + if (route.url.includes('*')) continue; // a wildcard names no action + const entry = { method, url: route.url, schema: route.schema ?? null, config: route.config ?? {} }; + if (mirror.include && !mirror.include(entry)) continue; + if (entry.config.wrpc === false) continue; + collected.push(entry); + } + }); + + fastify.addHook('onReady', async () => { + const units = new Map(); + // Pass 1: parametric routes establish the unit vocabulary the + // parameterless ones disambiguate against. + const knownUnits = new Set(); + for (const route of collected) { + const segments = route.url.split('/').filter((segment) => segment.length > 0); + if (!segments.some(isParam)) continue; + const named = defaultUnit(segments); + if (named) knownUnits.add(named.unit); + } + for (const route of collected) { + const segments = route.url.split('/').filter((segment) => segment.length > 0); + const named = defaultUnit(segments, knownUnits); + const unitName = route.config.wrpc?.unit ?? mirror.unit?.(route) ?? named?.unit; + if (!unitName) continue; // '/', '/:id' — nothing to name a unit after + const methodName = + route.config.wrpc?.name ?? mirror.name?.(route) ?? defaultName(route.method, segments.slice(named.tailIndex)); + let unit = units.get(unitName); + if (!unit) { + unit = {}; + units.set(unitName, unit); + } + if (Object.hasOwn(unit, methodName)) { + throw new TypeError( + `wrpcFastify mirror: '${unitName}/${methodName}' is claimed by both ` + + `${route.method} ${route.url} and an earlier route — set config.wrpc.name on one`, + ); + } + const signature = routeSignature(route.schema); + Object.defineProperty(unit, methodName, { + value: procedure({ + access: mirror.access ?? 'session', + meta: { mirrored: { method: route.method, path: route.url } }, + ...(signature ? { signature } : {}), + handler: mirrorHandler(fastify, route, mirror, stringify), + }), + enumerable: true, + writable: true, + configurable: true, + }); + } + for (const [unitName, definition] of units) { + rpc.router.addUnit(unitName, definition); + } + }); +}; + +module.exports = { setupMirror, defaultName, defaultUnit, routeSignature }; diff --git a/src/adapters/uws.js b/src/adapters/uws.js new file mode 100644 index 0000000..43465ca --- /dev/null +++ b/src/adapters/uws.js @@ -0,0 +1,423 @@ +'use strict'; + +const { EventEmitter } = require('node:events'); + +const { MAX_BODY_SIZE, statusLine, eachHeader } = require('./common.js'); + +// uWebSockets.js engine adapter. The module itself is NEVER a dependency of +// @alexify/wrpc (zero-dependency rule) — the caller injects it: +// +// const { createUwsEngine } = require('@alexify/wrpc/uws'); +// const engine = createUwsEngine({ uws: require('uWebSockets.js') }); +// const server = new Server({ router, engine, port: 8000 }); +// +// This is a *standalone* engine: uws owns the whole network stack, node:http +// included, so the Server shell creates no http server and routes its HTTP +// calls through the `onHttpCall` attach option instead. + +// uws send() status codes. +const BACKPRESSURE = 0; +const SUCCESS = 1; +const DROPPED = 2; + +const DEFAULT_IDLE_TIMEOUT = 120; // seconds; uws' own liveness mechanism +const DEFAULT_MAX_PAYLOAD = 16 * 1024 * 1024; + +// getRemoteAddressAsText hands back the formatted address as an ArrayBuffer; +// it is empty for a socket that is already gone. +const decodeAddress = (value) => Buffer.from(value).toString(); + +const copyBytes = (arrayBuffer) => Buffer.from(new Uint8Array(arrayBuffer)); + +const isTemplatedApp = (app) => + typeof app === 'object' && + app !== null && + typeof app.ws === 'function' && + typeof app.any === 'function' && + typeof app.listen === 'function'; + +// A node IncomingMessage look-alike: enough for verifyClient gates, the +// Server shell's path/origin checks, and RpcServer.attachSocket's meta. +const createUpgradeRequest = (path, query, headers, remoteAddress) => ({ + method: 'GET', + url: query ? `${path}?${query}` : path, + headers, + socket: { remoteAddress }, +}); + +// The protocol revision marker; must match the built-in engine's. +const WRPC_PROTOCOL = 'wrpc.v1'; + +// Mirrors WebsocketServer's negotiation: `false` from handleProtocols +// rejects the handshake, anything else selects (or declines) a subprotocol, +// and with no app configuration the wrpc revision is echoed when offered. +const negotiateProtocol = (header, { protocols, handleProtocols }, request) => { + if (!header) return ''; + const offered = header + .split(',') + .map((token) => token.trim()) + .filter(Boolean); + if (handleProtocols) { + const selected = handleProtocols(offered, request); + if (selected === false) return false; + return selected || ''; + } + if (protocols) return offered.find((name) => protocols.includes(name)) ?? ''; + if (offered.includes(WRPC_PROTOCOL)) return WRPC_PROTOCOL; + return ''; +}; + +/** + * WrpcSocket over a uws WebSocket. + * + * The uws handle is invalid the moment its close handler runs — every method + * on it throws "Invalid access of closed uWS.WebSocket" afterwards — so every + * call here is gated on #closed rather than trusting the caller's timing. + */ +class UwsSocket extends EventEmitter { + #ws; + #closed = false; + + constructor(ws, { remoteAddress = '', protocol = '' } = {}) { + super(); + this.#ws = ws; + this.remoteAddress = remoteAddress; + this.protocol = protocol; + } + + get closed() { + return this.#closed; + } + + get bufferedAmount() { + if (this.#closed) return 0; + try { + return this.#ws.getBufferedAmount(); + } catch { + return 0; + } + } + + send(data) { + if (this.#closed) return false; + const isBinary = typeof data !== 'string'; + let status; + try { + status = this.#ws.send(data, isBinary); + } catch { + // Raced a close between the guard and the call. + return false; + } + if (status === DROPPED) { + // uws silently discarded the message (maxBackpressure hit with + // closeOnBackpressureLimit off). A hole in the frame stream corrupts + // the RPC protocol, so fail loudly instead of continuing. + this.emit('error', new Error('uws dropped an outgoing message: backpressure limit exceeded')); + this.terminate(); + return false; + } + return status === SUCCESS; + } + + close(code = 1000, reason = '') { + if (this.#closed) return; + try { + this.#ws.end(code, reason); + } catch { + // already gone + } + } + + terminate() { + if (this.#closed) return; + try { + this.#ws.close(); + } catch { + // already gone + } + } + + // Called from the uws close handler; after this the handle is poison. + markClosed(code, reason) { + if (this.#closed) return; + this.#closed = true; + this.emit('close', code, reason); + } +} + +const createUwsEngine = (engineOptions = {}) => { + const { + uws = null, + app: providedApp = null, + ssl = null, + idleTimeout = DEFAULT_IDLE_TIMEOUT, + maxPayloadLength = DEFAULT_MAX_PAYLOAD, + // Finite by default, matching the built-in engine: with 0 uws buffers an + // unresponsive peer without limit. One biggest-allowed message in + // flight; past it the adapter terminates loudly (see the DROPPED path). + maxBackpressure = maxPayloadLength, + closeOnBackpressureLimit = false, + maxLifetime = 0, + compression = null, + sendPingsAutomatically = true, + maxBodySize = MAX_BODY_SIZE, + } = engineOptions; + + if (!providedApp && (typeof uws !== 'object' || uws === null || typeof uws.App !== 'function')) { + throw new TypeError( + 'createUwsEngine: pass the uWebSockets.js module as options.uws ' + + '(it is injected, never a dependency of @alexify/wrpc), or an existing app as options.app', + ); + } + if (providedApp && !isTemplatedApp(providedApp)) { + throw new TypeError('createUwsEngine: options.app does not look like a uWebSockets.js TemplatedApp'); + } + + // Only an app we created is ours to tear down; one handed in by fastify-uws + // belongs to fastify. + const ownsApp = !providedApp; + const app = providedApp ?? (ssl ? uws.SSLApp(ssl) : uws.App()); + const source = new EventEmitter(); + const sockets = new Set(); + let listenSocket = null; + let stoppedListening = false; + let closed = false; + let attached = false; + + const behavior = (attachOptions) => { + const { verifyClient, protocols, handleProtocols } = attachOptions; + return { + idleTimeout, + maxPayloadLength, + maxBackpressure, + closeOnBackpressureLimit, + maxLifetime, + sendPingsAutomatically, + ...(compression === null ? {} : { compression }), + + upgrade(res, req, context) { + // `req` is only valid synchronously, before the first await or the + // upgrade call — read everything off it up front. + const path = req.getUrl(); + const query = req.getQuery() ?? ''; + const headers = {}; + req.forEach((key, value) => { + headers[key] = value; + }); + const key = req.getHeader('sec-websocket-key'); + const offeredProtocols = req.getHeader('sec-websocket-protocol'); + const extensions = req.getHeader('sec-websocket-extensions'); + // The ws handle reports an empty address after upgrade; the response + // still knows it here, so capture it while we can. + const remoteAddress = decodeAddress(res.getRemoteAddressAsText()); + + let aborted = false; + res.onAborted(() => { + aborted = true; + }); + + const request = createUpgradeRequest(path, query, headers, remoteAddress); + const reject = (status) => { + if (aborted) return; + res.cork(() => { + res.writeStatus(statusLine(status)); + res.end(); + }); + }; + if (closed) return void reject(503); + if (verifyClient && !verifyClient({ req: request, socket: null, head: null })) { + return void reject(403); + } + const protocol = negotiateProtocol(offeredProtocols, { protocols, handleProtocols }, request); + if (protocol === false) return void reject(400); + if (aborted) return; + res.upgrade({ request, protocol, remoteAddress }, key, protocol, extensions, context); + }, + + open(ws) { + const data = ws.getUserData(); + const socket = new UwsSocket(ws, data); + data.socket = socket; + sockets.add(socket); + source.emit('connection', socket, data.request); + }, + + message(ws, message, isBinary) { + // The ArrayBuffer is neutered when this callback returns, and the + // RPC core hands payloads to async stream consumers — copy. + ws.getUserData().socket?.emit('message', copyBytes(message), isBinary); + }, + + drain(ws) { + ws.getUserData().socket?.emit('drain'); + }, + + ping(ws, message) { + ws.getUserData().socket?.emit('ping', copyBytes(message)); + }, + + pong(ws, message) { + ws.getUserData().socket?.emit('pong', copyBytes(message)); + }, + + close(ws, code, message) { + const { socket } = ws.getUserData(); + if (!socket) return; + sockets.delete(socket); + socket.markClosed(code, copyBytes(message).toString()); + }, + }; + }; + + const httpHandler = (onHttpCall) => (res, req) => { + const method = req.getCaseSensitiveMethod().toUpperCase(); + const path = req.getUrl(); + const query = req.getQuery() ?? ''; + const headers = {}; + req.forEach((key, value) => { + headers[key] = value; + }); + const remoteAddress = decodeAddress(res.getRemoteAddressAsText()); + // `req` is dead from here on. + + let aborted = false; + let responded = false; + const abortListeners = []; + res.onAborted(() => { + aborted = true; + for (const listener of abortListeners) listener(); + }); + + const respond = ({ status, headers: responseHeaders = {}, body }) => { + if (aborted || responded) return; + responded = true; + res.cork(() => { + res.writeStatus(statusLine(status)); + eachHeader(responseHeaders, (name, value) => { + // uws derives Content-Length from end(); writing our own would + // emit it twice. + if (name.toLowerCase() === 'content-length') return; + res.writeHeader(name, value); + }); + res.end(body ?? ''); + }); + }; + + const call = { + method, + url: query ? `${path}?${query}` : path, + headers, + body: null, + remoteAddress, + respond, + onAbort: (listener) => void abortListeners.push(listener), + }; + + const chunks = []; + let size = 0; + let overflowed = false; + res.onData((chunk, isLast) => { + if (aborted || overflowed) return; + if (chunk.byteLength > 0) { + size += chunk.byteLength; + if (size > maxBodySize) { + overflowed = true; + const packet = { type: 'callback', id: '', error: { message: 'Body size limit exceeded', code: 400 } }; + return void respond({ + status: 400, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(packet), + }); + } + chunks.push(copyBytes(chunk)); + } + if (!isLast) return; + if (chunks.length === 1) call.body = chunks[0]; + else if (chunks.length > 1) call.body = Buffer.concat(chunks, size); + void onHttpCall(call); + }); + }; + + return { + name: 'uws', + standalone: true, + capabilities: { + backpressure: true, + // uws owns liveness through idleTimeout + sendPingsAutomatically, so + // the RPC layer must not run a ping loop of its own. + ping: false, + // uws.DISABLED is 0, so a falsy compressor means no deflate. + deflate: Boolean(compression), + cork: true, + // No receive-side flow control: uws exposes no socket-level pause, so + // a fast uploader is not throttled by a slow stream consumer. + pause: false, + }, + app, + + // attachOptions: { path, verifyClient, protocols, handleProtocols, + // onHttpCall } — no `server`, this engine owns the network stack. + attach(attachOptions = {}) { + if (attached) throw new Error('createUwsEngine: this engine is already attached'); + attached = true; + const { path = '/*', onHttpCall } = attachOptions; + app.ws(path, behavior(attachOptions)); + // Without onHttpCall the host framework (fastify) owns HTTP routing + // and we only take over the upgrade path. + if (onHttpCall) app.any('/*', httpHandler(onHttpCall)); + return source; + }, + + listen({ host = '0.0.0.0', port = 0 } = {}) { + return new Promise((resolve, reject) => { + app.listen(host, port, (token) => { + if (!token) { + // uws reports only success/failure, never a reason. In practice + // a refused bind is a busy port, and tagging it EADDRINUSE keeps + // the Server shell's bind-retry working. + const error = new Error(`Failed to bind ${host}:${port}`); + error.code = 'EADDRINUSE'; + return void reject(error); + } + listenSocket = token; + const bound = typeof uws?.us_socket_local_port === 'function' ? uws.us_socket_local_port(token) : port; + resolve({ address: host, family: host.includes(':') ? 'IPv6' : 'IPv4', port: bound }); + }); + }); + }, + + // The listener-only phase of a graceful shutdown: new connections are + // refused while the sockets already accepted keep working — the same + // intake-first ordering the node boot gets from httpServer.close(). + // Optional in the engine port; feature-detected by the Server shell. + stopListening() { + if (closed || !listenSocket) return; + if (typeof uws?.us_listen_socket_close === 'function') { + uws.us_listen_socket_close(listenSocket); + stoppedListening = true; + } + listenSocket = null; + }, + + close({ code = 1001, reason = 'Server is closing' } = {}) { + if (closed) return; + closed = true; + for (const socket of sockets) socket.close(code, reason); + sockets.clear(); + // app.close() closes the app's listen sockets itself, so closing the + // token first would be a double free — a native SIGSEGV, not an error. + // Only an app we do NOT own (fastify-uws) leaves the token to us, and + // in that case we never opened one anyway. After stopListening() the + // token is already closed, so app.close() must NOT run again. + if (ownsApp && !stoppedListening) { + app.close(); + } else if (listenSocket && typeof uws?.us_listen_socket_close === 'function') { + uws.us_listen_socket_close(listenSocket); + } + listenSocket = null; + source.emit('close'); + }, + }; +}; + +module.exports = { createUwsEngine, UwsSocket, BACKPRESSURE, SUCCESS, DROPPED }; diff --git a/src/auth/index.js b/src/auth/index.js new file mode 100644 index 0000000..6adc7d1 --- /dev/null +++ b/src/auth/index.js @@ -0,0 +1,266 @@ +'use strict'; + +// The @alexify/wrpc/auth subpath: ready-made token STORES for the client, +// ready-made token TRANSPORTS for the server, and bearerAuth() — the +// composition of the client's authenticate/refresh/headers options around a +// store. Deliberately outside the base browser bundle: the seams these plug +// into (client hooks, sessions.transport) live in the core, the strategies +// do not — the same reasoning that keeps the rooms backplane in ./scaling. +// +// Browser-safe by construction: no node builtins, and the ONLY require is +// src/wire.js — the import-free wire-name constants (esbuild inlines them, +// so the subpath stays ~1 KB) — one file serves both sides, like ./query. + +const { META_PARAM, META_HEADER, META_PREFIX } = require('../wire.js'); + +// --------------------------------------------------------------------------- +// Client-side token stores. The contract is three functions, sync or async +// (IndexedDB is only reachable through async, so every caller awaits): +// { get(key), set(key, value), delete(key) } +// A Map already satisfies it — which is what memoryStore returns. + +const isTokenStore = (value) => + Boolean(value) && + typeof value.get === 'function' && + typeof value.set === 'function' && + typeof value.delete === 'function'; + +/** In-memory, per-tab: a Map IS the contract. */ +const memoryStore = () => new Map(); + +/** + * localStorage / sessionStorage (or anything getItem/setItem/removeItem + * shaped), values JSON-encoded so token PAIRS store as one entry. + */ +const webStorage = (storage, { prefix = 'wrpc:' } = {}) => ({ + get: (key) => { + const raw = storage.getItem(prefix + key); + if (typeof raw !== 'string') return undefined; + try { + return JSON.parse(raw); + } catch { + return undefined; + } + }, + set: (key, value) => void storage.setItem(prefix + key, JSON.stringify(value)), + delete: (key) => void storage.removeItem(prefix + key), +}); + +/** + * A JS-readable cookie as the store (document.cookie). NOT the session + * cookie the server sets — that one is HttpOnly and belongs to the cookie + * token transport; this is for apps that want tokens to survive a reload + * without localStorage. Values are percent-encoded JSON. + */ +const cookieStorage = (doc, { prefix = 'wrpc:', maxAge = 31536000, path = '/', secure = true } = {}) => ({ + get: (key) => { + const name = `${encodeURIComponent(prefix + key)}=`; + for (const part of String(doc.cookie ?? '').split('; ')) { + if (!part.startsWith(name)) continue; + try { + return JSON.parse(decodeURIComponent(part.slice(name.length))); + } catch { + return undefined; + } + } + return undefined; + }, + // `Secure` by default, matching the server cookie's own default: this + // store is script-readable by design (that is the XSS tradeoff the doc + // comment above owns), but a token pair must never ALSO travel in the + // clear. `secure: false` is for localhost development only. The delete + // stamp carries the same attributes — a cookie only clears on a match. + set: (key, value) => { + const body = `${encodeURIComponent(prefix + key)}=${encodeURIComponent(JSON.stringify(value))}`; + doc.cookie = `${body}; Path=${path}; Max-Age=${maxAge}; SameSite=Lax${secure ? '; Secure' : ''}`; + }, + delete: (key) => { + doc.cookie = `${encodeURIComponent(prefix + key)}=; Path=${path}; Max-Age=0${secure ? '; Secure' : ''}`; + }, +}); + +// --------------------------------------------------------------------------- +// bearerAuth: the client-side composition this subpath exists for. Wires a +// store, the app's signIn and (optionally) its refresh into the THREE client +// options that make bearer work end to end: +// headers presents `Authorization: Bearer ` on every open, so +// the server's bearer transport restores the session before +// any packet is dispatched (over ws it rides the wrpc_h +// connect-URL parameter — see docs/guide/metadata). +// authenticate awaited before the reconnect restore; signs in when the +// store holds nothing. +// refresh single-flight, one-shot retry; the handler receives the +// stored tokens, stores what it returns, clears on failure. +// +// const client = await connect(url, bearerAuth({ +// store: webStorage(localStorage), +// signIn: (client) => client.call('auth/signIn', credentials()), +// refresh: (client, tokens) => client.call('auth/refresh', { token: tokens.refresh }), +// on: [401, 403], +// })); +// +// signIn and refresh return the token pair to store ({ access, ... }) or a +// falsy value to store nothing. Mid-socket renewal is the server handler's +// half: an auth/refresh procedure that calls context.client.startSession +// re-binds the LIVE connection while the stored pair covers the next one. +const bearerAuth = ({ store, signIn, refresh, key = 'tokens', on }) => { + if (!isTokenStore(store)) { + throw new TypeError('bearerAuth: store must provide get/set/delete (a Map qualifies)'); + } + if (typeof signIn !== 'function') throw new TypeError('bearerAuth: signIn must be a function'); + const options = { + headers: async () => { + const tokens = await store.get(key); + return tokens?.access ? { authorization: `Bearer ${tokens.access}` } : {}; + }, + authenticate: async (client, info) => { + const tokens = await store.get(key); + // A stored token already rode the connection headers; the upgrade + // restore did the work. Nothing stored means nobody: sign in. + if (tokens?.access) return; + const fresh = await signIn(client, info); + if (fresh) await store.set(key, fresh); + }, + }; + if (typeof refresh === 'function') { + options.refresh = { + // wrpc's own "no session" refusal is 403; app handlers usually 401. + on: Array.isArray(on) && on.length > 0 ? on : [401, 403], + handler: async (client, error) => { + const tokens = await store.get(key); + let next; + try { + next = await refresh(client, tokens, error); + } catch (refreshError) { + // A THROWING refresh clears the store too: the pair it holds just + // proved dead, and leaving it in place would make the next + // reconnect's authenticate short-circuit on a corpse forever. + await store.delete(key); + throw refreshError; + } + if (next) await store.set(key, next); + else await store.delete(key); + }, + }; + } + return options; +}; + +// --------------------------------------------------------------------------- +// Server-side token transports (the sessions.transport injection). Both are +// non-ambient: the credential is script-attached, so the safe-method CSRF +// rule does not apply and write() has nothing to stamp. + +const MAX_DECLARED = 2048; +// ^ Mirrors the server's metaMaxBytes DEFAULT, for the raw fallbacks below +// only: every core call site hands read() the already-parsed `declared` / +// `meta` bags, capped on the CONFIGURABLE limit — the fallbacks serve +// callers with no parsed bag (the SSE channel key, custom hosts). + +// The ws leg cannot send real upgrade headers, so a declared bag arrives in +// a connect-URL parameter; real headers win, as always. Raw-fallback only — +// see MAX_DECLARED above. +const declaredFromUrl = (url, param) => { + const query = typeof url === 'string' ? url.slice(url.indexOf('?') + 1) : ''; + if (!query || query === url || query.length > MAX_DECLARED) return null; + let raw = null; + try { + raw = new URLSearchParams(query).get(param); + } catch { + return null; + } + if (!raw) return null; + try { + const value = JSON.parse(raw); + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null; + } catch { + return null; + } +}; + +// The ws subprotocol carrier. The browser WebSocket constructor cannot set +// an Authorization header, and the connect URL lands in proxy access logs — +// but RFC 6455 lets the client OFFER subprotocols, which travel as a real +// upgrade header. The ws transport offers `wrpc.bearer.` next to the +// wire revision; this is where the server reads it back. +const SUBPROTOCOL_PREFIX = 'wrpc.bearer.'; + +/** + * Session token in `Authorization: ` — the observed header + * on http/sse, the `wrpc.bearer.` subprotocol offer on ws (kept out + * of the connect URL and its access logs), or the declared bag the core + * parsed from wrpc_h. + */ +const bearerTransport = ({ scheme = 'Bearer' } = {}) => { + const marker = `${scheme} `; + const fromValue = (value) => + typeof value === 'string' && value.startsWith(marker) ? value.slice(marker.length) : null; + return { + ambient: false, + read: ({ headers, declared }) => { + const observed = fromValue(headers?.authorization); + if (observed) return observed; + const offered = headers?.['sec-websocket-protocol']; + if (typeof offered === 'string' && offered.length > 0) { + for (const part of offered.split(',')) { + const name = part.trim(); + if (name.startsWith(SUBPROTOCOL_PREFIX)) return name.slice(SUBPROTOCOL_PREFIX.length); + } + } + // The bag the core already parsed and capped (wrpc_h included) — this + // file never re-derives it, so it cannot drift from the core parser. + return fromValue(declared?.authorization); + }, + write: () => null, + }; +}; + +// The core kebab-normalizes meta keys; the simple camel split below matches +// utils.toKebab for ordinary field names ('authToken' -> 'auth-token'). +// Give `field` in kebab spelling outright if its casing is exotic. +const kebabField = (field) => field.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); + +/** + * Session token as a field of the declared connection metadata (the client's + * `meta` option / the x-wrpc-meta header, either spelling): `meta: { token }`. + */ +const payloadTransport = ({ field = 'token' } = {}) => { + const name = kebabField(field); + return { + ambient: false, + read: ({ headers, url, meta }) => { + // The bag the core already parsed: both header spellings and the ws + // query merged, kebab-keyed, capped on the configurable metaMaxBytes. + const parsed = meta?.[name] ?? meta?.[field]; + if (typeof parsed === 'string' && parsed.length > 0) return parsed; + // Raw fallbacks, for callers with no parsed bag: the per-key header, + // the canonical JSON header, the ws connect-URL parameter. + const prefixed = headers?.[META_PREFIX + name]; + if (typeof prefixed === 'string' && prefixed.length > 0) return prefixed; + const header = headers?.[META_HEADER]; + let declared = null; + if (typeof header === 'string' && header.length > 0 && header.length <= MAX_DECLARED) { + try { + declared = JSON.parse(decodeURIComponent(header)); + } catch { + declared = null; + } + if (typeof declared !== 'object' || Array.isArray(declared)) declared = null; + } + declared ??= declaredFromUrl(url, META_PARAM); + const token = declared?.[field] ?? declared?.[name]; + return typeof token === 'string' && token.length > 0 ? token : null; + }, + write: () => null, + }; +}; + +module.exports = { + isTokenStore, + memoryStore, + webStorage, + cookieStorage, + bearerAuth, + bearerTransport, + payloadTransport, +}; diff --git a/src/cli/types.js b/src/cli/types.js new file mode 100644 index 0000000..2d5f4ae --- /dev/null +++ b/src/cli/types.js @@ -0,0 +1,627 @@ +'use strict'; + +// `wrpc types --out api.d.ts` — the codegen half of F6. +// +// A running server already describes itself: `system/introspect` answers with +// { unitKey: { method: { access, kind?, meta?, signature? } } }. This turns +// that into the same shape of interface a user would otherwise hand-write for +// `connect()`, so the two halves of the typed client meet in the middle: +// hand-write the contract when you want exact types, generate it when you do +// not want to write it at all. +// +// The generated types are only as precise as the `signature` descriptors the +// router carries. That is deliberate — the descriptor is a coarse, closed +// format (see TYPE_NAMES) rather than an open schema language, because every +// byte of it arrives over the network and ends up inside a file the user +// compiles. Nothing from the wire is ever interpolated verbatim: names are +// quoted through quoteKey() and types are matched against an allowlist. + +const { writeFile } = require('node:fs/promises'); + +const DEFAULT_INTERFACE = 'Api'; +const DEFAULT_PACKAGE = '@alexify/wrpc'; +const MARKER = 'SubscriptionContract'; + +// A descriptor is data, not code: only these names may reach the output, and +// anything else degrades to `unknown` with a warning. JSON has no Date, so +// neither does this list. +const TYPE_NAMES = new Set(['string', 'number', 'boolean', 'unknown', 'any', 'object', 'null', 'never']); + +// A hostile (or generated-from-a-cycle) descriptor must not be able to blow +// the stack or the output size. +const MAX_DEPTH = 8; + +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +// 'string', 'number[]', 'boolean[][]' — a base name plus array suffixes. +const TYPE_REF = /^([A-Za-z]+)((?:\[\])*)$/; + +const USAGE = `wrpc types [options] + +Generates a TypeScript contract interface from a running wrpc server, for use +with connect(url). + +Arguments: + The server's wrpc endpoint, e.g. http://localhost:8000/api + +Options: + --out Write the declaration file here; '-' writes to stdout + --units Only these units (repeatable, or comma-separated) + --interface Name of the generated interface (default: ${DEFAULT_INTERFACE}) + --package Where to import ${MARKER} from (default: ${DEFAULT_PACKAGE}) + --schema Also write the raw introspection as a module for + client.use(); '-' writes to stdout (fed by the same + fetch as the types) + --openapi Also write an OpenAPI 3 document projected from the + procedures that declare an 'http' mapping; '-' writes + to stdout (same fetch again) + --format Module format for --schema (default: cjs) + -h, --help Show this help + -v, --version Show the wrpc version + +Examples: + wrpc types http://localhost:8000/api --out api.d.ts + wrpc types http://localhost:8000/api --units chat,auth.v1 --out src/api.d.ts + wrpc types http://localhost:8000/api --out api.d.ts --schema api.static.js + wrpc types http://localhost:8000/api --out -`; + +class CliError extends Error {} + +const fail = (message) => { + throw new CliError(message); +}; + +// --------------------------------------------------------------------------- +// argv + +const FLAGS_WITH_VALUE = new Set(['--out', '--units', '--interface', '--package', '--schema', '--format', '--openapi']); + +const parseArgs = (argv) => { + const options = { units: [], interfaceName: DEFAULT_INTERFACE, packageName: DEFAULT_PACKAGE }; + const positional = []; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--help' || arg === '-h') return { help: true }; + if (arg === '--version' || arg === '-v') return { version: true }; + // Everything after '--' is positional, even if it looks like a flag. + if (arg === '--') { + for (let n = i + 1; n < argv.length; n++) positional.push(argv[n]); + break; + } + if (FLAGS_WITH_VALUE.has(arg)) { + const value = argv[i + 1]; + // A missing value must not silently swallow the next flag: `--out --units x` + // would otherwise write a file called '--units'. A bare '-' is the one + // dash-leading value that IS a value — it means stdout. + if (value === undefined || (value.startsWith('-') && value !== '-')) fail(`${arg} requires a value`); + i++; + if (arg === '--out') options.out = value; + else if (arg === '--units') { + const names = value.split(','); + for (let n = 0; n < names.length; n++) options.units.push(names[n].trim()); + } else if (arg === '--interface') options.interfaceName = value; + else if (arg === '--schema') options.schema = value; + else if (arg === '--openapi') options.openapi = value; + else if (arg === '--format') options.format = value; + else options.packageName = value; + continue; + } + if (arg.startsWith('-') && arg !== '-') fail(`Unknown option ${arg}`); + positional.push(arg); + } + const [command, url, ...extra] = positional; + if (!command) fail('Missing command. Try `wrpc types --out api.d.ts`.'); + if (command !== 'types') fail(`Unknown command '${command}'. The only command is 'types'.`); + if (!url) fail('Missing . Try `wrpc types http://localhost:8000/api --out api.d.ts`.'); + if (extra.length > 0) fail(`Unexpected argument '${extra[0]}'`); + if (!options.out) fail("Missing --out . Use '--out -' to write to stdout."); + // An empty --units would filter every unit away and generate an empty + // interface, which looks like a working run and is not one. + if (options.units.some((unit) => unit === '')) fail('--units must not contain an empty unit name'); + if (!IDENTIFIER.test(options.interfaceName)) { + fail(`--interface must be a valid TypeScript identifier, got '${options.interfaceName}'`); + } + if (options.format !== undefined) { + if (options.schema === undefined) fail('--format only applies to --schema'); + if (options.format !== 'cjs' && options.format !== 'esm') { + fail(`--format must be 'cjs' or 'esm', got '${options.format}'`); + } + } + // Two artifacts cannot share one stdout: the result would be a .d.ts and a + // module concatenated into something that is neither. + const stdouts = [options.out, options.schema, options.openapi].filter((target) => target === '-'); + if (stdouts.length > 1) fail("only one of --out, --schema and --openapi may write to stdout ('-')"); + if (options.schema !== undefined && options.format === undefined) options.format = 'cjs'; + return { ...options, url }; +}; + +// --------------------------------------------------------------------------- +// rendering + +/** + * Bare when it is an identifier, a quoted string literal otherwise. The + * escaping is JSON.stringify's — that is what makes a name arriving from the + * wire safe to put in a file — and the quotes are then downgraded to single + * ones to match the rest of the codebase whenever nothing needed escaping. + * (Nothing needed escaping exactly when the body holds no backslash: a `"` + * would have become `\"`, a newline `\n`.) + */ +const quoteKey = (name) => { + if (IDENTIFIER.test(name)) return name; + const quoted = JSON.stringify(name); + const body = quoted.slice(1, -1); + return body.includes('\\') || body.includes("'") ? quoted : `'${body}'`; +}; + +// A comment is the one place free-form text (meta.description, the source URL) +// reaches the output, so it must not be able to close the comment or start a +// new line of code. +const commentText = (text) => + String(text) + .replace(/\*\//g, '* /') + // Matching control characters is the entire point: a newline (or a line + // separator, which JSON does not escape) would end the comment and turn + // whatever follows into code. + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, ' ') + .trim(); + +/** The endpoint, with any credentials and query string left out of the file. */ +const sourceLabel = (url) => { + try { + const parsed = new URL(url); + return commentText(`${parsed.origin}${parsed.pathname}`); + } catch { + return commentText(url); + } +}; + +const renderTypeName = (text, warn) => { + const parts = text.split('|').map((part) => part.trim()); + const rendered = []; + for (const part of parts) { + const match = TYPE_REF.exec(part); + if (!match || !TYPE_NAMES.has(match[1])) { + warn(`unknown type '${text}' in a signature; using 'unknown'`); + return 'unknown'; + } + rendered.push(match[1] + match[2]); + } + return rendered.join(' | '); +}; + +const isFieldMap = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * A descriptor shape is a type name ('string', 'number[]', 'string|number'), a + * one-element array (an array of that shape), or a field map whose keys may end + * in '?' to mark the field optional. + */ +const renderType = (shape, warn, depth = 0) => { + if (depth > MAX_DEPTH) { + warn(`signature nested deeper than ${MAX_DEPTH}; using 'unknown'`); + return 'unknown'; + } + if (typeof shape === 'string') return renderTypeName(shape, warn); + if (Array.isArray(shape)) { + if (shape.length !== 1) { + warn("an array in a signature describes an array OF one shape, so it holds exactly one entry; using 'unknown'"); + return 'unknown'; + } + return `Array<${renderType(shape[0], warn, depth + 1)}>`; + } + if (isFieldMap(shape)) { + const fields = []; + // Object.entries, never a lookup by name: a descriptor arriving as JSON + // can carry a '__proto__' key, and it must be read as data. + for (const [rawKey, value] of Object.entries(shape)) { + const optional = rawKey.endsWith('?'); + const name = optional ? rawKey.slice(0, -1) : rawKey; + if (name === '') { + warn('a signature field with an empty name was skipped'); + continue; + } + fields.push(`${quoteKey(name)}${optional ? '?' : ''}: ${renderType(value, warn, depth + 1)}`); + } + // No fields declared means exactly that. Write 'object' for "an object, + // contents unspecified" — `{}` in TypeScript means "anything but null". + if (fields.length === 0) return 'Record'; + return `{ ${fields.join('; ')} }`; + } + warn(`a signature shape must be a type name, an array, or an object, got ${typeof shape}; using 'unknown'`); + return 'unknown'; +}; + +const signatureOf = (info, target, warn) => { + const { signature } = info; + if (signature === undefined || signature === null) return null; + if (!isFieldMap(signature)) { + warn(`${target}: signature must be an object; ignoring it`); + return null; + } + return signature; +}; + +// The typed-event blocks: the introspection's `on` map (inbound handlers) +// becomes the contract's `sends` key — what typed client.sendEvent carries — +// and the declared `emits` map becomes `events`, the listener/responder +// types on the unit emitter. Same allowlisted signature language as calls. +const renderSends = (unitKey, inbound, warn) => { + if (!isFieldMap(inbound)) { + warn(`unit '${unitKey}' has a non-object 'on' block; skipped`); + return []; + } + const entries = []; + for (const name of Object.keys(inbound).sort()) { + const info = inbound[name]; + if (!isFieldMap(info)) continue; + const signature = signatureOf(info, `${unitKey}/on.${name}`, warn); + const args = signature?.args === undefined ? 'unknown' : renderType(signature.args, warn); + entries.push(` ${quoteKey(name)}: ${args};`); + } + if (entries.length === 0) return []; + return [' sends: {', ...entries, ' };']; +}; + +const renderEmits = (unitKey, emits, warn) => { + if (!isFieldMap(emits)) { + warn(`unit '${unitKey}' has a non-object 'emits' block; skipped`); + return []; + } + const entries = []; + for (const name of Object.keys(emits).sort()) { + const descriptor = emits[name]; + if (!isFieldMap(descriptor)) { + warn(`${unitKey}.emits.${name} is not a descriptor; skipped`); + continue; + } + const data = descriptor.data === undefined ? 'unknown' : renderType(descriptor.data, warn); + const returns = descriptor.returns === undefined ? 'void' : renderType(descriptor.returns, warn); + entries.push(` ${quoteKey(name)}: (data: ${data}) => ${returns};`); + } + if (entries.length === 0) return []; + return [' events: {', ...entries, ' };']; +}; + +const renderMethod = (unitKey, method, info, warn) => { + const target = `${unitKey}/${method}`; + const signature = signatureOf(info, target, warn); + const lines = []; + const description = info.meta?.description; + if (typeof description === 'string' && description.trim() !== '') { + lines.push(` /** ${commentText(description)} */`); + } + if (info.kind === 'subscription') { + if (signature?.returns !== undefined) { + warn(`${target}: 'returns' means nothing for a subscription (it yields values); use 'data'`); + } + // `any` rather than `unknown` for undeclared args: it is what lets both + // `subscribe()` and `subscribe(args)` compile, which is the honest surface + // for a subscription whose arguments nobody described. + const args = signature?.args === undefined ? 'any' : renderType(signature.args, warn); + const data = signature?.data === undefined ? 'unknown' : renderType(signature.data, warn); + lines.push(` ${quoteKey(method)}: ${MARKER}<${args}, ${data}>;`); + return lines; + } + if (signature?.data !== undefined) { + warn(`${target}: 'data' means nothing for a call (it answers once); use 'returns'`); + } + const args = signature?.args === undefined ? 'args?: unknown' : `args: ${renderType(signature.args, warn)}`; + const returns = signature?.returns === undefined ? 'unknown' : renderType(signature.returns, warn); + lines.push(` ${quoteKey(method)}(${args}): Promise<${returns}>;`); + return lines; +}; + +/** + * Introspection -> the text of a .d.ts. Pure: everything it needs is an + * argument, and every diagnostic goes to `warn`, so it is straightforward to + * test and impossible for it to write a file by accident. + */ +const renderTypes = (introspection, options = {}) => { + const { url = '', interfaceName = DEFAULT_INTERFACE, packageName = DEFAULT_PACKAGE, warn = () => {} } = options; + if (!isFieldMap(introspection)) fail('The server did not answer introspection with an object'); + const units = []; + let subscriptions = 0; + // Sorted, so re-running the command produces a byte-identical file and a + // diff means the server changed. + for (const unitKey of Object.keys(introspection).sort()) { + const methods = introspection[unitKey]; + if (!isFieldMap(methods)) { + warn(`unit '${unitKey}' is not an object of methods; skipped`); + continue; + } + const body = []; + for (const method of Object.keys(methods).sort()) { + // The reserved typed-event blocks, rendered below — never methods. + if (method === 'on' || method === 'emits') continue; + const info = methods[method]; + if (!isFieldMap(info)) { + warn(`${unitKey}/${method} is not a method descriptor; skipped`); + continue; + } + if (info.kind === 'subscription') subscriptions++; + const rendered = renderMethod(unitKey, method, info, warn); + for (let n = 0; n < rendered.length; n++) body.push(rendered[n]); + } + if (methods.emits !== undefined) body.push(...renderEmits(unitKey, methods.emits, warn)); + if (methods.on !== undefined) body.push(...renderSends(unitKey, methods.on, warn)); + // A unit CAN have no callable methods — one that only declares inbound + // event handlers (`on: {...}`) introspects as an empty method map. It is + // still worth emitting: the unit object is an Emitter, which is where its + // server -> client events arrive. + if (body.length === 0) { + units.push(` ${quoteKey(unitKey)}: Record;`); + continue; + } + units.push(` ${quoteKey(unitKey)}: {\n${body.map((line) => ` ${line}`).join('\n')}\n };`); + } + const lines = [ + '// Generated by `wrpc types`. Do not edit — re-run the command instead.', + `// Source: ${sourceLabel(url)}`, + '', + ]; + // Only imported when something needs it: an unused import is noise in a + // file the user is told not to edit. + if (subscriptions > 0) lines.push(`import type { ${MARKER} } from '${packageName}';`, ''); + lines.push(`export interface ${interfaceName} {`, ...units, '}', ''); + return lines.join('\n'); +}; + +// --------------------------------------------------------------------------- +// OpenAPI projection +// +// `introspect()` already carries everything a path item needs — the +// effective `http` mapping (verb, path, status, versioned) and the +// fastify-shaped `schema` parts (params/querystring/body, real JSON +// Schema) — so the OpenAPI document is a mechanical projection, not an +// inference. What the closed shape language is to the .d.ts, JSON.stringify +// is to this file: every wire-sourced value lands as DATA in one JSON +// literal, never as code or as a key outside it. + +// ':id' path segments -> '{id}'. +const openApiPath = (path) => { + const segments = path.split('/'); + for (let i = 0; i < segments.length; i++) { + if (segments[i].startsWith(':')) segments[i] = `{${segments[i].slice(1)}}`; + } + return segments.join('/'); +}; + +const isFieldMapLike = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); + +// One schema part with `properties` -> a parameter list (path or query). +const openApiParameters = (part, location, target, warn) => { + if (!isFieldMapLike(part)) return []; + const { properties } = part; + if (!isFieldMapLike(properties)) { + warn(`${target}: schema.${location === 'path' ? 'params' : 'querystring'} has no properties; skipped`); + return []; + } + const required = Array.isArray(part.required) ? part.required : []; + const parameters = []; + for (const name of Object.keys(properties).sort()) { + parameters.push({ + name, + in: location, + // Path parameters are always required, whatever the schema says. + required: location === 'path' ? true : required.includes(name), + schema: properties[name], + }); + } + return parameters; +}; + +// The wire error object every non-2xx wrpc answer carries. +const OPENAPI_ERROR_SCHEMA = { + type: 'object', + properties: { + message: { type: 'string' }, + code: { type: 'number' }, + details: {}, + }, + required: ['message', 'code'], +}; + +const renderOpenApi = (introspection, options = {}) => { + const { url = '', warn = () => {} } = options; + if (!isFieldMap(introspection)) fail('The server did not answer introspection with an object'); + const paths = {}; + let routes = 0; + for (const unitKey of Object.keys(introspection).sort()) { + const methods = introspection[unitKey]; + if (!isFieldMap(methods)) continue; + for (const method of Object.keys(methods).sort()) { + if (method === 'on' || method === 'emits') continue; + const info = methods[method]; + if (!isFieldMap(info) || !isFieldMapLike(info.http)) continue; + const target = `${unitKey}/${method}`; + const { http } = info; + if (typeof http.method !== 'string' || typeof http.path !== 'string' || !http.path.startsWith('/')) { + warn(`${target}: malformed http mapping; skipped`); + continue; + } + const verb = http.method.toLowerCase(); + const status = typeof http.status === 'number' ? http.status : 200; + const operation = { + operationId: target, + responses: { + [String(status)]: + status === 204 ? { description: 'No content' } : { description: 'The procedure result, as JSON' }, + default: { + description: 'A wrpc wire error', + content: { 'application/json': { schema: OPENAPI_ERROR_SCHEMA } }, + }, + }, + }; + const description = info.meta?.description; + if (typeof description === 'string' && description.trim() !== '') operation.summary = description; + const schema = isFieldMapLike(info.schema) ? info.schema : {}; + const parameters = [ + ...openApiParameters(schema.params, 'path', target, warn), + ...openApiParameters(schema.querystring, 'query', target, warn), + ]; + if (parameters.length > 0) operation.parameters = parameters; + if (schema.body !== undefined && verb !== 'get' && verb !== 'head') { + operation.requestBody = { + required: true, + content: { 'application/json': { schema: schema.body } }, + }; + } + const pathKey = openApiPath(http.path); + // Sorted null-proto container, like renderStatic: a '__proto__' path + // must land as data. + const item = Object.hasOwn(paths, pathKey) ? paths[pathKey] : {}; + item[verb] = operation; + Object.defineProperty(paths, pathKey, { value: item, enumerable: true, writable: true, configurable: true }); + routes++; + } + } + if (routes === 0) warn('no procedure declares an http mapping; the OpenAPI document has no paths'); + const document = { + openapi: '3.0.3', + info: { + title: 'wrpc API', + description: `Generated by \`wrpc types --openapi\` from ${sourceLabel(url)}. Paths live under the server's basePath.`, + version: '0.0.0', + }, + paths, + }; + return `${JSON.stringify(document, null, 2)}\n`; +}; + +/** + * Introspection -> the text of a module exporting it verbatim, for + * `client.use()`. Pure, like renderTypes. The payload is emitted as one JSON + * literal, which is what makes a wire-sourced object safe to put in a file: + * nothing from it is ever interpolated as code. (U+2028/U+2029 inside string + * literals are valid JS since ES2019, so the raw literal needs no escaping.) + */ +const renderStatic = (introspection, options = {}) => { + const { url = '', format = 'cjs', warn = () => {} } = options; + if (!isFieldMap(introspection)) fail('The server did not answer introspection with an object'); + // Sorted into a fresh null-prototype object (a '__proto__' unit key must + // land as data, not as a prototype), so re-running the command produces a + // byte-identical file and a diff means the server changed. + const sorted = { __proto__: null }; + for (const unitKey of Object.keys(introspection).sort()) { + const methods = introspection[unitKey]; + if (!isFieldMap(methods)) { + warn(`unit '${unitKey}' is not an object of methods; skipped`); + continue; + } + const unit = { __proto__: null }; + for (const method of Object.keys(methods).sort()) unit[method] = methods[method]; + sorted[unitKey] = unit; + } + const json = JSON.stringify(sorted, null, 2); + const header = [ + '// Generated by `wrpc types --schema`. Do not edit — re-run the command instead.', + `// Source: ${sourceLabel(url)}`, + ]; + if (format === 'esm') return [...header, `export default ${json};`, ''].join('\n'); + return ["'use strict';", '', ...header, `module.exports = ${json};`, ''].join('\n'); +}; + +// --------------------------------------------------------------------------- +// the server + +const INTROSPECT = 'system/introspect'; + +const fetchIntrospection = async (url, units, doFetch) => { + // `introspect` treats anything but an array as "no filter", which is what an + // empty object says here — an empty ARRAY would filter every unit away. + const args = units.length > 0 ? units : {}; + const body = JSON.stringify({ type: 'call', id: '1', method: INTROSPECT, args }); + let response = null; + try { + response = await doFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }); + } catch (error) { + fail(`Cannot reach ${url}: ${error.message}`); + } + if (!response.ok) fail(`${url} answered ${response.status}${response.statusText ? ` ${response.statusText}` : ''}`); + const text = await response.text(); + let packet = null; + try { + packet = JSON.parse(text); + } catch { + fail(`${url} answered something that is not JSON`); + } + if (packet?.error) { + const { message = 'unknown error', code = 500 } = packet.error; + fail(`${INTROSPECT} was refused with ${code}: ${message}`); + } + if (packet?.type !== 'callback') fail(`${url} answered '${packet?.type}' instead of a callback`); + return packet.result; +}; + +// --------------------------------------------------------------------------- +// entry point + +/** + * Everything the CLI touches beyond argv is injected, so the whole command is + * testable in-process: no stdout, no network, no filesystem. + */ +const main = async (argv, io = {}) => { + const { + log = (text) => void console.log(text), + error = (text) => void console.error(text), + fetch: doFetch = globalThis.fetch, + write = writeFile, + version = () => require('../../package.json').version, + } = io; + try { + const options = parseArgs(argv); + if (options.help) { + log(USAGE); + return 0; + } + if (options.version) { + log(version()); + return 0; + } + const introspection = await fetchIntrospection(options.url, options.units, doFetch); + const warn = (message) => void error(`wrpc types: ${message}`); + const text = renderTypes(introspection, { ...options, warn }); + const emit = async (target, content) => { + if (target === '-') return void log(content.replace(/\n$/, '')); + try { + await write(target, content); + } catch (cause) { + // A mistyped path is the commonest way this fails; ENOENT with a + // stack is not the answer to "that directory does not exist". + fail(`Cannot write ${target}: ${cause?.message ?? cause}`); + } + }; + await emit(options.out, text); + // One fetch feeds both artifacts: the schema module is the same + // introspection the types were rendered from, verbatim. + if (options.schema !== undefined) { + await emit(options.schema, renderStatic(introspection, { ...options, warn })); + } + if (options.openapi !== undefined) { + await emit(options.openapi, renderOpenApi(introspection, { ...options, warn })); + } + return 0; + } catch (cause) { + if (cause instanceof CliError) { + error(`wrpc types: ${cause.message}`); + return 1; + } + error(`wrpc types: ${cause?.stack ?? cause}`); + return 1; + } +}; + +module.exports = { + CliError, + MAX_DEPTH, + TYPE_NAMES, + USAGE, + fetchIntrospection, + main, + parseArgs, + quoteKey, + renderOpenApi, + renderStatic, + renderType, + renderTypes, +}; diff --git a/src/client.js b/src/client.js index a14da50..4920cb8 100644 --- a/src/client.js +++ b/src/client.js @@ -1,536 +1,25 @@ 'use strict'; -const { Emitter, jsonParse } = require('./utils.js'); -const { generateUUID } = require('./runtime/node.js'); -const { WebSocket } = globalThis; -const { chunkDecode } = require('./chunks.js'); -const { WrpcReadable, WrpcWritable } = require('./streams.js'); +// The barrel the rest of the package (and the sse subpath) requires: the +// core, the built-in transports (their require registers them), and the +// Service Worker proxy. Splitting the modules changed no require path — +// this file kept the old one. -const CALL_TIMEOUT = 7 * 1000; -const RECONNECT_TIMEOUT = 2 * 1000; - -const toByteView = async (input) => { - if (typeof input.arrayBuffer === 'function') { - const buffer = await input.arrayBuffer(); - return new Uint8Array(buffer); - } - return new Uint8Array(input); -}; - -class WrpcError extends Error { - constructor({ message, code }) { - super(message); - this.code = code; - } -} - -class ClientTransport extends Emitter { - active = false; - - constructor(url) { - super(); - this.url = url; - } - - send(obj) { - this.write(JSON.stringify(obj)); - } - - // eslint-disable-next-line class-methods-use-this - online() {} - - // eslint-disable-next-line class-methods-use-this - offline() {} -} - -class WrpcClient extends Emitter { - static connections = new Set(); - static isOnline = true; - - static online() { - WrpcClient.isOnline = true; - for (const connection of WrpcClient.connections) { - connection.#transport.online(); - if (!connection.active) { - connection.open().catch((error) => { - connection.emit('error', error); - }); - } - } - } - - static offline() { - WrpcClient.isOnline = false; - for (const connection of WrpcClient.connections) { - connection.#transport.offline(); - } - } - - static initialize() { - if (typeof window !== 'undefined') { - window.addEventListener('online', WrpcClient.online); - window.addEventListener('offline', WrpcClient.offline); - return; - } - if (typeof self !== 'undefined') { - self.addEventListener('online', WrpcClient.online); - self.addEventListener('offline', WrpcClient.offline); - } - } - - api = {}; - #transport = null; - #calls = new Map(); - #streams = new Map(); - #callTimeout = CALL_TIMEOUT; - #reconnectTimeout = RECONNECT_TIMEOUT; - #reconnectTimer = null; - #proxyPacket = null; - #options = {}; - - get active() { - return this.#transport.active; - } - - constructor(url, transport, options = {}) { - super(); - const { callTimeout, reconnectTimeout, proxy } = options; - if (callTimeout) this.#callTimeout = callTimeout; - if (reconnectTimeout) this.#reconnectTimeout = reconnectTimeout; - if (proxy) this.#proxyPacket = proxy; - this.url = url; - this.#transport = transport; - this.#options = options; - this.#bindTransport(); - } - - static async connect(url, options = {}) { - if (options.worker) { - const transport = WrpcClient.transport.event.getInstance(url); - const client = new WrpcClient(url, transport, options); - await client.open(); - return client; - } - const isHttp = url.startsWith('http'); - const Transport = isHttp ? WrpcClient.transport.http : WrpcClient.transport.ws; - const transport = new Transport(url); - const client = new WrpcClient(url, transport, options); - await client.open(); - return client; - } - - #bindTransport() { - this.#transport.on('open', () => { - clearTimeout(this.#reconnectTimer); - this.#reconnectTimer = null; - this.emit('open'); - }); - - this.#transport.on('close', () => { - this.emit('close'); - this.#scheduleReconnect(); - }); - - this.#transport.on('error', (error) => { - this.emit('error', error); - }); - - this.#transport.on('message', (data) => { - const escalate = (error) => this.emit('error', error); - if (typeof data === 'string') this.#handlePacket(data).catch(escalate); - else this.#handleBinary(data).catch(escalate); - }); - } - - #scheduleReconnect() { - if (this.active) return; - if (!WrpcClient.connections.has(this)) return; - if (this.#reconnectTimer) return; - this.#reconnectTimer = setTimeout(() => { - this.#reconnectTimer = null; - this.open().catch((error) => this.emit('error', error)); - }, this.#reconnectTimeout); - } - - async open() { - WrpcClient.connections.add(this); - await this.#transport.open(this.#options); - } - - close() { - clearTimeout(this.#reconnectTimer); - this.#reconnectTimer = null; - WrpcClient.connections.delete(this); - this.#transport.close(); - } - - write(data) { - this.#transport.write(data); - } - - send(data) { - this.#transport.send(data); - } - - getStream(id) { - const stream = this.#streams.get(id); - if (stream) return stream; - throw new Error(`Stream ${id} is not initialized`); - } - - createStream(name, size) { - const id = generateUUID(); - return new WrpcWritable(id, name, size, this); - } - - createBlobUploader(blob) { - const { name = 'blob', size } = blob; - const consumer = this.createStream(name, size); - const { id } = consumer; - const upload = async () => { - for await (const chunk of blob.stream()) { - consumer.write(chunk); - } - consumer.end(); - }; - return { id, upload }; - } - - async #handlePacket(data) { - if (this.#proxyPacket) return void this.#proxyPacket(data); - const packet = jsonParse(data); - if (!packet) throw new Error('Invalid JSON packet'); - const { type, id, name } = packet; - if (type === 'event') { - const parts = name.split('/'); - const unit = parts[0]; - const eventName = parts[1]; - const apiUnit = this.api[unit]; - if (apiUnit) apiUnit.emit(eventName, packet.data); - return; - } - if (!id) throw new Error('Packet structure error'); - if (type === 'callback') { - const promised = this.#calls.get(id); - if (!promised) throw new Error(`Callback ${id} not found`); - const resolve = promised[0]; - const reject = promised[1]; - const timeout = promised[2]; - this.#calls.delete(id); - clearTimeout(timeout); - if (packet.error) { - return void reject(new WrpcError(packet.error)); - } - resolve(packet.result); - return; - } - if (type === 'stream') await this.#handleStream(packet); - } - - async #handleStream(packet) { - const { id, name, size, status } = packet; - const stream = this.#streams.get(id); - if (status === undefined) { - if (stream) { - throw new Error(`Stream ${name} is already initialized`); - } - const readableStream = new WrpcReadable(id, name, size); - this.#streams.set(id, readableStream); - return; - } - if (!stream) throw new Error(`Stream ${id} is not initialized`); - if (status === 'end') { - await stream.close(); - this.#streams.delete(id); - } else if (status === 'terminate') { - await stream.terminate(); - this.#streams.delete(id); - } - } - - async #handleBinary(input) { - const byteView = await toByteView(input); - const { id, payload } = chunkDecode(byteView); - const stream = this.#streams.get(id); - if (!stream) { - throw new Error(`Stream ${id} is not initialized`); - } - await stream.push(payload); - } - - async load(...units) { - if (!this.active) throw new Error('Not connected'); - const introspect = this.#scaffold('system')('introspect'); - const introspection = await introspect(units); - const available = Object.keys(introspection); - for (const unit of units) { - if (!available.includes(unit)) continue; - const methods = new Emitter(); - const instance = introspection[unit]; - const request = this.#scaffold(unit); - const methodNames = Object.keys(instance); - for (const methodName of methodNames) { - methods[methodName] = request(methodName); - } - this.api[unit] = methods; - } - } - - #scaffold(unit, version) { - const createMethod = (methodName) => { - const method = async (args = {}) => { - const id = generateUUID(); - const ver = version ? `.${version}` : ''; - const target = `${unit}${ver}/${methodName}`; - const packet = { type: 'call', id, method: target, args }; - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - if (!this.#calls.has(id)) return; - this.#calls.delete(id); - reject(new Error('Request timeout')); - }, this.#callTimeout); - this.#calls.set(id, [resolve, reject, timeout]); - this.send(packet); - }); - }; - return method; - }; - return createMethod; - } -} - -class ClientWsTransport extends ClientTransport { - #socket = null; - #opening = null; - - async open() { - if (this.active) return Promise.resolve(); - if (this.#opening) return this.#opening; - const opening = new Promise((resolve, reject) => { - const socket = new WebSocket(this.url); - this.#socket = socket; - const onClose = (error) => { - this.#socket = null; - if (this.#opening) { - this.#opening = null; - this.emit('error', error); - return void reject(new Error('Connection closed')); - } - this.active = false; - this.emit('close', error); - }; - const onOpen = () => { - this.active = true; - this.emit('open'); - this.#opening = null; - resolve(); - }; - socket.addEventListener('open', onOpen, { once: true }); - socket.addEventListener('close', onClose, { once: true }); - socket.addEventListener('error', onClose, { once: true }); - socket.addEventListener('message', ({ data }) => { - this.emit('message', data); - }); - }); - this.#opening = opening; - return opening; - } - - close() { - if (!this.active) return; - this.#socket.close(); - } - - write(data) { - if (!this.active) throw new Error('Not connected'); - this.#socket.send(data); - } -} - -class ClientHttpTransport extends ClientTransport { - async open() { - if (this.active) return; - this.active = true; - this.emit('open'); - } - - close() { - if (!this.active) return; - this.active = false; - this.emit('close'); - } - - write(data) { - const headers = { 'Content-Type': 'application/json' }; - const options = { method: 'POST', headers, body: data }; - const send = async () => { - try { - const res = await fetch(this.url, options); - const packet = await res.text(); - this.emit('message', packet); - } catch (error) { - this.emit('error', error); - } - }; - send(); - } -} - -class ClientEventTransport extends ClientTransport { - static instance = null; - - #port = null; - #worker = null; - - static getInstance(url) { - if (ClientEventTransport.instance) { - return ClientEventTransport.instance; - } - const transport = new ClientEventTransport(url); - ClientEventTransport.instance = transport; - return transport; - } - - async open(options = {}) { - if (this.active) return; - const worker = options.worker ?? this.#worker; - if (!worker) throw new Error('Service Worker not provided'); - this.#worker = worker; - const { port1, port2 } = new MessageChannel(); - this.#port = port1; - port1.addEventListener('message', ({ data }) => { - if (data === undefined) return; - this.emit('message', data); - }); - port1.start(); - this.#worker.postMessage({ type: 'wrpc:connect' }, [port2]); - this.active = true; - this.emit('open'); - } - - close() { - this.active = false; - this.#port.close(); - this.#port = null; - this.emit('close'); - } - - online() { - if (this.#worker) this.#worker.postMessage({ type: 'wrpc:online' }); - } - - offline() { - if (this.#worker) this.#worker.postMessage({ type: 'wrpc:offline' }); - } - - write(data) { - if (!this.#port) throw new Error('Not connected'); - this.#port.postMessage(data); - } -} - -class WrpcClientProxy extends Emitter { - #ports = new Set(); - #pending = new Map(); - #connection = null; - #callTimeout = CALL_TIMEOUT; - #reconnectTimeout = RECONNECT_TIMEOUT; - - constructor(options = {}) { - super(); - const { callTimeout, reconnectTimeout } = options; - if (callTimeout) this.#callTimeout = callTimeout; - if (reconnectTimeout) this.#reconnectTimeout = reconnectTimeout; - if (typeof self === 'undefined') { - throw new Error('WrpcClientProxy must run in ServiceWorker context'); - } - self.addEventListener('message', (event) => { - const { type } = event.data; - if (type?.startsWith('wrpc')) this.#handleEvent(event); - }); - } - - async open() { - if (this.#connection) { - if (this.#connection.connected) return; - await this.#connection.open(); - return; - } - const protocol = self.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const url = `${protocol}//${self.location.host}`; - const options = { - callTimeout: this.#callTimeout, - reconnectTimeout: this.#reconnectTimeout, - proxy: (data) => this.#proxyPacket(data), - }; - this.#connection = await WrpcClient.connect(url, options); - } - - close() { - if (!this.#connection) return; - this.#connection.close(); - this.#connection = null; - } - - #handleEvent(event) { - const { type } = event.data; - if (type === 'wrpc:connect') { - const port = event.ports[0]; - if (!port) throw new Error('MessagePort not provided'); - this.#ports.add(port); - port.addEventListener('message', (messageEvent) => { - this.#handleMessage(messageEvent, port); - }); - port.start(); - return; - } - if (type === 'wrpc:online') WrpcClient.online(); - else if (type === 'wrpc:offline') WrpcClient.offline(); - else throw new Error(`Unknown event: ${type}`); - } - - async #handleMessage(event, port) { - const { data } = event; - if (data === undefined) throw new Error('Message data is undefined'); - await this.open(); - if (!this.#connection || !this.#connection.active) { - throw new Error('Not connected to server'); - } - const packet = jsonParse(data); - if (!packet || !packet.id) throw new Error('Invalid JSON packet'); - this.#pending.set(packet.id, port); - this.#connection.write(data); - } - - #proxyPacket(data) { - if (typeof data !== 'string') return void this.#broadcast(data); - const packet = jsonParse(data); - if (!packet) return void this.#broadcast(data); - const { type, id, status } = packet; - if (type === 'event') return void this.#broadcast(data); - const port = this.#pending.get(id); - if (!port) return void this.#broadcast(data); - port.postMessage(data); - if (type === 'callback') return void this.#pending.delete(id); - if (type !== 'stream') return; - const streamDone = status === 'end' || status === 'terminate'; - if (streamDone) this.#pending.delete(id); - } - - #broadcast(data, excludePort = null) { - for (const port of this.#ports) { - if (port === excludePort) continue; - port.postMessage(data); - } - } -} - -WrpcClient.transport = { - ws: ClientWsTransport, - http: ClientHttpTransport, - event: ClientEventTransport, -}; +const { WrpcClient, WrpcError, ClientTransport, isClientTransport, metaHeaders } = require('./client/core.js'); +require('./client/transports.js'); +const { WrpcClientProxy } = require('./client/proxy.js'); WrpcClient.initialize(); -module.exports = { WrpcClient, WrpcClientProxy, WrpcError }; +// The contract-first entry point. `connect(url)` is the same call as +// `WrpcClient.connect(url)` under a name that reads as a function, because +// that is what carries the type argument in `connect('wss://host')` — +// the whole typed-client story is the ONE place a user names their contract. +// A wrapper rather than a re-export of the static: the name of the thing a +// user calls should not depend on how the class happens to expose it. +const connect = (url, options) => WrpcClient.connect(url, options); + +// ClientTransport is exported for transports that live in their own subpath +// (see src/sse/client.js); it is deliberately NOT re-exported from the +// package barrel, where transports stay type-only. +module.exports = { WrpcClient, WrpcClientProxy, WrpcError, ClientTransport, isClientTransport, connect, metaHeaders }; diff --git a/src/client/core.js b/src/client/core.js new file mode 100644 index 0000000..eed739b --- /dev/null +++ b/src/client/core.js @@ -0,0 +1,2029 @@ +'use strict'; + +// The client core: WrpcError, the ClientTransport base and WrpcClient +// itself. The three built-in transports live in ./transports.js (registered +// the same way src/sse/client.js registers the SSE one) and the Service +// Worker proxy in ./proxy.js; ../client.js is the barrel that assembles +// them, so require paths and the browser field are unchanged. + +const { Emitter, jsonParse, isCodec, toKebab, backoffDelay, createEventStream } = require('../utils.js'); +const { generateUUID } = require('../runtime/node.js'); +const { chunkDecode } = require('../chunks.js'); +const { WrpcReadable, WrpcWritable } = require('../streams.js'); +const { createLoggerWriter } = require('../logging.js'); +const { createClientTelemetry } = require('../telemetry/client.js'); +const { META_HEADER, META_PREFIX } = require('../wire.js'); + +const CALL_TIMEOUT = 7 * 1000; + +// Cap on the transport handshake. Connection setup is the one phase with no +// liveness signal at all: the heartbeat starts after 'open', and nothing +// schedules the next reconnect attempt while open() is pending — so a +// handshake that neither opens nor errors (a mobile network that swallows +// SYNs, a proxy that accepts and stalls) would park the whole ladder. +const CONNECT_TIMEOUT = 30 * 1000; +const CONNECT_TIMEOUT_ERROR = { message: 'Connect timeout', code: 408 }; + +// What a call that outlived callTimeout rejects with: 408, coded like every +// other client-produced refusal, so `error.code` checks work here too. +const REQUEST_TIMEOUT_ERROR = { message: 'Request timeout', code: 408 }; + +// The wire revision this client speaks, offered as a WebSocket subprotocol. +const WRPC_PROTOCOL = 'wrpc.v1'; + +// Monotonic where available; Date.now is the fallback for a host without it. +const now = () => (typeof performance === 'object' ? performance.now() : Date.now()); +const RECONNECT_TIMEOUT = 2 * 1000; + +// 499, nginx's "client closed request": the caller took the call back, so +// it is neither a server fault nor a success. +const CANCELLED_ERROR = { message: 'Cancelled by the caller', code: 499 }; + +// What every in-flight call is rejected with the moment the connection is +// gone: an answer can no longer arrive, and waiting out callTimeout would +// just park the caller on a corpse. +const CONNECTION_CLOSED_ERROR = { message: 'Connection closed', code: 503 }; + +const RECONNECT = { + minDelay: RECONNECT_TIMEOUT, + maxDelay: 30 * 1000, + factor: 2, + jitter: true, + retries: Infinity, +}; + +// App-level heartbeat. A browser WebSocket exposes no protocol-level ping, +// so a connection that died without a close frame (dropped NAT mapping, +// suspended laptop, a proxy that stopped forwarding) looks perfectly open +// from JavaScript until the first call times out. Sending `{type:'ping'}` +// and expecting `{type:'pong'}` is the only liveness signal available. +const HEARTBEAT = { interval: 30 * 1000, timeout: 10 * 1000 }; + +// Heartbeat timers are unref'd so the beat itself never keeps a process +// alive — the live socket is already a ref'd handle, so an idle-but-connected +// client still holds the loop open. The RECONNECT timer is deliberately NOT +// unref'd: during an outage there is no socket left to hold the loop, and a +// process whose only work is a wrpc client must not exit mid-reconnect. +// (In a browser setTimeout returns a number and there is nothing to unref.) +const unref = (timer) => { + if (timer && typeof timer.unref === 'function') timer.unref(); + return timer; +}; + +const normalizeReconnect = (options) => { + const { reconnect, reconnectTimeout } = options; + if (reconnect === false) return { ...RECONNECT, retries: 0 }; + const base = reconnectTimeout ? { ...RECONNECT, minDelay: reconnectTimeout } : RECONNECT; + // Both paths share the invariants: a `reconnectTimeout` above the default + // cap would otherwise silently reconnect FASTER than asked, since the cap + // wins inside backoffDelay. + const merged = reconnect ? { ...base, ...reconnect } : { ...base }; + if (!(merged.minDelay > 0)) merged.minDelay = RECONNECT.minDelay; + if (!(merged.maxDelay >= merged.minDelay)) merged.maxDelay = merged.minDelay; + // How long an open connection must SURVIVE before the attempt counter + // resets (see the 'open' handler). Defaults to minDelay; 0 restores the + // old reset-on-open behavior. + if (!(merged.stableAfter >= 0)) merged.stableAfter = merged.minDelay; + return merged; +}; + +// Call batching. Several calls issued in the same tick travel as ONE frame +// (a JSON array), which on HTTP is one request instead of N and on a +// WebSocket is one frame instead of N. Only `call` packets batch: a ping, a +// cancel or an unsubscribe is a control packet whose whole point is to +// arrive now. +const BATCH = { flush: 'microtask', maxSize: 16, maxBytes: 64 * 1024 }; + +const normalizeBatch = (options) => { + const { batch } = options; + if (!batch) return null; + const merged = { ...BATCH, ...(batch === true ? {} : batch) }; + const timed = typeof merged.flush === 'number' && merged.flush >= 0; + if (!timed && merged.flush !== 'microtask') merged.flush = BATCH.flush; + if (!(merged.maxSize > 1)) merged.maxSize = BATCH.maxSize; + if (!(merged.maxBytes > 0)) merged.maxBytes = BATCH.maxBytes; + return merged; +}; + +const normalizeHeartbeat = (options) => { + const { heartbeat } = options; + if (heartbeat === false || heartbeat === 0) return null; + if (!heartbeat) return HEARTBEAT; + const merged = { ...HEARTBEAT, ...heartbeat }; + if (!(merged.interval > 0) || !(merged.timeout > 0)) return null; + return merged; +}; + +const toByteView = async (input) => { + if (typeof input.arrayBuffer === 'function') { + const buffer = await input.arrayBuffer(); + return new Uint8Array(buffer); + } + return new Uint8Array(input); +}; + +class WrpcError extends Error { + constructor({ message, code, details }) { + super(message); + this.code = code; + // Structured issue lists the server attached (validation paths and the + // like) — an optional wire field, absent on most errors. + if (details !== undefined) this.details = details; + } +} + +// Client-side pre-validation: the introspected input schema parts compiled +// by an app-injected ajv (`validation: { ajv }`), so a doomed call rejects +// locally with the SAME 400 + details it would earn from the server — +// without the round trip. wrpc imports no schema library; without the +// injection this costs nothing. +const PREVALIDATE_PARTS = [ + ['params', 'params'], + ['querystring', 'query'], + ['body', 'body'], +]; + +const compilePrevalidate = (ajv, schema) => { + const parts = []; + for (const [part, argsKey] of PREVALIDATE_PARTS) { + if (schema[part] === undefined) continue; + parts.push([part, argsKey, ajv.compile(schema[part])]); + } + if (parts.length === 0) return null; + return (args) => { + const value = args && typeof args === 'object' ? args : {}; + let issues = null; + for (const [part, argsKey, validate] of parts) { + if (validate(value[argsKey] ?? {})) continue; + issues ??= []; + for (const item of validate.errors ?? []) { + issues.push({ message: item.message, path: `/${part}${item.instancePath ?? ''}` }); + } + } + if (issues) { + const messages = []; + for (const issue of issues) messages.push(`${issue.path} ${issue.message}`); + throw new WrpcError({ message: messages.join('; '), code: 400, details: { issues } }); + } + return value; + }; +}; + +// A declared value has to survive being a header value on http/sse, so one +// rule produces it everywhere — including the ws query, which could carry +// richer JSON but must not, or the two transports would disagree on shape. +// null/undefined DROP the key rather than sending the string 'null': a +// header cannot say "absent", and a lie the server cannot undo is worse +// than an omission it can. +const toHeaderValue = (value) => { + if (typeof value === 'string') return value; + if (value === null || value === undefined) return null; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +}; + +// A header value must stay latin-1 or fetch throws a TypeError deep inside +// the transport, long after the mistake was made. +const HEADER_SAFE = /^[ -~]*$/; + +// Normalizes one declared bag: keys to kebab, and — when the values will +// become header values — through toHeaderValue. Refusal-style like the +// server's half: a value that cannot ride drops with a warning rather than +// failing the connection it was only labelling. +const normalizeDeclared = (value, stringify, log) => { + if (!value) return null; + let out = null; + for (const key in value) { + if (key === '__proto__') continue; + const name = toKebab(key); + if (name.length === 0) continue; + let entry = value[key]; + if (stringify) { + entry = toHeaderValue(entry); + if (entry === null) continue; + if (!HEADER_SAFE.test(entry)) { + log.warn({ event: 'declared.unsendable', key: name }); + continue; + } + } + (out ??= {})[name] = entry; + } + return out; +}; + +// The declared-meta header block for one request, built once per open (or +// per request, when a call brings its own). Two spellings of the same bag: +// the canonical single header is type-faithful and needs one CORS entry; +// the prefixed one is what a gateway can route on, strip or inject, at the +// cost of string-only values and a CORS entry per key. +const metaHeaders = (meta, prefixed) => { + if (!prefixed) return { [META_HEADER]: encodeURIComponent(JSON.stringify(meta)) }; + const out = {}; + for (const key in meta) { + // Flattened like the connection bag (normalizeDeclared): a per-call bag + // reaches this branch with its JSON types intact, and a non-string + // value used to ride as-is — poisoning the byte accounting downstream + // (undefined.length -> NaN) and silently dropping the whole block. + const value = toHeaderValue(meta[key]); + if (value !== null) out[META_PREFIX + key] = value; + } + return out; +}; + +// ws and http spell the same endpoint with different schemes; a fallback +// list crosses that line, so the URL is re-spelled per candidate. +const mapScheme = (url, name) => { + if (name === 'ws') return url.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:'); + return url.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:'); +}; + +class ClientTransport extends Emitter { + active = false; + // Whether the connection stays open: only a persistent one can carry a + // cancel, an unsubscribe or a subscription's values. + persistent = true; + // Opt-in: only a transport that can silently die needs an app-level + // heartbeat. A request/response transport has nothing to keep alive, and + // a MessagePort to a Service Worker cannot half-close. + heartbeat = false; + + constructor(url) { + super(); + this.url = url; + } + + send(obj) { + // The codec is handed to the transport at bind time (client core). + // `obj.meta` rides along so a transport with a header block can mirror + // it; a control packet has none, and ws/worker ignore the argument. + this.write(this.codec ? this.codec.encode(obj) : JSON.stringify(obj), obj?.meta); + } + + // Drop the connection without waiting for a close handshake. The default + // is a graceful close; transports that can hang on an unresponsive peer + // override it. + terminate() { + this.close(); + } + + // eslint-disable-next-line class-methods-use-this + online() {} + + // eslint-disable-next-line class-methods-use-this + offline() {} + + // Synthesizes an error callback for every CALL packet a failed + // request/response frame carried — the transport is the only party that + // knows which ids just died with it, and without an answer each would + // wait out its whole callTimeout. Shared by the http and sse legs; both + // directions speak the codec when one is configured. Only `call` packets + // are answered: a control packet has no caller parked on it, and a + // subscribe's terminal signal is an `end`, not a callback. + failPackets(data, status) { + let parsed = null; + if (this.codec) { + try { + parsed = this.codec.decode(data); + } catch { + parsed = null; + } + } else { + parsed = jsonParse(data); + } + if (!parsed) return; + const packets = Array.isArray(parsed) ? parsed : [parsed]; + const answers = []; + for (const packet of packets) { + if (!packet || typeof packet !== 'object' || packet.type !== 'call' || typeof packet.id !== 'string') continue; + answers.push({ + type: 'callback', + id: packet.id, + error: { message: `HTTP request failed (${status})`, code: status }, + }); + } + if (answers.length === 0) return; + const frame = Array.isArray(parsed) ? answers : answers[0]; + this.emit('message', this.codec ? this.codec.encode(frame) : JSON.stringify(frame)); + } +} + +// The structural check every registered transport must pass — the ONE +// injection seam third parties are told to add into, and (before this) the +// only one with neither a validator nor a contract test. Prototype-level: +// what connect() constructs is the class, so the class's prototype is what +// can answer for every future instance. +const isClientTransport = (Transport) => { + if (typeof Transport !== 'function') return false; + const proto = Transport.prototype; + return ( + Boolean(proto) && + typeof proto.open === 'function' && + typeof proto.close === 'function' && + typeof proto.write === 'function' && + typeof proto.on === 'function' + ); +}; + +class WrpcClient extends Emitter { + // The registry (CLAUDE.md's late-registration idiom): ./transports.js and + // the sse subpath ASSIGN INTO it — never replace it — so require order + // stops mattering; null-prototyped, so a name like 'toString' can never + // answer with Object.prototype's function and slip past the shape check. + static transport = { __proto__: null }; + + static connections = new Set(); + static isOnline = true; + + static online() { + WrpcClient.isOnline = true; + for (const connection of WrpcClient.connections) { + connection.#transport.online(); + if (!connection.active) { + // #escalate, not emit('error'): Emitter throws for an unlistened + // 'error', and a throw inside this loop would abandon the re-open + // of every connection after this one. + connection.open().catch((error) => connection.#escalate(error, 'online.open')); + } + } + } + + static offline() { + WrpcClient.isOnline = false; + for (const connection of WrpcClient.connections) { + connection.#transport.offline(); + } + } + + static initialize() { + if (typeof window !== 'undefined') { + window.addEventListener('online', WrpcClient.online); + window.addEventListener('offline', WrpcClient.offline); + return; + } + if (typeof self !== 'undefined') { + self.addEventListener('online', WrpcClient.online); + self.addEventListener('offline', WrpcClient.offline); + } + } + + api = {}; + #log = null; + #otel = null; + #transport = null; + #calls = new Map(); + #cancelled = new Set(); + #subscriptions = new Map(); + #batch = null; + #pending = []; + #pendingBytes = 0; + #flushTimer = null; + #streams = new Map(); + #callTimeout = CALL_TIMEOUT; + #connectTimeout = CONNECT_TIMEOUT; + #querystring = null; + #validation = null; + // The ordered fallback list (null without one) and the live candidate. + #transportNames = null; + #transportIndex = 0; + #boundHandlers = null; + #codec = null; + #codecRest = null; + #reconnect = RECONNECT; + #reconnectTimer = null; + // Arms on 'open', zeroes #attempt when it fires: the counter resets on a + // connection that PROVED itself, not on the TCP open (see bind('open')). + #stableTimer = null; + #attempt = 0; + #connected = false; + // The authenticate hook and the settling of the first connect's afterOpen + // run — open() awaits it so connect() returns an authenticated client. + #authenticate = null; + #authenticating = false; + #opened = null; + // The refresh hook, the codes that trigger it, and the in-flight run + // shared by every concurrent refusal (single-flight). + #refresh = null; + #refreshCodes = [401]; + #refreshing = null; + // Opt-in per-call retry policy (attempts, coded triggers, backoff). + #retry = null; + // Connection-phase headers and metadata: objects, or functions + // re-evaluated on every open so a reconnect presents fresh values. + #headers = null; + #meta = null; + #metaFormat = 'json'; + #random = Math.random; + #generateId = generateUUID; + #heartbeat = null; + #pingTimer = null; + #pongTimer = null; + #loaded = new Set(); + // Units scaffolded from a static introspection artifact via use(). Kept + // apart from #loaded: they are never re-introspected on reconnect — the + // artifact is the contract, exactly as a generated .d.ts would be. + #static = new Set(); + #unitMethods = new Map(); + #responders = new Map(); + #proxyPacket = null; + #options = {}; + + get active() { + return this.#transport.active; + } + + /** How many reconnect attempts have been made since the last open. */ + get attempt() { + return this.#attempt; + } + + constructor(url, transport, options = {}) { + super(); + const { callTimeout, proxy, random, generateId, logger, telemetry, querystring, validation, codec } = options; + const { authenticate } = options; + // Presents this connection's credential: awaited inside open() on the + // first connect, and again on every reconnect BEFORE the subscriptions + // are re-opened and the units re-loaded — the window an 'open' listener + // structurally cannot reach (see #afterOpen). + if (typeof authenticate === 'function') this.#authenticate = authenticate; + const { refresh } = options; + // Single-flight credential refresh: on a refusal whose code is listed + // (401 by default) the handler runs ONCE for all concurrent refusals, + // and each refused call is re-issued exactly once. `authenticate` heals + // a NEW connection; `refresh` heals a LIVE one whose credential expired. + if (refresh !== undefined && refresh !== null) { + if (typeof refresh === 'function') { + this.#refresh = refresh; + } else if (typeof refresh.handler === 'function') { + this.#refresh = refresh.handler; + if (Array.isArray(refresh.on) && refresh.on.length > 0) this.#refreshCodes = [...refresh.on]; + } else { + throw new TypeError('WrpcClient: options.refresh must be a function or { on?, handler }'); + } + } + const { retry } = options; + // Opt-in, and NEVER buffering: a call that failed with a listed code is + // re-issued (fresh packet id) after a jittered backoff, up to + // `attempts` extra tries. Off by default — silent auto-retry surprises + // exactly the calls that are not idempotent, so the application opts + // whole-client and scopes non-idempotent procedures away from the + // trigger codes (503 by default: the coded "connection closed / + // draining" refusals a reconnect heals). + if (retry !== undefined && retry !== null && retry !== false) { + const merged = { + attempts: 2, + on: [503], + minDelay: 200, + maxDelay: 2_000, + factor: 2, + jitter: true, + ...(retry === true ? {} : retry), + }; + if (!(merged.attempts > 0) || !Array.isArray(merged.on) || merged.on.length === 0) { + throw new TypeError('WrpcClient: options.retry needs attempts > 0 and a non-empty `on` code list'); + } + this.#retry = merged; + } + const { headers } = options; + // Connection-phase headers, distinct from per-call `meta`: they ride as + // REAL request headers on http/sse (and any transport that can send + // them), and as ONE query parameter on the browser ws connect URL — + // the WHATWG WebSocket constructor takes no headers. Validated when the + // server's procedure declares `schema.headers`. + if (headers !== undefined && headers !== null) { + const valid = typeof headers === 'function' || (typeof headers === 'object' && !Array.isArray(headers)); + if (!valid) throw new TypeError('WrpcClient: options.headers must be an object or a function returning one'); + this.#headers = headers; + } + const { meta } = options; + // Connection-phase metadata — headers' unvalidated sibling: never runs + // through schema validation, lands on client.meta.data server-side, and + // has a per-call twin (the packet `meta` field / context.callMeta). + if (meta !== undefined && meta !== null) { + const valid = typeof meta === 'function' || (typeof meta === 'object' && !Array.isArray(meta)); + if (!valid) throw new TypeError('WrpcClient: options.meta must be an object or a function returning one'); + this.#meta = meta; + } + const { metaFormat } = options; + // A sibling option rather than a descriptor on `meta`: that option + // already means "an object OR a function returning one", so making the + // object form ALSO mean a descriptor would be genuinely ambiguous — + // { value, carrier } is a legitimate meta bag today. And not inferred + // from the data either: "prefixed when every value is a string" would + // make the wire shape depend on the payload, so one numeric field would + // silently flip the connection and change what CORS has to allow. + if (metaFormat !== undefined) { + if (metaFormat !== 'json' && metaFormat !== 'prefixed') { + throw new TypeError("WrpcClient: options.metaFormat must be 'json' or 'prefixed'"); + } + this.#metaFormat = metaFormat; + } + // Off by default, unlike the server: a client that printed on every + // reconnect would be noise in a browser console nobody asked for. + this.#log = createLoggerWriter(logger); + this.#otel = createClientTelemetry(telemetry); + if (callTimeout) this.#callTimeout = callTimeout; + const { connectTimeout } = options; + // 0/false disables — for hosts that own their timeouts (tests, tooling). + if (connectTimeout !== undefined) this.#connectTimeout = connectTimeout > 0 ? connectTimeout : 0; + // Pluggable query-string serializer (qs and friends) for mapped REST + // requests — the mirror of the server's `querystring` option, so array + // encodings agree end to end. + if (querystring && typeof querystring.stringify === 'function') this.#querystring = querystring; + // Structural, like every injection: anything with compile(schema) -> fn + // (ajv-shaped: boolean answer, `.errors` on failure). + if (validation?.ajv && typeof validation.ajv.compile === 'function') this.#validation = validation.ajv; + if (Array.isArray(options.transport)) this.#transportNames = [...options.transport]; + // The wire codec — the client half of the server's `codec` option. The + // packet half must produce single-line text (SSE frames by line); the + // optional `rest` section frames REST-leg bodies and may be binary. + // Structural check shared with the server (isCodec). + if (codec !== undefined && codec !== null) { + if (!isCodec(codec)) { + throw new TypeError( + 'WrpcClient: options.codec must provide encode(packet)/decode(text), a rest section, or both', + ); + } + this.#codec = typeof codec.encode === 'function' && typeof codec.decode === 'function' ? codec : null; + this.#codecRest = codec.rest ?? null; + } + if (proxy) this.#proxyPacket = proxy; + if (random) this.#random = random; // deterministic jitter in tests + // Packet, subscription and stream ids; uuid v4 unless the app brings + // its own (cuid/ulid/a test counter). Correlation ids, not secrets. + if (typeof generateId === 'function') this.#generateId = generateId; + this.#reconnect = normalizeReconnect(options); + this.#heartbeat = normalizeHeartbeat(options); + this.#batch = normalizeBatch(options); + this.url = url; + this.#transport = transport; + this.#options = options; + this.#bindTransport(); + } + + // A rejected open (an authenticate hook that failed, a refused socket) + // must not leave a half-born client behind: open() registered it in + // WrpcClient.connections before the transport was reached, and a failing + // authenticate arms the reconnect timer — close() undoes both, so the + // caller who never received the client has nothing running for it. + static async #openOrClose(client) { + try { + await client.open(); + } catch (error) { + client.close(); + throw error; + } + return client; + } + + static async connect(url, options = {}) { + if (options.worker) { + const transport = WrpcClient.transport.event.getInstance(url); + const client = new WrpcClient(url, transport, options); + return WrpcClient.#openOrClose(client); + } + // The scheme picks the transport unless one is named. 'sse' only exists + // once '@alexify/wrpc/sse' has been required, which is what registers it. + // A list is an ordered fallback: candidates are tried in the given + // order, each with its own reconnect budget — there is deliberately no + // default order, the application names its own. All names are checked + // UP FRONT: a fallback that fails at fall-back time is a fallback + // nobody tested. + if (Array.isArray(options.transport)) { + if (options.transport.length === 0) throw new Error('transport list must not be empty'); + for (const candidate of options.transport) { + if (candidate === 'event') throw new Error("transport list cannot contain 'event' — pass options.worker"); + if (WrpcClient.transport[candidate] === undefined) { + throw new Error(`Unknown transport '${candidate}'`); + } + if (!isClientTransport(WrpcClient.transport[candidate])) { + throw new TypeError(`Transport '${candidate}' does not satisfy the ClientTransport contract`); + } + } + } + const name = Array.isArray(options.transport) + ? options.transport[0] + : (options.transport ?? (url.startsWith('http') ? 'http' : 'ws')); + const Transport = WrpcClient.transport[name]; + if (Transport === undefined) { + throw new Error(`Unknown transport '${name}'`); + } + if (!isClientTransport(Transport)) { + throw new TypeError(`Transport '${name}' does not satisfy the ClientTransport contract`); + } + const transport = new Transport(mapScheme(url, name)); + const client = new WrpcClient(url, transport, options); + return WrpcClient.#openOrClose(client); + } + + // An 'error' with no listener throws by design (see Emitter), which is + // right for a synchronous mistake and wrong for a background failure: a + // throw out of a timer or a promise chain would take the process down for + // a reconnect that is about to be retried anyway. + #escalate(error, event = 'client.error') { + // A logger observes; a listener handles. Both run — the log line is not + // a fallback for a missing listener. + this.#log.error({ err: error, event }); + if (this.listenerCount('error') > 0) return void this.emit('error', error); + globalThis.console?.error?.(error); + } + + // The connection died: every pending call is settled NOW with a coded + // rejection (503) instead of waiting out callTimeout on a corpse, its + // timer cleared and its abort listener released. + #failCalls() { + if (this.#calls.size === 0 && this.#pending.length === 0) return; + const entries = Array.from(this.#calls.values()); + this.#calls.clear(); + this.#pending.length = 0; + this.#pendingBytes = 0; + this.#cancelled.clear(); + for (const entry of entries) { + clearTimeout(entry.timeout); + entry.release(); + entry.reject(new WrpcError(CONNECTION_CLOSED_ERROR)); + } + } + + // The single-call form of #failCalls, for the path that knows exactly + // which ids just died: a batch frame whose write threw was already spliced + // out of #pending, and the socket was gone before the throw — so neither + // #failCalls nor a 'close' will ever settle its calls. + #failCall(id) { + const entry = this.#calls.get(id); + if (!entry) return; + this.#calls.delete(id); + clearTimeout(entry.timeout); + entry.release(); + entry.reject(new WrpcError(CONNECTION_CLOSED_ERROR)); + } + + // Mirror of the server-side Client.destroy(): an inbound stream mid- + // transfer is terminated so its consumer's `for await` ends instead of + // parking forever on a connection that no longer exists. + #failStreams() { + if (this.#streams.size === 0) return; + const streams = Array.from(this.#streams.values()); + this.#streams.clear(); + for (const stream of streams) { + Promise.resolve(stream.terminate()).catch(() => {}); + } + } + + // The handlers are kept so #unbindTransport can take them OFF a + // transport being abandoned by a fallback swap — a late 'close' from the + // old socket would otherwise double-schedule reconnects on the new one. + #unbindTransport() { + if (!this.#boundHandlers) return; + for (const [event, handler] of this.#boundHandlers) this.#transport.off(event, handler); + this.#boundHandlers = null; + } + + #bindTransport() { + // The transport owns the outbound write, so it carries the codec; a + // fallback swap re-binds and re-hands it here. The logger rides the same + // seam: a transport that refuses something (an oversize meta block) has + // to be able to say so where the application can see it. + if (this.#codec) this.#transport.codec = this.#codec; + this.#transport.log = this.#log; + const bind = (event, handler) => { + this.#boundHandlers ??= []; + this.#boundHandlers.push([event, handler]); + this.#transport.on(event, handler); + }; + bind('open', () => { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + const attempts = this.#attempt; + // NOT zeroed on open: a TCP/WS open proves nothing yet. An + // accept-then-drop peer (a server shedding load mid-restart, a proxy + // that resets right after the upgrade) used to re-zero the counter on + // every open — the window pinned at minDelay forever, retries never + // exhausting, the fallback transport never reached. The counter resets + // only once the connection has survived reconnect.stableAfter. + clearTimeout(this.#stableTimer); + if (this.#reconnect.stableAfter > 0 && attempts > 0) { + this.#stableTimer = unref( + setTimeout(() => { + this.#stableTimer = null; + this.#attempt = 0; + }, this.#reconnect.stableAfter), + ); + } else { + this.#attempt = 0; + } + this.#startHeartbeat(); + const reconnected = this.#connected; + this.#connected = true; + this.#log.info({ event: reconnected ? 'reconnected' : 'open', url: this.url, attempts }); + this.#otel.recordConnection(1); + if (reconnected) this.#otel.recordReconnect('recovered', attempts); + // Without an authenticate hook this is the historical path, spelled + // out rather than routed through the async #afterOpen: an async hop + // would push both the 'open' emit and #restore one microtask later, + // and the ordering of a lifecycle emit against the subscribe packets + // that follow it is exactly what applications end up depending on. + // + // Lifecycle emits are async (listeners may be): a throwing listener + // must surface through #escalate, not as an unhandled rejection. + if (!this.#authenticate) { + this.emit('open').catch((error) => this.#escalate(error, 'listener.open')); + if (reconnected) this.#restore(attempts).catch((error) => this.#escalate(error, 'reconnect.restore')); + return; + } + const settled = this.#afterOpen(reconnected, attempts); + // The first connect's rejection is open()'s to re-raise (see open()); + // every later one has nowhere to go but #escalate. Both branches + // attach a handler in this turn, so neither is an unhandled rejection. + if (reconnected) settled.catch((error) => this.#escalate(error, 'reconnect.afterOpen')); + else this.#opened = settled; + }); + + bind('close', () => { + // A close inside the stability window keeps the pre-open attempt + // count: this connection never proved itself. + clearTimeout(this.#stableTimer); + this.#stableTimer = null; + this.#stopHeartbeat(); + // Settled before 'close' is announced: a listener reacting to the + // close must find the calls already rejected and the streams ended, + // not racing them. + this.#failCalls(); + this.#failStreams(); + this.#log.info({ event: 'close', url: this.url }); + this.#otel.recordConnection(-1); + this.emit('close').catch((error) => this.#escalate(error, 'listener.close')); + this.#scheduleReconnect(); + }); + + bind('error', (error) => { + this.#escalate(error, 'transport.error'); + }); + + bind('message', (data) => { + const escalate = (error) => this.#escalate(error, 'message'); + if (typeof data === 'string') this.#handlePacket(data).catch(escalate); + else this.#handleBinary(data).catch(escalate); + }); + } + + // A reconnected socket is a NEW server-side client: whatever `load()` set + // up (the introspected method list) belongs to the connection that just + // died, so it is rebuilt before 'reconnect' is announced. The api unit + // objects themselves are reused, so event listeners registered on them + // survive — that is the whole point of reloading rather than reconnecting + // and leaving `api` quietly stale. + // + // The two halves are DECOUPLED on purpose: subscriptions are re-opened + // first and unconditionally, because a failing load() used to sit in + // front of the resume loop in a fire-and-forget call — one introspect + // hiccup silently killed every subscription forever. And a restore that + // failed is a connection that is not usable as reconnected: the transport + // is terminated so the normal reconnect path (backoff, retries, + // 'reconnect-failed') takes over, instead of leaving a half-restored + // client that looks open. + // The seam the transport's 'open' event could not provide. #restore sends + // every subscribe packet BEFORE its first await (see below), so an async + // 'open' listener can never win that race: its body past the first await + // resumes with the packets already on the wire. This runs the credential + // first, awaited, and only then announces 'open' and restores. + async #afterOpen(reconnected, attempts) { + try { + // The guard keeps a refresh from running inside the hook that would + // itself be re-run by it (see #maybeRefresh). + this.#authenticating = true; + await this.#authenticate(this, { reconnected, attempts }); + } catch (error) { + this.#log.warn({ event: 'authenticate.failed', err: error, attempts, url: this.url }); + void this.emit('authenticate-failed', { error, attempts, reconnected }).catch((e) => + this.#escalate(e, 'listener.authenticate-failed'), + ); + // Same reasoning, same ordering, as the restore path below: put the + // pre-open attempt count back BEFORE terminate() synchronously hands + // it to the reconnect scheduler. + if (this.#attempt === 0) this.#attempt = attempts; + if (this.active) this.#transport.terminate(); + throw error; + } finally { + this.#authenticating = false; + } + // 'open' now means USABLE: a listener that fires a session-gated call + // finds the credential already presented. Still not awaited — a listener + // is an observer, and one that throws must not take the restore with it. + this.emit('open').catch((error) => this.#escalate(error, 'listener.open')); + if (reconnected) await this.#restore(attempts); + } + + async #restore(attempts) { + // Each subscription is re-opened from the last eventId it saw, so the + // server can replay what was missed instead of starting over. A feed + // that yields untracked values has no eventId and simply resumes live. + const subscriptions = Array.from(this.#subscriptions.values()); + for (const record of subscriptions) this.#openSubscription(record); + const units = Array.from(this.#loaded); + try { + if (units.length > 0) await this.load(...units); + } catch (error) { + this.#log.warn({ event: 'restore.failed', err: error, attempts, url: this.url }); + void this.emit('restore-failed', { error, attempts }).catch((e) => this.#escalate(e, 'listener.restore-failed')); + // The attempt counter was reset to 0 the moment the socket opened, so + // a restore failing afterwards used to hand #scheduleReconnect a zero: + // retry at minDelay forever, the window never growing, `retries` never + // exhausting, the next fallback transport never reached. Put the + // pre-open count back FIRST — terminate() emits 'close' synchronously, + // and that is where the scheduler reads it. The guard covers the other + // ordering: a socket that died on its own already advanced the + // counter, and that number is the fresher one. + if (this.#attempt === 0) this.#attempt = attempts; + // Still "open" from the transport's point of view — force the cycle. + if (this.active) this.#transport.terminate(); + throw error; + } + await this.emit('reconnect', { units, attempts, subscriptions: subscriptions.length }); + } + + // Ends every live subscription NOW, loudly — the path a fallback onto a + // request/response transport takes: re-sending `subscribe` packets the + // server would refuse one by one (400 each) would be the quiet version + // of the same failure. + #failSubscriptions(message) { + if (this.#subscriptions.size === 0) return; + const records = Array.from(this.#subscriptions.values()); + this.#subscriptions.clear(); + for (const record of records) { + record.onRelease?.(); + const error = new WrpcError({ message, code: 400 }); + record.stream?.fail(error); + if (record.onError) record.onError(error); + else if (!record.stream) this.#escalate(error, 'subscription.error'); + } + } + + // The next candidate on the fallback list takes over: the dead + // transport's listeners come off (a late 'close' from its socket must + // not double-drive the new one), the URL is re-spelled for the new + // scheme, and capability loss is loud rather than silent. + #advanceTransport() { + if (!this.#transportNames || this.#transportIndex >= this.#transportNames.length - 1) return false; + const from = this.#transportNames[this.#transportIndex]; + this.#transportIndex++; + const to = this.#transportNames[this.#transportIndex]; + this.#unbindTransport(); + try { + this.#transport.terminate(); + } catch { + // Already dead — that is why we are here. + } + const Transport = WrpcClient.transport[to]; + this.#transport = new Transport(mapScheme(this.url, to)); + this.#bindTransport(); + if (this.#transport.persistent === false) { + this.#failSubscriptions(`Transport fell back to '${to}', which cannot carry subscriptions`); + } + this.#log.warn({ event: 'transport.fallback', from, to, url: this.url }); + this.emit('transport-fallback', { from, to }).catch((error) => + this.#escalate(error, 'listener.transport-fallback'), + ); + return true; + } + + #scheduleReconnect() { + if (this.active) return; + if (!WrpcClient.connections.has(this)) return; + if (this.#reconnectTimer) return; + const { retries } = this.#reconnect; + if (this.#attempt >= retries) { + // Exhaustion falls through the candidate list before it is final: + // retries are PER CANDIDATE (the counter resets), and the fresh + // candidate gets an immediate first try — backoff was guarding the + // old transport's endpoint, not this one. + if (this.#advanceTransport()) { + this.#attempt = 0; + return void this.open().catch((error) => { + this.#escalate(error, 'fallback.open'); + this.#scheduleReconnect(); + }); + } + this.#log.warn({ event: 'reconnect.failed', attempts: this.#attempt, url: this.url }); + this.#otel.recordReconnect('exhausted', this.#attempt); + return void this.emit('reconnect-failed', { attempts: this.#attempt }).catch((error) => + this.#escalate(error, 'listener.reconnect-failed'), + ); + } + const delay = backoffDelay({ ...this.#reconnect, attempt: this.#attempt, random: this.#random }); + this.#attempt++; + // Every scheduled attempt is one count ('recovered'/'exhausted' are the + // terminal markers), and the log line is info, not debug — a reconnect + // storm must be visible on a console logger too. + this.#otel.recordReconnect('attempted', this.#attempt); + this.#log.info({ event: 'reconnecting', attempt: this.#attempt, delay, url: this.url }); + this.emit('reconnecting', { attempt: this.#attempt, delay }).catch((error) => + this.#escalate(error, 'listener.reconnecting'), + ); + // Not unref'd: see the note on `unref` above. + this.#reconnectTimer = setTimeout(() => { + this.#reconnectTimer = null; + this.open().catch((error) => { + // A rejected open never emitted 'close', so nothing else would + // schedule the next attempt — the loop has to continue here. + this.#escalate(error, 'reconnect.open'); + this.#scheduleReconnect(); + }); + }, delay); + } + + // --------------------------------------------------------------------- + // Heartbeat: ping -> await pong -> ping. One timer is armed at a time, so + // a stalled connection cannot pile pings up behind the missing pong. + + #startHeartbeat() { + this.#stopHeartbeat(); + if (!this.#heartbeat || !this.#transport.heartbeat) return; + this.#armPing(); + } + + #stopHeartbeat() { + if (this.#pingTimer) clearTimeout(this.#pingTimer); + if (this.#pongTimer) clearTimeout(this.#pongTimer); + this.#pingTimer = null; + this.#pongTimer = null; + } + + #armPing() { + this.#pingTimer = unref( + setTimeout(() => { + this.#pingTimer = null; + this.#sendPing(); + }, this.#heartbeat.interval), + ); + } + + #sendPing() { + if (!this.active) return; + try { + this.send({ type: 'ping' }); + } catch (error) { + return void this.#escalate(error, 'heartbeat.ping'); + } + this.#pongTimer = unref( + setTimeout(() => { + this.#pongTimer = null; + this.#onHeartbeatTimeout(); + }, this.#heartbeat.timeout), + ); + } + + #onPong() { + if (!this.#pongTimer) return; // unsolicited pong: nothing was waiting + clearTimeout(this.#pongTimer); + this.#pongTimer = null; + if (this.#heartbeat && this.active) this.#armPing(); + } + + // The peer stopped answering: close so the transport reports 'close' and + // the normal reconnect path takes over. + #onHeartbeatTimeout() { + this.#log.warn({ event: 'heartbeat.timeout', url: this.url }); + // Like every lifecycle emit: a throwing listener on a documented event + // must surface through #escalate, not as an unhandled rejection. + this.emit('heartbeat-timeout').catch((error) => this.#escalate(error, 'listener.heartbeat-timeout')); + try { + this.#transport.terminate(); + } catch (error) { + this.#escalate(error, 'heartbeat.terminate'); + } + } + + // Re-evaluated on EVERY open, the function form included: a version bump, + // a rotated device id or a fresh bearer token is picked up by the + // reconnect that follows, not frozen at construction. + async #resolveDeclared(source) { + const value = typeof source === 'function' ? await source() : source; + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + return value; + } + + async open() { + WrpcClient.connections.add(this); + // A fresh options object per open, never a mutation: #options belongs + // to the application, and #advanceTransport keeps rebuilding candidates + // from the pristine url. + let options = this.#options; + if (this.#headers || this.#meta) { + options = { ...options }; + // Headers are ALWAYS stringified: `{ v: 2 }` used to arrive as '2' over + // http (fetch coerces) and vanish over ws (the server keeps a flat + // string map), so the two transports quietly disagreed. Meta keeps its + // JSON types here — only the prefixed carrier flattens them. + if (this.#headers) { + options.headers = normalizeDeclared(await this.#resolveDeclared(this.#headers), true, this.#log); + } + if (this.#meta) { + // The prefixed carrier makes every value a header value, so it + // stringifies — on ws and the worker too, where the JSON parameter + // could have carried richer types. The guarantee this option makes + // is about the bag the SERVER observes, not about the wire, and a + // bag whose shape changed with the transport would break it. + const prefixed = this.#metaFormat === 'prefixed'; + options.meta = normalizeDeclared(await this.#resolveDeclared(this.#meta), prefixed, this.#log); + if (prefixed) options.metaPrefixed = true; + } + } + // The handshake raced against connectTimeout: on expiry the transport is + // terminated, whose rejection/'close' hands control back to the normal + // reconnect cycle with the attempt counter intact. (A later settle of + // the lost open() promise is absorbed by the race — never unhandled.) + if (this.#connectTimeout > 0) { + let timer = null; + const expired = new Promise((resolve, reject) => { + timer = unref( + setTimeout(() => { + try { + this.#transport.terminate(); + } catch { + // Already dead — the rejection below carries the story. + } + reject(new WrpcError(CONNECT_TIMEOUT_ERROR)); + }, this.#connectTimeout), + ); + }); + try { + await Promise.race([this.#transport.open(options), expired]); + } finally { + clearTimeout(timer); + } + } else { + await this.#transport.open(options); + } + // Assigned synchronously by the 'open' handler above (every transport + // emits 'open' before its open() resolves), so a first connect with an + // authenticate hook is awaited here: connect() resolves authenticated. + const opened = this.#opened; + if (!opened) return; + this.#opened = null; + await opened; + } + + close() { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + clearTimeout(this.#stableTimer); + this.#stableTimer = null; + this.#opened = null; + this.#stopHeartbeat(); + // Anything still queued leaves before the socket does; a call whose + // packet never shipped would otherwise wait out its whole timeout. + this.flush(); + // Snapshotted and cleared BEFORE anyone is told: a listener that reacts by + // unsubscribing, or by closing again, then finds nothing left to do + // instead of mutating the map being walked — and `handle.closed` already + // reads true by the time it is asked. + const ended = Array.from(this.#subscriptions.values()); + this.#subscriptions.clear(); + // An explicit close ends the session: a later open() is a fresh start, + // not a reconnect, so it must not replay 'reconnect'. + this.#connected = false; + this.#attempt = 0; + WrpcClient.connections.delete(this); + // An explicit close settles everything in flight the same way a dropped + // connection does — flushed packets included, since their answers can + // no longer arrive. + this.#failCalls(); + this.#failStreams(); + this.#transport.close(); + // Last, with nothing left to tear down. Closing the client ends every + // subscription it carried, and a subscription that ended has to say so: + // `unsubscribe()` stays silent because the caller named that one feed, + // whereas close() is usually called by something else entirely (a page + // teardown, a shutdown hook), so the code owning the feed never asked. + for (const record of ended) this.#endSubscription(record); + } + + /** + * The terminal signal for one subscription, in the order the `end` packet + * delivers it. A listener that throws is contained rather than allowed to + * rob the rest of theirs — or, inside close(), to abandon the teardown. + */ + #endSubscription(record) { + try { + record.onRelease?.(); + record.onEnd?.(); + } catch (error) { + this.#escalate(error, 'subscription.listener'); + } + // Always, even if a listener threw: an `iterate()` consumer is parked in + // next() and would otherwise wait there forever. + record.stream?.end(); + } + + write(data) { + this.#transport.write(data); + } + + send(data) { + // Only calls batch: everything else is a control packet whose value is + // that it leaves now. + if (this.#batch && data?.type === 'call') return void this.#enqueue(data); + this.#transport.send(data); + } + + #enqueue(packet) { + // Serialized ONCE: the same text is the size accounting and the wire + // bytes — the old path stringified for the length, dropped the string, + // and paid stringify again at flush. Sizes are UTF-16 code units + // (byte-exact for ASCII payloads; a bound either way). + const text = this.#codec ? this.#codec.encode(packet) : JSON.stringify(packet); + const size = text.length; + this.#pending.push({ packet, text, size }); + this.#pendingBytes += size; + const { maxSize, maxBytes } = this.#batch; + if (this.#pending.length >= maxSize || this.#pendingBytes >= maxBytes) return void this.flush(); + this.#schedule(); + } + + #schedule() { + if (this.#flushTimer !== null) return; + const { flush } = this.#batch; + if (flush === 'microtask') { + this.#flushTimer = 'microtask'; + queueMicrotask(() => this.flush()); + return; + } + this.#flushTimer = unref(setTimeout(() => this.flush(), flush)); + } + + /** Sends whatever calls are waiting to be batched. Safe to call anytime. */ + flush() { + if (this.#flushTimer !== null && this.#flushTimer !== 'microtask') clearTimeout(this.#flushTimer); + this.#flushTimer = null; + if (this.#pending.length === 0) return; + const pending = this.#pending; + this.#pending = []; + this.#pendingBytes = 0; + // A batch of one is that packet: no reason to make the peer unwrap it. + // The frame is assembled from the texts serialized at enqueue — flush + // never runs JSON.stringify again. + let frame = pending[0].text; + // The per-call meta of everything in this frame, merged in order so the + // LAST writer wins. One POST has one header block, so this is a lossy + // SUMMARY for the infrastructure between the two ends — a gateway, a + // WAF, an access log — and deliberately not a data channel: each call's + // exact meta is already in its own packet's `meta` field, which the + // batch never touches and which is what becomes context.callMeta. + // Allocated only when something actually carries meta — and only for a + // transport that can put it on a request (metaHeaders): ws and the + // worker port ignore write()'s second argument, so building the + // aggregate there was allocation per batch element for nothing. + let meta = null; + if (this.#transport.metaHeaders === true) { + for (let i = 0; i < pending.length; i++) { + const carried = pending[i].packet.meta; + if (carried) meta = Object.assign(meta ?? {}, carried); + } + } + if (pending.length > 1) { + if (this.#codec) { + // Only the codec knows its framing, so the batch array is encoded + // whole — per-item texts served the size accounting (a documented + // double encode on this deliberate slow path). + const packets = new Array(pending.length); + for (let i = 0; i < pending.length; i++) packets[i] = pending[i].packet; + frame = this.#codec.encode(packets); + } else { + // Concatenated in place: map() built a throwaway array of the very + // texts already sitting in `pending`, just to hand them to join(). + frame = `[${pending[0].text}`; + for (let i = 1; i < pending.length; i++) frame += `,${pending[i].text}`; + frame += ']'; + } + } + try { + this.#transport.write(frame, meta); + } catch (error) { + // The frame never left. Settle its calls NOW with the coded 503 every + // other dead-connection path produces — waiting out callTimeout on a + // corpse is exactly what #failCalls exists to prevent, and these ids + // are invisible to it once spliced out of #pending. + for (let i = 0; i < pending.length; i++) this.#failCall(pending[i].packet.id); + this.#escalate(error, 'batch.flush'); + } + } + + // A call cancelled before its batch left never has to be cancelled on the + // wire — dropping it here is both cheaper and safer than racing a `cancel` + // packet ahead of the `call` it refers to. + #unqueue(id) { + const index = this.#pending.findIndex((entry) => entry.packet.id === id); + if (index < 0) return false; + const [entry] = this.#pending.splice(index, 1); + this.#pendingBytes -= entry.size; + return true; + } + + getStream(id) { + const stream = this.#streams.get(id); + if (stream) return stream; + throw new Error(`Stream ${id} is not initialized`); + } + + createStream(name, size) { + const id = this.#generateId(); + // The binary chunk header stores the id length in one byte, so a custom + // generator emitting more than 255 UTF-8 bytes would only fail later, + // deep inside chunkEncode — refuse it here, where the cause is visible. + if (typeof id !== 'string' || id.length === 0 || id.length > 255) { + throw new TypeError('createStream: generateId must return a string of at most 255 characters'); + } + return new WrpcWritable(id, name, size, this); + } + + createBlobUploader(blob) { + const { name = 'blob', size } = blob; + const consumer = this.createStream(name, size); + const { id } = consumer; + const upload = async () => { + for await (const chunk of blob.stream()) { + consumer.write(chunk); + } + consumer.end(); + }; + return { id, upload }; + } + + // Inbound half of the wire codec; null for malformed, like jsonParse. + #decodePacket(text) { + if (!this.#codec) return jsonParse(text); + try { + return this.#codec.decode(text); + } catch { + return null; + } + } + + async #handlePacket(data) { + const packet = this.#decodePacket(data); + if (!packet) { + if (this.#proxyPacket) return void this.#proxyPacket(data, null); + throw new Error('Invalid JSON packet'); + } + // A batch frame answers several packets at once; each one is dispatched + // exactly as it would have been on its own. + if (Array.isArray(packet)) { + if (this.#proxyPacket) return void this.#proxyPacket(data, packet); + // Contained per item: one answer this client cannot route must not + // strand the rest of the batch, which is the guarantee the protocol + // makes about a failure inside a batch. + for (const item of packet) { + try { + await this.#dispatch(item ?? {}); + } catch (error) { + this.#escalate(error, 'batch.dispatch'); + } + } + return; + } + const { type } = packet; + // Heartbeat first, and before the proxy hand-off: the pong answers a + // ping this client sent, so it is never anyone else's packet. + if (type === 'pong') return void this.#onPong(); + if (type === 'ping') return void this.send({ type: 'pong' }); + if (this.#proxyPacket) return void this.#proxyPacket(data, packet); + await this.#dispatch(packet); + } + + async #dispatch(packet) { + const { type, id, name } = packet; + if (type === 'event') { + // An event carrying an id is an ask: the server wants an answer, and + // that is a different contract from fan-out delivery to listeners. + if (typeof id === 'string' && id) return void (await this.#answerAsk(name, packet.data, id)); + return void (await this.#handleEvent(name, packet.data)); + } + if (!id) throw new Error('Packet structure error'); + if (type === 'callback') return void this.#settle(packet); + if (type === 'data' || type === 'end') return void this.#handleSubscriptionPacket(packet); + if (type === 'stream') await this.#handleStream(packet); + } + + #settle(packet) { + const { id } = packet; + const call = this.#calls.get(id); + // The 499 acknowledging a cancel this client sent: the caller was + // rejected the moment it aborted, so the ack is expected, not an error. + if (!call && this.#cancelled.delete(id)) return; + if (!call) throw new Error(`Callback ${id} not found`); + this.#calls.delete(id); + clearTimeout(call.timeout); + call.release?.(); + if (packet.error) return void call.reject(new WrpcError(packet.error)); + call.resolve(packet.result); + } + + // `data` carries one value of a subscription; `end` closes it, with an + // `error` when the server-side generator threw. A packet for a + // subscription this client already dropped is ignored: the unsubscribe and + // the last values in flight cross on the wire, and that is normal. + #handleSubscriptionPacket(packet) { + const { type, id } = packet; + const record = this.#subscriptions.get(id); + if (!record) return; + if (type === 'data') { + // Remembered for the resume after a reconnect. Untracked values leave + // it alone: a feed with no ids simply has no resume point. + if (packet.eventId !== undefined) record.lastEventId = packet.eventId; + // A delivered value proves the credential works again: the NEXT + // expiry, hours from now, earns its own one-shot refresh. + if (record.refreshed) record.refreshed = false; + record.onData?.(packet.data); + record.stream?.push(packet.data); + return; + } + // The restore half of #withRefresh. After an outage longer than the + // credential, the reconnect's re-subscribe earns a terminal refusal + // while plain calls heal through refresh — a healthy-looking client + // whose feeds are silently dead. Keep the record, run the single-flight + // refresh, re-open ONCE; a second refusal takes the terminal path below + // (record.refreshed blocks re-entry), so there is no loop by + // construction. + if (packet.error && this.#refresh && !record.refreshed && this.#refreshCodes.includes(packet.error.code)) { + record.refreshed = true; + void this.#runRefresh(new WrpcError(packet.error)) + .then(() => { + if (this.active && this.#subscriptions.has(id)) this.#openSubscription(record); + }) + .catch(() => { + // The refresh's own failure: deliver the refusal the feed earned. + if (this.#subscriptions.delete(id)) this.#failSubscription(record, packet.error); + }); + return; + } + this.#subscriptions.delete(id); + if (!packet.error) return void this.#endSubscription(record); + this.#failSubscription(record, packet.error); + } + + // Terminal error delivery for one subscription — the `end`-with-error + // shape, shared by the direct path and the failed-refresh fallback above. + #failSubscription(record, wireError) { + record.onRelease?.(); + const error = new WrpcError(wireError); + record.stream?.fail(error); + if (record.onError) return void record.onError(error); + // Nobody asked to hear about it, but a subscription that died must not + // die quietly. + if (!record.stream) this.#escalate(error, 'subscription.error'); + } + + /** + * Registers the answer this client gives when the server asks `name` + * ('unit/event'). One responder per name — two answers to one question + * are ambiguous, so a duplicate registration throws, like Emitter's + * duplicate-listener guard. Client-level rather than per-unit on purpose: + * unit objects carry server-named methods, where a method called + * 'respond' would collide. + */ + respond(name, handler) { + if (typeof name !== 'string' || name.length === 0) { + throw new TypeError('respond: name must be a non-empty string'); + } + if (typeof handler !== 'function') { + throw new TypeError('respond: handler must be a function'); + } + if (this.#responders.has(name)) { + throw new Error(`Duplicate responder for '${name}'`); + } + this.#responders.set(name, handler); + } + + unrespond(name) { + return this.#responders.delete(name); + } + + // The server asked: run the responder and answer with the callback packet + // the id makes addressable. No responder is answered too — a 501 now beats + // the server discovering nothing at its timeout. + async #answerAsk(name, data, id) { + const responder = typeof name === 'string' ? this.#responders.get(name) : undefined; + if (!responder) { + this.send({ type: 'callback', id, error: { message: `No responder for '${name}'`, code: 501 } }); + return void (await this.emit('unhandled-event', { name, data, id })); + } + try { + const result = await responder(data); + this.send({ type: 'callback', id, result }); + } catch (error) { + const code = typeof error?.code === 'number' ? error.code : 500; + const wire = { message: error?.message ?? 'Responder failed', code }; + // Same exposure rule the server applies: 4xx details are part of the + // conversation, a 5xx's internals stay here unless the error opts in. + if (error?.details !== undefined && (code < 500 || error.expose === true)) wire.details = error.details; + this.send({ type: 'callback', id, error: wire }); + } + } + + // Events are addressed 'unit/event'. One that reaches no listener — an + // unloaded unit, or a loaded one nobody subscribed to — surfaces as + // 'unhandled-event' rather than vanishing: a silently dropped broadcast is + // indistinguishable from a broken server. + async #handleEvent(name, data) { + if (typeof name === 'string') { + const slash = name.indexOf('/'); + if (slash > 0) { + const unit = name.slice(0, slash); + const eventName = name.slice(slash + 1); + // `api` is a plain object, so a wire-supplied unit like 'constructor' + // or 'toString' resolves up the prototype chain. Only an Emitter this + // client put there itself is a real unit — anything else is an event + // nobody is listening for. + const apiUnit = this.api[unit]; + if (eventName && apiUnit instanceof Emitter && apiUnit.listenerCount(eventName) > 0) { + return void (await apiUnit.emit(eventName, data)); + } + } + } + await this.emit('unhandled-event', { name, data }); + } + + async #handleStream(packet) { + const { id, name, size, status } = packet; + const stream = this.#streams.get(id); + if (status === undefined) { + if (stream) { + throw new Error(`Stream ${name} is already initialized`); + } + const readableStream = new WrpcReadable(id, name, size); + this.#streams.set(id, readableStream); + return; + } + if (!stream) throw new Error(`Stream ${id} is not initialized`); + if (status === 'end') { + await stream.close(); + this.#streams.delete(id); + } else if (status === 'terminate') { + await stream.terminate(); + this.#streams.delete(id); + } + } + + async #handleBinary(input) { + const byteView = await toByteView(input); + const { id, payload } = chunkDecode(byteView); + const stream = this.#streams.get(id); + if (!stream) { + throw new Error(`Stream ${id} is not initialized`); + } + await stream.push(payload); + } + + async load(...units) { + if (!this.active) throw new Error('Not connected'); + const introspection = await this.#call('system/introspect', units); + for (const unit of units) { + const instance = introspection[unit]; + if (!instance) continue; + this.#loaded.add(unit); + // Dynamic wins: a live introspection supersedes a static artifact for + // the same unit, and from here on the unit reloads on reconnect. + this.#static.delete(unit); + this.#scaffoldUnit(unit, instance); + } + } + + /** + * Static introspection: scaffolds units from an introspection artifact + * (the raw `system/introspect` shape, e.g. generated by + * `wrpc types --schema`) with no wire traffic. Synchronous and usable + * before open(). A unit already load()ed is skipped (dynamic wins); + * static units are never re-introspected on reconnect. + */ + use(introspection) { + if (typeof introspection !== 'object' || introspection === null || Array.isArray(introspection)) { + throw new TypeError('use() takes an introspection object: { unit: { method: info } }'); + } + for (const unit of Object.keys(introspection)) { + const instance = introspection[unit]; + if (typeof instance !== 'object' || instance === null || Array.isArray(instance)) { + throw new TypeError(`use(): unit '${unit}' must be an object of method infos`); + } + if (this.#loaded.has(unit)) continue; + this.#static.add(unit); + this.#scaffoldUnit(unit, instance); + } + return this; + } + + #scaffoldUnit(unit, instance) { + // Reuse the unit's emitter when it already exists: a reconnect reloads + // every unit, and replacing the object would silently drop every event + // listener the caller registered on it. + let methods = this.api[unit]; + if (!(methods instanceof Emitter)) { + methods = new Emitter(); + // defineProperty, not assignment: a unit named '__proto__' would go + // through Object.prototype's setter and mutate the prototype instead + // of becoming a unit. + Object.defineProperty(this.api, unit, { + value: methods, + enumerable: true, + writable: true, + configurable: true, + }); + } + const request = this.#scaffold(unit); + // 'on' and 'emits' are the introspection's typed-event blocks, not + // methods — scaffolding them would SHADOW the unit Emitter's own `on`. + const methodNames = []; + for (const name of Object.keys(instance)) { + if (name !== 'on' && name !== 'emits') methodNames.push(name); + } + const previous = this.#unitMethods.get(unit); + if (previous) { + // A method the server no longer exposes must stop being callable. + for (const methodName of previous) { + if (!methodNames.includes(methodName)) delete methods[methodName]; + } + } + for (const methodName of methodNames) { + methods[methodName] = request(methodName, instance[methodName]); + } + this.#unitMethods.set(unit, new Set(methodNames)); + } + + /** Sends a fire-and-forget event to the server; `name` is 'unit/event'. */ + sendEvent(name, data) { + const packet = { type: 'event', name, data }; + // Fire-and-forget still deserves a parent: the server opens a CONSUMER + // span for the event, and this is what links it to the caller's trace. + this.#otel.inject(packet); + this.send(packet); + } + + // `unit` is the introspection unit key — 'auth' or the pinned 'auth.v1' — + // so the version already rides inside it; the wire target is 'unit/name'. + #target(unit, methodName) { + return `${unit}/${methodName}`; + } + + /** + * One call addressed by wire target ('unit/name', 'unit.vN/name') with no + * scaffolding: the escape hatch for a method whose unit has not been + * load()ed — which is every method inside an `authenticate` hook on a + * first connect, since `api` is built by load() and load() runs after + * auth. No client-side pre-validation and no REST leg: the packet path. + */ + call(method, args = {}, options = {}) { + return this.#call(method, args, options); + } + + // One-shot retry through the injected refresh hook: on a refusal whose + // code the hook covers, run the (single-flight) refresh and re-issue the + // call ONCE — with a fresh packet id, since the once-path builds a new + // packet. The retry calls the once-path directly, so a second refusal + // surfaces as-is: no loop by construction. Never fires inside the + // authenticate hook (#authenticating) — the hook heals connections, and a + // refresh triggered by its own calls would recurse. + #withRefresh(issue) { + // Captured at issue time: a call made BY the refresh handler is issued + // while #refreshing is set, and joining that same run would await the + // very promise that is awaiting this call — a permanent, client-wide + // wedge with no timer left to break it (the call's own timeout was + // cleared when the refusal arrived). Such a call surfaces its refusal + // instead. Calls issued before the run started see null here and join + // as ever, which is what keeps ten concurrent 401s at one refresh. + const joined = this.#refreshing; + return issue().catch(async (error) => { + if (!this.active || this.#authenticating || !this.#refreshCodes.includes(error?.code)) throw error; + if (this.#refreshing !== null && this.#refreshing === joined) throw error; + try { + await this.#runRefresh(error); + } catch { + // The refresh's own failure is the hook's private business — the + // caller gets the refusal it actually received. + throw error; + } + return issue(); + }); + } + + #runRefresh(error) { + // Ten concurrent refusals produce ONE handler run; everyone awaits it. + // The first refusal's error is the one the handler sees. The handler is + // invoked one microtask LATER, never synchronously: #withRefresh's + // joined-run capture reads #refreshing at issue time, and a handler + // whose synchronous prefix issues a call must find the run already + // assigned — or that call would join the run that is awaiting it. + if (this.#refreshing) return this.#refreshing; + this.#refreshing = Promise.resolve() + .then(() => this.#refresh(this, error)) + .then( + (value) => { + this.#otel.recordRefresh('ok'); + return value; + }, + (refreshError) => { + // Observable ONCE per run, not per joined caller — symmetric with + // the authenticate hook, which logs and emits on failure. A + // refresh dying silently left credential rot invisible: callers + // surface their original refusal, so nothing else says why. + this.#otel.recordRefresh('failed'); + this.#log.warn({ event: 'refresh.failed', err: refreshError }); + void this.emit('refresh-failed', { error: refreshError, cause: error }).catch((e) => + this.#escalate(e, 'listener.refresh-failed'), + ); + throw refreshError; + }, + ) + .finally(() => { + this.#refreshing = null; + }); + return this.#refreshing; + } + + #call(target, args, options = {}) { + const issue = this.#refresh + ? () => this.#withRefresh(() => this.#callOnce(target, args, options)) + : () => this.#callOnce(target, args, options); + if (!this.#retry) return issue(); + return this.#withRetry(issue, options.signal); + } + + // The retry loop around one call: coded failures in `retry.on` re-issue + // through the SAME issue thunk (fresh packet id each time — the once-path + // builds a new packet), after a jittered backoff. Wrapped OUTSIDE the + // refresh machinery, so a 401 heals through refresh first and only the + // codes the policy names reach here. Never retries a cancelled call. + async #withRetry(issue, signal) { + let attempt = 0; + while (true) { + try { + return await issue(); + } catch (error) { + if (attempt >= this.#retry.attempts) throw error; + if (signal?.aborted || error?.code === 499) throw error; + if (!this.#retry.on.includes(error?.code)) throw error; + if (!WrpcClient.connections.has(this)) throw error; // closed for good + const delay = backoffDelay({ ...this.#retry, attempt, random: this.#random }); + attempt++; + await new Promise((resolve) => unref(setTimeout(resolve, delay))); + } + } + } + + #callOnce(target, args, options = {}) { + const id = this.#generateId(); + const { signal } = options; + const packet = { type: 'call', id, method: target, args }; + if (options.meta !== undefined) packet.meta = options.meta; + // The per-call deadline (CallOptions.timeout): the local timer below + // uses it, and it RIDES THE PACKET so the server shortens the + // procedure's own budget to match — no point finishing work whose + // caller already gave up. Additive field; absent unless set. + if (options.timeout > 0) packet.timeout = options.timeout; + if (!this.#otel.enabled) return this.#dispatchCall(target, packet, id, signal, options.timeout); + const started = now(); + return this.#otel.withSpan({ packet, target }, (handle) => { + // Injected INSIDE the span so the traceparent names this call's span, + // which is what the server will pick up as its parent. + this.#otel.inject(packet); + return this.#dispatchCall(target, packet, id, signal, options.timeout).then( + (result) => { + this.#otel.endSpan(handle, { 'wrpc.status': 'ok' }); + this.#otel.recordCall(target, 'ok', now() - started); + return result; + }, + (error) => { + this.#otel.recordError(handle, error); + this.#otel.endSpan(handle, { 'wrpc.status': 'error', 'rpc.wrpc.status_code': error?.code }); + this.#otel.recordCall(target, 'error', now() - started); + throw error; + }, + ); + }); + } + + // The telemetry bracket for the REST leg, mirroring #callOnce: same span + // shape, same duration metric — a mapped call must not vanish from traces + // because the transport spelled it as a REST request. Trace context rides + // REAL traceparent/tracestate headers (there is no packet to carry tp/ts); + // the server maps them back onto its synthetic packet (copyTraceHeaders). + #restCall(http, args = {}, options = {}, target = http.path) { + if (!this.#otel.enabled) return this.#restCallOnce(http, args, options); + const started = now(); + return this.#otel.withSpan({ packet: { type: 'call' }, target }, (handle) => { + const trace = {}; + // Injected INSIDE the span, like #callOnce: the header names this + // call's span, which the server picks up as its parent. + this.#otel.injectHeaders(trace); + return this.#restCallOnce(http, args, options, trace).then( + (result) => { + this.#otel.endSpan(handle, { 'wrpc.status': 'ok' }); + this.#otel.recordCall(target, 'ok', now() - started); + return result; + }, + (error) => { + this.#otel.recordError(handle, error); + this.#otel.endSpan(handle, { 'wrpc.status': 'error', 'rpc.wrpc.status_code': error?.code }); + this.#otel.recordCall(target, 'error', now() - started); + throw error; + }, + ); + }); + } + + // The REST leg of a mapped procedure: args arrive in the same + // { params, query, body } shape the procedure sees on every transport; + // here they become the path, the query string and the JSON body. The + // response is the plain result (or the wire error object) — external + // REST semantics, not a callback envelope. + async #restCallOnce(http, args = {}, options = {}, trace = null) { + const { signal, meta } = options; + if (signal?.aborted) throw new WrpcError(CANCELLED_ERROR); + const method = http.method; + const safeMethod = method === 'GET' || method === 'HEAD'; + // codec.rest frames the REST leg's bodies (values, not packets; binary + // allowed) in both directions; without it the leg speaks JSON as ever. + const rest = this.#codecRest; + const encodeBody = () => (rest ? rest.encode(args.body) : JSON.stringify(args.body)); + const body = safeMethod || args?.body === undefined ? undefined : encodeBody(); + const url = this.#restUrl(http, args); + let res; + try { + // On REST there is no packet, so per-call meta and connection meta are + // the SAME channel — the server unpacks the request's whole declared + // bag into packet.meta. Merging is the existing semantics rather than + // a workaround, and the per-call half wins. Safe from the batching + // problem by construction: this leg is one call per request. + res = await this.#transport.request(method, url, body, signal, { rest, meta, trace }); + } catch (error) { + if (signal?.aborted) throw new WrpcError(CANCELLED_ERROR); + throw new WrpcError({ message: `HTTP request failed: ${error?.message ?? error}`, code: 503 }); + } + if (res.status === 204) return undefined; + let parsed; + if (rest) { + // Error statuses decode through the same codec — the wire error + // object arrives codec-framed exactly like a result. + try { + parsed = res.body.length === 0 ? undefined : rest.decode(res.body); + } catch { + parsed = null; + } + } else { + parsed = jsonParse(res.text); + } + if (res.status >= 200 && res.status < 300) return parsed; + const wire = parsed && typeof parsed === 'object' && typeof parsed.message === 'string' ? parsed : null; + throw new WrpcError({ + message: wire?.message ?? `HTTP request failed (${res.status})`, + code: wire?.code ?? res.status, + details: wire?.details, + }); + } + + #restUrl(http, args) { + const params = args?.params ?? {}; + const segments = http.path === '/' ? [] : http.path.slice(1).split('/'); + const parts = new Array(segments.length); + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + if (segment.startsWith(':')) { + const value = params[segment.slice(1)]; + if (value === undefined) { + throw new WrpcError({ + message: `Missing path param '${segment.slice(1)}' for ${http.method} ${http.path}`, + code: 400, + }); + } + parts[i] = encodeURIComponent(String(value)); + } else { + parts[i] = segment; + } + } + const baseUrl = this.#transport.url ?? this.url; + let url = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + if (parts.length > 0) url += `/${parts.join('/')}`; + const query = args?.query; + if (query && Object.keys(query).length > 0) { + const text = this.#querystring ? this.#querystring.stringify(query) : String(new URLSearchParams(query)); + if (text) url += `?${text}`; + } + return url; + } + + #dispatchCall(target, packet, id, signal, callTimeout) { + return new Promise((resolve, reject) => { + if (signal?.aborted) return void reject(new WrpcError(CANCELLED_ERROR)); + const timeout = setTimeout( + () => { + if (!this.#calls.has(id)) return; + this.#calls.delete(id); + this.#unqueue(id); + release(); + reject(new WrpcError(REQUEST_TIMEOUT_ERROR)); + }, + callTimeout > 0 ? callTimeout : this.#callTimeout, + ); + const onAbort = () => { + if (!this.#calls.has(id)) return; + this.#calls.delete(id); + clearTimeout(timeout); + // Still queued: drop it instead of racing a cancel ahead of the + // call. And a request/response transport cannot carry a cancel at + // all — sending one there only earns a 400 nobody can route. + if (!this.#unqueue(id) && this.active && this.#transport.persistent !== false) { + this.#cancelled.add(id); + this.send({ type: 'cancel', id }); + } + reject(new WrpcError(CANCELLED_ERROR)); + }; + const release = () => signal?.removeEventListener('abort', onAbort); + signal?.addEventListener('abort', onAbort, { once: true }); + this.#calls.set(id, { resolve, reject, timeout, release }); + try { + this.send(packet); + } catch (error) { + // A dead transport throws synchronously ('Not connected'). Reject + // CODED like every other dead-connection path — and clean up now, + // or the armed timer would hold the entry for the full callTimeout. + this.#calls.delete(id); + clearTimeout(timeout); + release(); + reject(new WrpcError({ message: error?.message ?? 'Connection closed', code: 503 })); + } + }); + } + + /** + * Opens a subscription. `onData` receives every value; the returned handle + * carries the last seen eventId and can stop it. The subscription is + * re-opened from that eventId automatically after a reconnect. + */ + #subscribe(target, args, options = {}) { + const id = this.#generateId(); + const record = { + id, + target, + args, + lastEventId: options.lastEventId, + onData: options.onData ?? null, + onError: options.onError ?? null, + onEnd: options.onEnd ?? null, + stream: options.stream ?? null, + // Lets iterate() drop its abort listener however the subscription ends. + onRelease: options.onRelease ?? null, + // One-shot guard for the refresh-and-reopen path (see + // #handleSubscriptionPacket): reset by the next delivered value. + refreshed: false, + }; + const live = this.#subscriptions; + live.set(id, record); + this.#openSubscription(record); + return { + id, + unsubscribe: () => this.#unsubscribe(id), + get lastEventId() { + return record.lastEventId; + }, + get closed() { + return !live.has(id); + }, + }; + } + + #openSubscription(record) { + const packet = { type: 'subscribe', id: record.id, method: record.target, args: record.args }; + if (record.lastEventId !== undefined && record.lastEventId !== null) { + packet.lastEventId = record.lastEventId; + } + // The server-side subscription span becomes a child of whatever trace + // was active when the subscription was opened (or re-opened). + this.#otel.inject(packet); + this.send(packet); + } + + #unsubscribe(id) { + const record = this.#subscriptions.get(id); + if (!record) return false; + this.#subscriptions.delete(id); + record.onRelease?.(); + record.stream?.end(); + // The server answers with `end`, which lands on a record that is gone — + // ignored on purpose, the caller already knows. + if (this.active) this.send({ type: 'unsubscribe', id }); + return true; + } + + #scaffold(unit) { + const createMethod = (methodName, info = {}) => { + const target = this.#target(unit, methodName); + if (info.kind !== 'subscription') { + // Compiled once per scaffold — load() re-runs on every reconnect, + // so a schema change on the server lands with the reload. + const prevalidate = this.#validation && info.schema ? compilePrevalidate(this.#validation, info.schema) : null; + const guard = (args) => { + if (!prevalidate) return null; + try { + prevalidate(args); + } catch (error) { + return Promise.reject(error); + } + return null; + }; + // A procedure with an `http` mapping, on a transport that can carry + // it, goes out as the SAME REST request an external consumer would + // send — one endpoint, two audiences. Every other transport speaks + // packets as always. + let fn; + if (info.http && this.#transport.rest === true) { + fn = (args = {}, options = {}) => { + const refused = guard(args); + if (refused) return refused; + const issue = this.#refresh + ? () => this.#withRefresh(() => this.#restCall(info.http, args, options, target)) + : () => this.#restCall(info.http, args, options, target); + return this.#retry ? this.#withRetry(issue, options.signal) : issue(); + }; + } else { + fn = (args = {}, options = {}) => guard(args) ?? this.#call(target, args, options); + } + // Per-call metadata, bound once: api.unit.m.withMeta({ idem })(args). + // Works on both legs — the packet path carries it as the packet's + // `meta` field, the mapped REST leg as request headers. + // Keys are normalized HERE, at bind time rather than per invocation, + // so a bound method costs no more than it did before. Values keep + // their JSON types — the packet field is JSON, and only a header + // carrier has to flatten. The raw client.call(target, args, { meta }) + // escape hatch is deliberately NOT normalized: it is the seam the + // auth hooks write against, and it hands the wire exactly what it was + // given. + fn.withMeta = (meta) => { + const bound = normalizeDeclared(meta, false, this.#log); + return (args = {}, options = {}) => fn(args, { ...options, meta: bound }); + }; + return fn; + } + // A subscription is not callable: it answers with a stream, so it + // exposes the two ways to consume one instead of pretending to be a + // function that resolves once. + return { + kind: 'subscription', + subscribe: (args = {}, options = {}) => this.#subscribe(target, args, options), + iterate: (args = {}, options = {}) => this.#iterate(target, args, options), + }; + }; + return createMethod; + } + + #iterate(target, args, options = {}) { + const { signal } = options; + const stream = createEventStream({ signal, highWaterMark: options.highWaterMark }); + // An already-aborted signal means the caller is gone before it started: + // opening a subscription nobody will consume would leave a generator + // running on the server with no handle to stop it. + if (signal?.aborted) { + const iterator = stream[Symbol.asyncIterator](); + iterator.subscription = { id: null, lastEventId: undefined, closed: true, unsubscribe: () => false }; + return iterator; + } + // Breaking out of `for await` (or aborting) has to reach the server, and + // whichever ends it first has to release the listener the other used. + let stop = null; + const release = () => { + if (!stop) return; + signal?.removeEventListener('abort', stop); + stop = null; + }; + const handle = this.#subscribe(target, args, { + ...options, + stream, + onData: options.onData ?? null, + onRelease: release, + }); + stop = () => { + stop = null; + handle.unsubscribe(); + }; + signal?.addEventListener('abort', stop, { once: true }); + const iterator = stream[Symbol.asyncIterator](); + const originalReturn = iterator.return.bind(iterator); + iterator.return = () => { + release(); + handle.unsubscribe(); + return originalReturn(); + }; + iterator.subscription = handle; + return iterator; + } +} + +module.exports = { + WrpcClient, + WrpcError, + ClientTransport, + isClientTransport, + WRPC_PROTOCOL, + CALL_TIMEOUT, + normalizeReconnect, + metaHeaders, + unref, + toByteView, +}; diff --git a/src/client/proxy.js b/src/client/proxy.js new file mode 100644 index 0000000..5290561 --- /dev/null +++ b/src/client/proxy.js @@ -0,0 +1,127 @@ +'use strict'; + +// The Service Worker proxy: forwards packets between a page's WrpcClient +// and a worker-held connection. Shares nothing with WrpcClient beyond +// calling connect() — which is why it lives in its own module. + +const { Emitter, jsonParse } = require('../utils.js'); +const { WrpcClient, CALL_TIMEOUT, normalizeReconnect } = require('./core.js'); + +class WrpcClientProxy extends Emitter { + #ports = new Set(); + #pending = new Map(); + #connection = null; + #callTimeout = CALL_TIMEOUT; + #reconnect = null; + #heartbeat = undefined; + #logger = undefined; + #telemetry = undefined; + + constructor(options = {}) { + super(); + const { callTimeout, heartbeat, logger, telemetry } = options; + if (callTimeout) this.#callTimeout = callTimeout; + this.#reconnect = normalizeReconnect(options); + this.#heartbeat = heartbeat; + // The proxy rebuilds its own options bag, so anything not forwarded here + // is silently lost on the connection it owns. + this.#logger = logger; + this.#telemetry = telemetry; + if (typeof self === 'undefined') { + throw new Error('WrpcClientProxy must run in ServiceWorker context'); + } + self.addEventListener('message', (event) => { + const { type } = event.data; + if (type?.startsWith('wrpc')) this.#handleEvent(event); + }); + } + + async open() { + if (this.#connection) { + if (this.#connection.active) return; + await this.#connection.open(); + return; + } + const protocol = self.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${protocol}//${self.location.host}`; + const options = { + callTimeout: this.#callTimeout, + reconnect: this.#reconnect, + heartbeat: this.#heartbeat, + logger: this.#logger, + telemetry: this.#telemetry, + proxy: (data, packet) => this.#proxyPacket(data, packet), + }; + this.#connection = await WrpcClient.connect(url, options); + } + + close() { + if (!this.#connection) return; + this.#connection.close(); + this.#connection = null; + } + + #handleEvent(event) { + const { type } = event.data; + if (type === 'wrpc:connect') { + const port = event.ports[0]; + if (!port) throw new Error('MessagePort not provided'); + this.#ports.add(port); + port.addEventListener('message', (messageEvent) => { + this.#handleMessage(messageEvent, port); + }); + port.start(); + return; + } + if (type === 'wrpc:online') WrpcClient.online(); + else if (type === 'wrpc:offline') WrpcClient.offline(); + else throw new Error(`Unknown event: ${type}`); + } + + async #handleMessage(event, port) { + const { data } = event; + if (data === undefined) throw new Error('Message data is undefined'); + await this.open(); + if (!this.#connection || !this.#connection.active) { + throw new Error('Not connected to server'); + } + const packet = jsonParse(data); + if (!packet) throw new Error('Invalid JSON packet'); + // The worker is the page's peer: it answers the page's heartbeat itself + // rather than paying for a round trip to the server for every port. + if (packet.type === 'ping') return void port.postMessage(JSON.stringify({ type: 'pong' })); + if (packet.type === 'pong') return; + if (!packet.id) throw new Error('Invalid JSON packet'); + this.#pending.set(packet.id, port); + this.#connection.write(data); + } + + // `packet` is the already-parsed form of `data` (the client parses once); + // it is null when the frame was not JSON at all. + #proxyPacket(data, packet) { + if (typeof data !== 'string') return void this.#broadcast(data); + const parsed = packet ?? jsonParse(data); + if (!parsed) return void this.#broadcast(data); + const { type, id, status } = parsed; + if (type === 'event') return void this.#broadcast(data); + const port = this.#pending.get(id); + if (!port) return void this.#broadcast(data); + port.postMessage(data); + // `end` is a subscription's terminal packet, so it releases its slot the + // same way a callback does — otherwise every subscription a page opens + // pins a port reference in the worker for the life of the connection. + if (type === 'callback' || type === 'end') return void this.#pending.delete(id); + if (type !== 'stream') return; + const streamDone = status === 'end' || status === 'terminate'; + if (streamDone) this.#pending.delete(id); + } + + #broadcast(data, excludePort = null) { + for (const port of this.#ports) { + if (port === excludePort) continue; + port.postMessage(data); + } + } +} + +module.exports = { WrpcClientProxy }; diff --git a/src/client/transports.js b/src/client/transports.js new file mode 100644 index 0000000..0f7921a --- /dev/null +++ b/src/client/transports.js @@ -0,0 +1,325 @@ +'use strict'; + +// The three built-in client transports, registered on WrpcClient.transport +// exactly the way the SSE subpath registers its own — the registry is the +// one seam every transport, built-in or not, goes through. + +const { WrpcClient, ClientTransport, WRPC_PROTOCOL, metaHeaders } = require('./core.js'); +const { HEADERS_PARAM, META_PARAM } = require('../wire.js'); + +// Mirrors the server's metaMaxBytes default: past it the server drops the +// entire declared bag, so refusing here is the difference between a visible +// warning and a label that silently stopped arriving. +const META_MAX = 2048; +const { jsonParse } = require('../utils.js'); +const { WebSocket } = globalThis; + +class ClientWsTransport extends ClientTransport { + // The one transport that can die without saying so. + heartbeat = true; + + #socket = null; + #opening = null; + + async open(options = {}) { + if (this.active) return Promise.resolve(); + if (this.#opening) return this.#opening; + const opening = new Promise((resolve, reject) => { + // The client OFFERS the protocol revision; the server echoes it (see + // protocol.md#versioning). `protocols` overrides the offer, and an + // empty array offers nothing — an escape hatch for a proxy that + // mangles the header. The selected protocol lands on `this.protocol`. + let protocols = options.protocols ?? [WRPC_PROTOCOL]; + // The Authorization header is the ONE declared name that is a secret, + // and the connect URL lands in proxy access logs. RFC 6455 gives ws a + // header that survives the WHATWG constructor — the subprotocol offer + // — so a Bearer credential rides as `wrpc.bearer.` and is + // stripped from the wrpc_h bag (the server's bearer transport reads + // sec-websocket-protocol first). A token outside the RFC 7230 token + // charset cannot be a subprotocol name and falls back to the query, + // with the loud caveat below. + let bag = options.headers; + const auth = bag?.authorization; + const bearer = typeof auth === 'string' && auth.startsWith('Bearer ') ? auth.slice(7) : null; + if (bearer && /^[!#$%&'*+.^_`|~A-Za-z0-9-]+$/.test(bearer)) { + protocols = [...protocols, `wrpc.bearer.${bearer}`]; + bag = { ...bag }; + delete bag.authorization; + if (Object.keys(bag).length === 0) bag = null; + } + // Connection-phase headers ride as ONE query parameter: the WHATWG + // WebSocket constructor cannot set real headers, in the browser by + // spec and in Node because the client uses the same globalThis + // implementation. The server reads observed upgrade headers first and + // this parameter only for names they do not carry, so a transport + // that CAN send real headers needs no query at all. Loud caveat: the + // connect URL lands in proxy access logs — a device id belongs here, + // a secret does not. + const params = []; + // Capped like the http leg: past metaMaxBytes the server drops the + // ENTIRE bag (measured over the whole query), so sending it anyway + // would be a silent loss on the side that cannot see it. The refusal + // keeps the connection working, un-labelled, and says so. + const declare = (param, bag) => { + const value = encodeURIComponent(JSON.stringify(bag)); + const bytes = param.length + 1 + value.length; + if (bytes > META_MAX) return void this.log?.warn({ event: 'meta.oversize', param, bytes }); + params.push(`${param}=${value}`); + }; + if (bag) declare(HEADERS_PARAM, bag); + if (options.meta) declare(META_PARAM, options.meta); + const url = params.length > 0 ? `${this.url}${this.url.includes('?') ? '&' : '?'}${params.join('&')}` : this.url; + const socket = protocols.length > 0 ? new WebSocket(url, protocols) : new WebSocket(url); + this.#socket = socket; + const onClose = (error) => { + // Scoped to the socket it was registered for. Both 'close' and + // 'error' route here, and terminate() abandons a socket while it is + // still alive — its later close must not clear the #socket of the + // replacement a reconnect has already installed. + if (this.#socket !== socket) return; + this.#socket = null; + if (this.#opening) { + this.#opening = null; + this.emit('error', error); + return void reject(new Error('Connection closed')); + } + if (!this.active) return; + this.active = false; + this.emit('close', error); + }; + const onOpen = () => { + this.protocol = socket.protocol || ''; + this.active = true; + this.emit('open'); + this.#opening = null; + resolve(); + }; + socket.addEventListener('open', onOpen, { once: true }); + socket.addEventListener('close', onClose, { once: true }); + socket.addEventListener('error', onClose, { once: true }); + socket.addEventListener('message', ({ data }) => { + this.emit('message', data); + }); + }); + this.#opening = opening; + return opening; + } + + close() { + if (!this.active) return; + this.#socket.close(); + } + + // A peer that stopped answering will never complete a close handshake, so + // waiting for one would gate the reconnect on the socket's own timeout. + // Report the close now; the socket's later 'close' is then a no-op. + terminate() { + // A connect still in flight (the connectTimeout race): closing a + // CONNECTING socket aborts the handshake and fires 'close', which is + // where onClose rejects the pending open(). + if (this.#opening) return void this.#socket?.close(); + if (!this.active) return; + const socket = this.#socket; + this.active = false; + this.#socket = null; + this.emit('close'); + socket?.close(); + } + + write(data) { + if (!this.active) throw new Error('Not connected'); + this.#socket.send(data); + } +} + +class ClientHttpTransport extends ClientTransport { + // One request, one response: nothing to cancel or subscribe on. + persistent = false; + // Can carry procedure-mapped REST requests (client/core #restCall): a + // call whose procedure declares `http` goes out as the same REST request + // an external consumer would send, not as a packet POST. + rest = true; + // Consumes write()'s meta argument as request headers — the flag the + // batch flush checks before building its per-frame aggregate. + metaHeaders = true; + // Connection-phase headers and metadata, resolved per open — here they + // ride as REAL request headers on every packet POST and REST leg. + headers = null; + meta = null; + metaBag = null; + prefixed = false; + + async open(options = {}) { + this.headers = options.headers ?? null; + // Built once per open, as a header BLOCK rather than a single encoded + // value: which spelling it is (one canonical JSON header, or one header + // per key) is the client's metaFormat choice, and every leg below just + // spreads whatever came out. + this.prefixed = options.metaPrefixed === true; + // Both the raw bag and the prebuilt block: a call that brings its own + // meta has to merge with the BAG, not with the block. In json mode the + // two halves are the same header name, so layering blocks would replace + // the connection's meta rather than extend it. + this.metaBag = options.meta ?? null; + this.meta = this.metaBag ? metaHeaders(this.metaBag, this.prefixed) : null; + if (this.active) return; + this.active = true; + this.emit('open'); + } + + close() { + if (!this.active) return; + this.active = false; + this.emit('close'); + } + + // The REST leg: one mapped call, one plain-bodied response. The caller + // (client/core #restCall) interprets status and body; aborting `signal` + // aborts the fetch. `rest` is the codec.rest section when configured — + // it owns this leg's Content-Type and switches the read path to bytes. + // The PACKET codec's contentType belongs to write() below, never here: + // a JSON REST body must say JSON. + async request(method, url, body, signal, options = {}) { + const { rest = null, meta = null, trace = null } = options; + // Declared first, wire headers after: the protocol's own always win. + // A call's own meta merges over the connection's bag — per-call wins a + // key collision — and only then becomes headers. `trace` is the REST + // leg's traceparent/tracestate pair, injected by the telemetry writer. + const block = meta ? this.#requestMeta(meta) : this.meta; + const headers = { + ...this.headers, + ...block, + ...trace, + 'Content-Type': rest?.contentType ?? 'application/json', + }; + const init = body === undefined ? { method, headers, signal } : { method, headers, body, signal }; + const res = await fetch(url, init); + if (rest) return { status: res.status, body: new Uint8Array(await res.arrayBuffer()) }; + return { status: res.status, text: await res.text() }; + } + + // One request's meta block: the connection bag with this request's own + // layered over it. Capped here rather than left to the server, whose + // limit drops the WHOLE bag — a silent loss on the side that cannot fix + // it. Refusing here keeps the connection's own label intact and says so. + #requestMeta(meta) { + const merged = { ...this.metaBag, ...meta }; + const block = metaHeaders(merged, this.prefixed); + let bytes = 0; + // String() defensively: a non-string slipping through would make bytes + // NaN, and `NaN <= META_MAX` refuses the whole block with no real cause. + for (const key in block) bytes += key.length + String(block[key]).length; + if (bytes <= META_MAX) return block; + this.log.warn({ event: 'meta.oversize', bytes }); + return this.meta; + } + + // Malformed answers null either way — the codec's parse is the probe's. + #decode(text) { + if (!this.codec) return jsonParse(text); + try { + return this.codec.decode(text); + } catch { + return null; + } + } + + write(data, meta) { + // A batch's aggregated per-call meta (see client core flush) merges over + // the connection bag for THIS request only. It is a summary: one POST + // has one header block, so a key carried by several calls shows the last + // value. Nothing is lost — each call's exact meta rides its own packet. + const block = meta ? this.#requestMeta(meta) : this.meta; + const headers = { ...this.headers, ...block, 'Content-Type': this.codec?.contentType ?? 'application/json' }; + const options = { method: 'POST', headers, body: data }; + const send = async () => { + try { + const res = await fetch(this.url, options); + const text = await res.text(); + // Error statuses normally still carry wrpc callback packets (the + // server answers errors as JSON with the same code). Only when the + // body is NOT wrpc's — a proxy's HTML 502, an empty body — are + // answers synthesized, so the exact calls this request carried + // settle now instead of waiting out callTimeout. + if (res.ok || this.#decode(text) !== null) return void this.emit('message', text); + // The synthesized per-id answers live on the base class now — the + // SSE transport's outbound half fails the same way (see failPackets). + this.failPackets(data, res.status); + } catch (error) { + this.emit('error', error); + this.failPackets(data, 503); + } + }; + send(); + } +} + +class ClientEventTransport extends ClientTransport { + static instance = null; + + #port = null; + #worker = null; + + static getInstance(url) { + if (ClientEventTransport.instance) { + return ClientEventTransport.instance; + } + const transport = new ClientEventTransport(url); + ClientEventTransport.instance = transport; + return transport; + } + + async open(options = {}) { + if (this.active) return; + const worker = options.worker ?? this.#worker; + if (!worker) throw new Error('Service Worker not provided'); + this.#worker = worker; + const { port1, port2 } = new MessageChannel(); + this.#port = port1; + port1.addEventListener('message', ({ data }) => { + if (data === undefined) return; + this.emit('message', data); + }); + port1.start(); + // Declared headers travel in the connect message for the port's + // consumer. The built-in proxy (client/proxy.js) ignores them — its own + // WrpcClient carries its own options — but a custom consumer that + // attaches ports to an RpcServer can hand them to attachPort. + const connect = { type: 'wrpc:connect' }; + if (options.headers) connect.headers = options.headers; + if (options.meta) connect.meta = options.meta; + this.#worker.postMessage(connect, [port2]); + this.active = true; + this.emit('open'); + } + + close() { + this.active = false; + this.#port.close(); + this.#port = null; + this.emit('close'); + } + + online() { + if (this.#worker) this.#worker.postMessage({ type: 'wrpc:online' }); + } + + offline() { + if (this.#worker) this.#worker.postMessage({ type: 'wrpc:offline' }); + } + + write(data) { + if (!this.#port) throw new Error('Not connected'); + this.#port.postMessage(data); + } +} + +// Assigned INTO the class's null-proto registry, never replacing it: the +// sse subpath registers the same way, and require order stops mattering. +Object.assign(WrpcClient.transport, { + ws: ClientWsTransport, + http: ClientHttpTransport, + event: ClientEventTransport, +}); + +module.exports = { ClientWsTransport, ClientHttpTransport, ClientEventTransport }; diff --git a/src/engine/index.js b/src/engine/index.js new file mode 100644 index 0000000..7f21dec --- /dev/null +++ b/src/engine/index.js @@ -0,0 +1,31 @@ +'use strict'; + +const { createNodeEngine } = require('./node.js'); + +// Structural runtime check for user-provided engines (duck typing per the +// zero-dependency injection rule) — used by the Server shell to fail fast +// with a clear message instead of a deep TypeError. +// +// Two engine kinds satisfy the port: +// +// hosted (default, `standalone` falsy) — attaches to a node http(s) +// server's upgrade flow: attach({ server, ... }). The Server shell owns +// the listener and feeds HTTP requests into RpcServer.handleHttpCall. +// Such an engine MAY also support manual upgrades: when the returned +// source exposes handleUpgrade(req, socket, head), a middleware adapter +// can drive the handshake from its own 'upgrade' listener. +// +// standalone (`standalone: true`) — owns the whole network stack, node +// http included (uWebSockets.js). It is attached WITHOUT a server: +// attach({ path, verifyClient, onHttpCall, ... }), where onHttpCall +// receives the same abstract call description handleHttpCall consumes, +// and it must implement listen({ host, port }) -> Promise
. +const isEngine = (engine) => + typeof engine === 'object' && + engine !== null && + typeof engine.name === 'string' && + typeof engine.attach === 'function' && + typeof engine.close === 'function' && + (!engine.standalone || typeof engine.listen === 'function'); + +module.exports = { createNodeEngine, isEngine }; diff --git a/src/engine/node.js b/src/engine/node.js new file mode 100644 index 0000000..642cf8b --- /dev/null +++ b/src/engine/node.js @@ -0,0 +1,36 @@ +'use strict'; + +const { WebsocketServer } = require('../websocket/ws.js'); + +// Default engine: wraps the built-in RFC 6455 implementation. Its +// Connection objects satisfy the WrpcSocket contract natively (send -> +// boolean, bufferedAmount, close/terminate, pause/resume, 'message' / +// 'drain' / 'ping' / 'pong' / 'close'(code, reason) / 'error' events). +const createNodeEngine = (engineOptions = {}) => { + let wss = null; + return { + name: 'node', + // Hosted engine: the Server shell owns node:http(s) and the listener. + standalone: false, + capabilities: { + backpressure: true, + ping: true, + deflate: true, + cork: true, + pause: true, + }, + // attachOptions: { server, path, verifyClient, protocols, + // handleProtocols, perMessageDeflate, pingInterval, maxBuffer, + // maxPayload, maxBackpressure, fragmentThreshold, closeTimeout } + // Returns an EventEmitter with 'connection'(socket, req) events. + attach(attachOptions) { + wss = new WebsocketServer({ ...engineOptions, ...attachOptions }); + return wss; + }, + close(options) { + if (wss) wss.close(options); + }, + }; +}; + +module.exports = { createNodeEngine }; diff --git a/src/index.browser.js b/src/index.browser.js index a769be3..af793c2 100644 --- a/src/index.browser.js +++ b/src/index.browser.js @@ -1,15 +1,19 @@ 'use strict'; -const { Emitter } = require('./utils.js'); -const { WrpcClient, WrpcClientProxy, WrpcError } = require('./client.js'); +const { Emitter, createEventStream, EventStream, isCodec } = require('./utils.js'); +const { WrpcClient, WrpcClientProxy, WrpcError, connect } = require('./client.js'); const { WrpcReadable, WrpcWritable } = require('./streams.js'); const { chunkEncode, chunkDecode } = require('./chunks.browser.js'); module.exports = { + isCodec, Emitter, + createEventStream, + EventStream, WrpcClient, WrpcClientProxy, WrpcError, + connect, WrpcReadable, WrpcWritable, chunkEncode, diff --git a/src/index.js b/src/index.js index fc88292..57f1361 100644 --- a/src/index.js +++ b/src/index.js @@ -1,48 +1,53 @@ 'use strict'; -const { Emitter } = require('./utils.js'); -const { WrpcClient, WrpcClientProxy, WrpcError } = require('./client.js'); -const { Server, Client, Context, Session, createProxy } = require('./server.js'); +const { Emitter, createEventStream, EventStream, isCodec } = require('./utils.js'); +const { WrpcClient, WrpcClientProxy, WrpcError, connect } = require('./client.js'); +const { Server } = require('./server.js'); +const { RpcServer, Client, Context } = require('./rpc/core.js'); +const { defineRouter, procedure, Router, Procedure, effectiveSchema } = require('./rpc/router.js'); +const { RoomRegistry, Broadcast } = require('./rpc/rooms.js'); +const { Cluster } = require('./rpc/cluster.js'); +const { tracked, isTracked, createEventLog, EventLog } = require('./rpc/subscriptions.js'); +const { Session, MemorySessionStore, createProxy, isTokenTransport } = require('./rpc/sessions.js'); const { ServerTransport, buildHeaders } = require('./transport.js'); const { WrpcReadable, WrpcWritable } = require('./streams.js'); const { chunkEncode, chunkDecode } = require('./chunks.js'); -const { - OPCODES, - CLOSE_CODES, - CLOSE_TIMEOUT, - MAGIC, - WebsocketServer, - Connection, - Frame, - FrameParser, - ParseError, - PARSE_ERR_CODES, -} = require('./websocket/ws.js'); +// The WebSocket engine internals live in the '@alexify/wrpc/ws' subpath; +// the engine port contract in '@alexify/wrpc/engine'. module.exports = { Emitter, WrpcClient, WrpcClientProxy, WrpcError, + connect, Server, + RpcServer, Client, Context, Session, createProxy, + defineRouter, + procedure, + Router, + Procedure, + effectiveSchema, + RoomRegistry, + Broadcast, + Cluster, + tracked, + isTracked, + createEventLog, + EventLog, + createEventStream, + EventStream, + isCodec, + isTokenTransport, + MemorySessionStore, ServerTransport, buildHeaders, WrpcReadable, WrpcWritable, chunkEncode, chunkDecode, - OPCODES, - CLOSE_CODES, - CLOSE_TIMEOUT, - MAGIC, - WebsocketServer, - Connection, - Frame, - FrameParser, - ParseError, - PARSE_ERR_CODES, }; diff --git a/src/logging.js b/src/logging.js new file mode 100644 index 0000000..d5d20d5 --- /dev/null +++ b/src/logging.js @@ -0,0 +1,149 @@ +'use strict'; + +// A logger façade with no imports at all — that is what keeps this file +// browser-safe and what lets scripts/size.js bundle it without reaching for +// anything outside src/. It normalizes three shapes into one writer: +// +// - a pino-like structured logger, called as (mergingObject, message) +// - a Console-like object, called as (message) +// - nothing at all, in which case every method is a no-op +// +// The writer that comes back always has the same shape, so no call site in +// the codebase ever has to branch on whether logging is configured. + +// Marks a value as already being a writer. Every internal hand-off from a +// parent to a child component passes a writer where the public constructor +// documents a raw logger; the brand is what makes running it back through +// createLoggerWriter free instead of wrapping a wrapper. +const LOG_WRITER = Symbol.for('wrpc.logWriter'); + +const LEVELS = ['log', 'info', 'debug', 'warn', 'error']; + +// Where each level goes when the sink does not implement it. Console always +// has all five; a pino at a raised level still has all five; a partial +// object handed over by a test may have one. +const FALLBACKS = { + log: ['log', 'info', 'debug'], + info: ['info', 'log', 'debug'], + debug: ['debug', 'log', 'info'], + warn: ['warn', 'error', 'log'], + error: ['error', 'warn', 'log'], +}; + +const hasMethod = (value, name) => typeof value?.[name] === 'function'; + +// `child` (and pino's `level`) is what separates a structured logger, which +// takes (mergingObject, message), from a Console, which takes (message). +// Every structured logger worth injecting — pino, bunyan, winston, roarr — +// has one or both; Console has neither. Guessing "structured" wrong prints +// an object where a string belongs, so a sink that gives no signal is +// treated as a Console: the entry is dropped rather than shown raw. +const isStructured = (logger) => hasMethod(logger, 'child') || 'level' in logger; + +const isLoggerLike = (logger) => + LEVELS.some((level) => hasMethod(logger, level)) || hasMethod(logger, 'table') || hasMethod(logger, 'group'); + +// Resolves a level to the first method the sink actually has, bound once at +// construction rather than looked up per call. +const bindLevel = (sink, level) => { + for (const name of FALLBACKS[level]) { + if (hasMethod(sink, name)) return sink[name].bind(sink); + } + return null; +}; + +const noop = () => {}; + +const DISABLED = Object.freeze({ + [LOG_WRITER]: true, + enabled: false, + // Returning `this` is what makes the per-connection and per-call children + // free when logging is off: no allocation, no closure, no branch. + child() { + return this; + }, + log: noop, + info: noop, + debug: noop, + warn: noop, + error: noop, +}); + +// Console takes a message, not an entry. An Error with no message of its own +// is passed through as the argument so the host prints its stack, which is +// what the plain `console.error(error)` calls this replaced used to do. +const createConsoleWriter = (sink) => { + const writer = { + [LOG_WRITER]: true, + enabled: true, + child() { + return this; + }, + }; + for (const level of LEVELS) { + // A Console has no level filter of its own, so `debug` — the per-call + // firehose (one line per successful RPC) — is dropped here: a default + // console server should say what went wrong, not narrate every call. + // A structured logger keeps all five levels; its own `level` decides. + if (level === 'debug') { + writer.debug = noop; + continue; + } + const method = bindLevel(sink, level); + writer[level] = method + ? (entry, message) => { + // A logger that throws must never break the request path, so the + // guard lives here — once — instead of at every call site. + try { + method(message ?? entry?.err ?? entry); + } catch {} + } + : noop; + } + return writer; +}; + +const createStructuredWriter = (sink) => { + const writer = { + [LOG_WRITER]: true, + enabled: true, + child(bindings) { + if (!hasMethod(sink, 'child')) return this; + try { + return createStructuredWriter(sink.child(bindings)); + } catch { + return this; + } + }, + }; + for (const level of LEVELS) { + const method = bindLevel(sink, level); + writer[level] = method + ? (entry, message) => { + try { + method(entry, message); + } catch {} + } + : noop; + } + return writer; +}; + +/** + * Normalizes a `logger` option into a writer. + * + * `false`/`null`/`undefined` disable logging outright; `true` logs to the + * global console. An unrecognized object disables logging rather than + * throwing — an observability option must never be the thing that stops a + * server from booting. + */ +const createLoggerWriter = (logger) => { + if (logger?.[LOG_WRITER] === true) return logger; + if (!logger) return DISABLED; + if (logger === true) return createConsoleWriter(globalThis.console); + if (typeof logger !== 'object') return DISABLED; + if (!isLoggerLike(logger)) return DISABLED; + return isStructured(logger) ? createStructuredWriter(logger) : createConsoleWriter(logger); +}; + +module.exports = { createLoggerWriter, LOG_WRITER }; diff --git a/src/query/index.js b/src/query/index.js new file mode 100644 index 0000000..2a776ce --- /dev/null +++ b/src/query/index.js @@ -0,0 +1,220 @@ +'use strict'; + +// TanStack Query bindings, in the shape tRPC v11 settled on: not hooks, but +// OPTION FACTORIES. `wq.queryOptions(['chat', 'list'], args)` hands back the +// `{ queryKey, queryFn }` a user spreads into their own `useQuery`, so this +// file works with React Query, Solid Query, Svelte Query and query-core alike +// — and never imports any of them. +// +// Nothing is required here, by design. The wrpc client and the queryClient are +// INJECTED and checked structurally, which is the same rule the engine, the +// backplane and the framework adapters follow: zero runtime dependencies, and +// duck typing instead of instanceof. +// +// const { QueryClient, useQuery } = ... // the user's own +// const wq = createQueryUtils(client, { queryClient }); +// useQuery(wq.queryOptions(['chat', 'list'], { room: 'a' })); +// +// Paths are resolved LAZILY, inside queryFn/mutationFn rather than when the +// options are built. That is not an implementation detail: `client.api` only +// exists after `load()`, and it is rebuilt from scratch on every reconnect — +// options captured before either would otherwise point at a dead method. + +const isFunction = (value) => typeof value === 'function'; + +const requireClient = (client) => { + if (!client || typeof client.api !== 'object' || client.api === null) { + throw new TypeError('createQueryUtils(client): client must be a WrpcClient (an object with an `api`)'); + } +}; + +const requireQueryClient = (queryClient, where) => { + if (!queryClient || !isFunction(queryClient.setQueryData)) { + throw new TypeError(`${where}: pass a queryClient with a setQueryData() method (TanStack QueryClient)`); + } +}; + +// Default cache update: the newest value REPLACES what was there. Appending by +// default would look friendlier and quietly grow without bound for the life of +// the subscription, which is the wrong default for a feed that never ends. +const replace = (_previous, data) => data; + +const createQueryUtils = (client, options = {}) => { + requireClient(client); + const { prefix = [], queryClient: defaultQueryClient = null } = options; + if (!Array.isArray(prefix)) throw new TypeError('createQueryUtils: prefix must be an array'); + if (defaultQueryClient !== null) requireQueryClient(defaultQueryClient, 'createQueryUtils'); + + const checkPath = (path) => { + if (!Array.isArray(path) || path.length !== 2) { + throw new TypeError(`wrpc/query: a path is [unit, method], got ${JSON.stringify(path)}`); + } + const [unit, method] = path; + if (typeof unit !== 'string' || typeof method !== 'string') { + throw new TypeError(`wrpc/query: a path is [unit, method] of strings, got ${JSON.stringify(path)}`); + } + return [unit, method]; + }; + + // Own properties only, both hops. A unit object is an Emitter, so + // ['chat', 'on'] would otherwise resolve to Emitter.prototype.on and be + // called as if it were a procedure; and `api` is a plain object, so + // ['constructor', 'x'] would reach Object.prototype. + const member = (unit, method) => { + if (!Object.hasOwn(client.api, unit)) { + throw new Error(`wrpc/query: unit '${unit}' is not loaded — await client.load('${unit}') first`); + } + const loaded = client.api[unit]; + if (!Object.hasOwn(loaded, method)) { + throw new Error(`wrpc/query: unit '${unit}' has no method '${method}'`); + } + return loaded[method]; + }; + + const resolveCall = (path) => { + const [unit, method] = checkPath(path); + const found = member(unit, method); + if (found?.kind === 'subscription') { + throw new Error(`wrpc/query: ${unit}/${method} is a subscription — use subscriptionHandler()`); + } + if (!isFunction(found)) throw new Error(`wrpc/query: ${unit}/${method} is not callable`); + return found; + }; + + const resolveSubscription = (path) => { + const [unit, method] = checkPath(path); + const found = member(unit, method); + if (isFunction(found)) { + throw new Error(`wrpc/query: ${unit}/${method} is a call — use queryOptions() or mutationOptions()`); + } + if (found?.kind !== 'subscription') throw new Error(`wrpc/query: ${unit}/${method} is not a subscription`); + return found; + }; + + /** + * The cache key for a path. Prefix-shaped on purpose: TanStack matches keys + * by prefix, so `invalidateQueries({ queryKey: wq.queryKey(['chat']) })` + * — or just `['chat']` — reaches every query of the unit, and adding `args` + * last keeps two different arguments in two different cache entries. + */ + const queryKey = (path, args) => { + const base = Array.isArray(path) ? path : [path]; + return args === undefined ? [...prefix, ...base] : [...prefix, ...base, args]; + }; + + return Object.freeze({ + queryKey, + + /** + * `{ queryKey, queryFn }` for useQuery; `extra` is passed through. + * + * The path is checked here — a typo is a programmer error and should + * surface where it was written — but RESOLVED inside queryFn, which is + * what lets options outlive a `load()` or a reconnect. + */ + queryOptions(path, args, extra) { + checkPath(path); + return { + ...extra, + queryKey: queryKey(path, args), + // `async` so that a resolution failure (unit not loaded, method gone) + // REJECTS rather than throwing out of queryFn: a query's error channel + // is where a caller is looking, and not every consumer wraps the call. + // TanStack hands queryFn an AbortSignal and aborts it when the query is + // cancelled or the component unmounts; forwarding it is what makes that + // reach the server as `{type:'cancel'}`. + queryFn: async (context) => resolveCall(path)(args ?? {}, { signal: context?.signal }), + }; + }, + + /** + * `{ queryKey, queryFn, initialPageParam, getNextPageParam }` for + * useInfiniteQuery — the paging factory tRPC v11 ships and a chat/feed + * app reaches for first. The page cursor rides as an ordinary args + * field (`cursorKey`, default 'cursor'), merged over `args`, so the + * procedure sees `{ ...args, cursor }` exactly as a hand-written + * queryFn would send it. Everything else in `extra` + * (initialPageParam, getNextPageParam, maxPages, ...) passes through. + * Same lazy resolution and AbortSignal forwarding as queryOptions. + */ + infiniteQueryOptions(path, args, extra = {}) { + checkPath(path); + const { cursorKey = 'cursor', ...rest } = extra; + if (typeof cursorKey !== 'string' || cursorKey.length === 0) { + throw new TypeError('wrpc/query: infiniteQueryOptions cursorKey must be a non-empty string'); + } + return { + ...rest, + queryKey: queryKey(path, args), + queryFn: async (context) => + resolveCall(path)({ ...args, [cursorKey]: context?.pageParam }, { signal: context?.signal }), + }; + }, + + /** `{ mutationKey, mutationFn }` for useMutation; the variables ARE the args. */ + mutationOptions(path, extra) { + checkPath(path); + return { + ...extra, + mutationKey: queryKey(path), + mutationFn: async (args) => resolveCall(path)(args ?? {}), + }; + }, + + /** + * Bridges a subscription into the cache: every value is written through + * `queryClient.setQueryData`, so the same component that reads a query + * reads live data with no extra wiring. Returns the wrpc subscription + * handle — call `unsubscribe()` on teardown. + * + * A reconnect needs nothing from the caller for DELIVERY: the client + * re-opens its subscriptions from the last eventId it saw, on the SAME + * record, so this keeps receiving without re-subscribing and without + * leaking a second listener. It does not make resume free of duplicates + * though — an untracked feed (one that never yields `tracked()`) has no + * resume point, so it replays from the start, and an accumulating `update` + * appends the replay to what it already had. Label values with `tracked()` + * server-side, or make `update` idempotent. + */ + subscriptionHandler(path, args, handlerOptions = {}) { + // The path first: a path that is not a path is a more fundamental + // mistake than a missing queryClient, and it names the one in the message. + const [unit, method] = checkPath(path); + const { + queryClient = defaultQueryClient, + queryKey: key = queryKey(path, args), + update = replace, + onData, + onError, + onEnd, + lastEventId, + } = handlerOptions; + requireQueryClient(queryClient, `wrpc/query: subscriptionHandler(${unit}/${method})`); + if (!isFunction(update)) throw new TypeError('wrpc/query: subscriptionHandler update must be a function'); + const subscription = resolveSubscription(path); + return subscription.subscribe(args ?? {}, { + lastEventId, + onData: (data) => { + // Contained on purpose. This runs synchronously inside the client's + // packet dispatch, so a throw here (a buggy `update`, or a key + // TanStack cannot hash) would escape as a CLIENT-level 'error' and + // abandon the rest of the packet — a cache-write failure belongs to + // this subscription. With no onError it still propagates: a feed + // that stopped working must not do so quietly. + try { + queryClient.setQueryData(key, (previous) => update(previous, data)); + } catch (error) { + if (!onError) throw error; + onError(error); + return; + } + onData?.(data); + }, + onError, + onEnd, + }); + }, + }); +}; + +module.exports = { createQueryUtils }; diff --git a/src/rpc/client.js b/src/rpc/client.js new file mode 100644 index 0000000..c7f2ee6 --- /dev/null +++ b/src/rpc/client.js @@ -0,0 +1,487 @@ +'use strict'; + +// The per-connection halves of the engine-agnostic core: `Context` (one +// invocation's view) and `Client` (one peer). Split out of rpc/core.js — +// they reach the RpcServer only through the options bag #addClient builds, +// so the dependency is one-way: core requires this file, never the reverse. + +const http = require('node:http'); + +const { Emitter, jsonParse } = require('../utils.js'); +const { generateUUID } = require('../runtime/node.js'); +const { WrpcWritable } = require('../streams.js'); +const { RoomRegistry } = require('./rooms.js'); +const { DEFAULT_META_MAX } = require('./meta.js'); +const { createLoggerWriter } = require('../logging.js'); +const { createServerTelemetry } = require('../telemetry/server.js'); + +// One peer holding thousands of open generators is a denial of service the +// application never opted into; the cap is generous but present. +const DEFAULT_MAX_SUBSCRIPTIONS = 256; +// Same reasoning for in-flight calls: each holds a controller, a context and +// possibly a queue slot until it settles. +const DEFAULT_MAX_CALLS = 1000; +// How long client.ask() waits for the peer's answer. Mirrors the client's +// default call timeout: an ask is a call travelling the other way. +const DEFAULT_ASK_TIMEOUT = 7_000; + +// The empty halves of a ClientMeta. Null-prototyped: header names and +// declared metadata keys are peer-controlled, and a plain literal would +// answer meta.headers['toString'] with a real function. +const FROZEN_EMPTY = Object.freeze({ __proto__: null }); +const EMPTY_META = Object.freeze({ + data: FROZEN_EMPTY, + headers: FROZEN_EMPTY, + url: '', + remoteAddress: '', + protocol: '', +}); + +// The frozen snapshot of what the peer presented when the connection was +// made. `headers` is copied, not adopted: the source object belongs to the +// host request and other code may still be reading (or mutating) it. +const buildMeta = ({ headers, url, remoteAddress, protocol, data } = {}) => + Object.freeze({ + data: data ?? FROZEN_EMPTY, + headers: headers ? Object.freeze({ __proto__: null, ...headers }) : FROZEN_EMPTY, + url: url ?? '', + remoteAddress: remoteAddress ?? '', + protocol: protocol ?? '', + }); + +const refusal = (message) => { + const error = new Error(message); + error.code = 400; + error.expose = true; + return error; +}; + +class Context { + #log = null; + + constructor(client, signal = null, target = null) { + this.client = client; + this.uuid = client.generateId(); + this.state = {}; + // Aborted when the caller cancels, unsubscribes, or disconnects. A + // handler that awaits anything long-lived should pass it along; one + // that ignores it simply runs to completion and has its result dropped. + this.signal = signal; + // Call identity, so cross-cutting hooks need not re-derive it from the + // packet: the wire target ('unit.ver/name'; an inbound event's name + // verbatim) and the resolved Procedure handling it. + this.method = target?.method ?? null; + this.procedure = target?.procedure ?? null; + // The caller's per-invocation metadata (the packet's optional `meta` + // field, sanitized) — a frozen empty object rather than null, so a + // cross-cutting hook reads context.callMeta.idem without `?.`. + this.callMeta = target?.callMeta ?? FROZEN_EMPTY; + } + + get session() { + return this.client.session; + } + + // Bound lazily: a Context is allocated for every call, subscribe and + // inbound event, and most handlers never log. `uuid` is already there, so + // the correlation id exists whether or not anyone asks for the child. + get log() { + return (this.#log ??= this.client.log.child({ callId: this.uuid })); + } + + // Rooms are reached from a handler through here — `ctx.server.to(room)` — + // rather than by closing over a server the router had to exist before. + get server() { + return this.client.server; + } + + // The connection's presented metadata, mirrored the way `session` is: a + // getter, so the per-call Context allocates nothing for it. + get meta() { + return this.client.meta; + } +} + +class Client extends Emitter { + #transport = null; + #sessions = null; + #rooms = null; + #server = null; + #log = null; + #otel = null; + #codec = null; + // id -> { resolve, reject, timer }: answers this client owes to asks the + // server sent it. See ask()/expectAnswer()/settleAnswer(). + #asks = new Map(); + #ready = null; + + constructor(transport, options = {}) { + super(); + const { + sessions, + rooms, + server, + log, + otel, + maxSubscriptions = DEFAULT_MAX_SUBSCRIPTIONS, + maxCalls = DEFAULT_MAX_CALLS, + generateId, + codec = null, + meta = null, + metaMax = DEFAULT_META_MAX, + } = options; + // The dispatcher's cap on the per-packet `meta` field (sanitizeMeta). + this.metaMax = metaMax; + // What the peer presented when the connection was made (headers, url, + // negotiated subprotocol) — frozen, EMPTY_META when nothing was. The + // peer-controlled parts are labels, never authorization inputs. + this.meta = meta ?? EMPTY_META; + this.#transport = transport; + this.#sessions = sessions; + this.#codec = codec; + // A Client built outside an RpcServer still has working rooms; it just + // has nobody to share them with. + this.#rooms = rooms ?? new RoomRegistry(); + this.#server = server ?? null; + // Connection-scoped, bound once: a connection lives for minutes, and the + // binding hoists the peer id out of every line logged for it. + this.#log = createLoggerWriter(log ?? globalThis.console).child({ peer: transport.source }); + this.#otel = otel ?? createServerTelemetry(null); + this.source = transport.source; + this.session = null; + this.sessionReady = Promise.resolve(); + this.streams = new Map(); + // id -> AbortController, for the two things a peer can take back. + this.calls = new Map(); + this.subscriptions = new Map(); + this.maxSubscriptions = maxSubscriptions; + this.maxCalls = maxCalls; + // Context uuids and server-side stream ids; uuid v4 unless the app + // brings its own (cuid/ulid/a test counter) — see RpcServerOptions. + this.generateId = typeof generateId === 'function' ? generateId : generateUUID; + // Instance-prefixed, so the id IS the address: a cluster command for + // this client goes straight to this instance's channel, no broadcast. + // A standalone Client (no server) has no instance and no prefix. + this.id = this.#server ? `${this.#server.instanceId}.${this.generateId()}` : this.generateId(); + // The application's bag, carried by cluster descriptors — what + // socket.data is in socket.io. wrpc itself never reads it. + this.data = {}; + } + + error(code, { id = '', error = null } = {}) { + const httpCode = code <= 599 ? code : 500; + const status = http.STATUS_CODES[httpCode]; + const info = error ? error.stack : status || 'Unknown error'; + this.#transport.error(code, { id, error }); + this.#log.error({ event: 'rpc.error', code, id, err: error }, `${this.source}\t${code}\t${info}`); + } + + // What dispatch actually gates on: the session restore PLUS the settled + // onConnect hooks (#addClient assigns the combined promise). Two promises + // on purpose — a hook may `await client.sessionReady`, so folding the + // hooks into that same promise would make such a hook wait for itself. + // Follows `sessionReady` until #addClient diverges them, so a standalone + // Client that assigns sessionReady by hand keeps the gate it expects. + get ready() { + return this.#ready ?? this.sessionReady; + } + + set ready(value) { + this.#ready = value; + } + + /** The connection-scoped writer, reached by the dispatcher and handlers. */ + get log() { + return this.#log; + } + + /** The server's telemetry writer; disabled-shaped when unconfigured. */ + get otel() { + return this.#otel; + } + + /** 'ws' | 'http' | 'sse' | 'event' — a metric attribute and a log field. */ + get transportKind() { + return this.#transport.kind; + } + + // Diagnostics for packets that carry no id to answer on (inbound events): + // the log is the only channel left. + warn(message, entry = {}) { + this.#log.warn({ event: 'rpc.warn', ...entry }, `${this.source}\t${message}`); + } + + /** Returns false when the transport is above its high-water mark. */ + send(obj, options = {}) { + const { code, method, text } = options; + const flushed = this.#transport.send(obj, code, text); + // Debug on purpose: one line per successful call is a firehose. A + // console logger drops debug outright; a structured logger's own level + // decides. Failures still log at error, unconditionally. + const isSuccessCallback = obj.type === 'callback' && !obj.error; + if (isSuccessCallback) { + this.#log.debug({ event: 'call.ok', method, id: obj.id }, `${this.source}\tCALL\t${method}\tOK`); + } + return flushed; + } + + /** + * Resolves when the transport has drained — or when it closes, so a + * producer waiting on a peer that never reads is released by the + * disconnect rather than parked forever. + */ + drain() { + return new Promise((resolve) => { + const done = () => { + this.#transport.off('drain', done); + this.#transport.off('close', done); + resolve(); + }; + this.#transport.on('drain', done); + this.#transport.on('close', done); + }); + } + + // True for transports that stay open (WebSocket, worker port): only + // those can carry events and streams. + get persistent() { + return Boolean(this.#transport.connection); + } + + /** The RpcServer this client belongs to; null for a standalone Client. */ + get server() { + return this.#server; + } + + /** False on a text-only transport (SSE), where binary streams cannot go. */ + get binary() { + return this.#transport.binary !== false; + } + + createContext(signal = null, target = null) { + return new Context(this, signal, target); + } + + // The inbound half of the wire codec: what handleMessage parses frames + // with. Malformed input answers null, same contract as jsonParse. + decodePacket(text) { + if (!this.#codec) return jsonParse(text); + try { + return this.#codec.decode(typeof text === 'string' ? text : String(text)); + } catch { + return null; + } + } + + // NOTE: `emit` is the inherited LOCAL Emitter emit — `client.on('x', fn)` + // works, and Client is substitutable for the Emitter it extends. The wire + // send has its own name and always had it: `sendEvent`. + + sendEvent(name, data) { + const packet = { type: 'event', name, data }; + if (!this.#transport.connection) { + throw refusal(`Can't send wrpc event to http transport`); + } + this.send(packet); + } + + /** + * Writes an ALREADY-serialized packet. The fan-out seam: a broadcast to N + * clients stringifies once and hands every recipient the same text, + * instead of paying JSON.stringify per client. Returns the transport's + * backpressure signal, like send(). + */ + sendRaw(text) { + if (!this.#transport.connection) { + throw refusal(`Can't send wrpc event to http transport`); + } + return this.#transport.write(text); + } + + /** + * A call in the other direction: sends `{type:'event', name, data, id}` + * and resolves with the answer the peer's responder returns (registered + * client-side with `client.respond(name, fn)`). Rejects with 408 on + * timeout, 503 when the connection drops first. + */ + ask(name, data, options = {}) { + if (!this.#transport.connection) { + throw refusal(`Can't send wrpc event to http transport`); + } + const id = this.generateId(); + const packet = { type: 'event', name, data, id }; + this.send(packet); + return this.expectAnswer(id, options.timeout); + } + + /** + * Registers a pending answer slot for `id`. The seam Broadcast.ask() + * uses: the broadcast writes its own pre-serialized packet through + * sendRaw and only needs the bookkeeping half of ask(). + */ + expectAnswer(id, timeout) { + return new Promise((resolve, reject) => { + const wait = timeout > 0 ? timeout : DEFAULT_ASK_TIMEOUT; + const timer = setTimeout(() => { + this.#asks.delete(id); + const error = new Error('Ask timeout'); + error.code = 408; + reject(error); + }, wait); + if (typeof timer.unref === 'function') timer.unref(); + this.#asks.set(id, { resolve, reject, timer }); + }); + } + + /** Routes an inbound `callback` to its pending ask; false when none. */ + settleAnswer(packet) { + const pending = this.#asks.get(packet.id); + if (!pending) return false; + this.#asks.delete(packet.id); + clearTimeout(pending.timer); + if (packet.error) { + const error = new Error(packet.error.message ?? 'Ask failed'); + error.code = packet.error.code ?? 500; + if (packet.error.details !== undefined) error.details = packet.error.details; + pending.reject(error); + } else { + pending.resolve(packet.result); + } + return true; + } + + // --------------------------------------------------------------------- + // Rooms. The registry owns both directions, so this client keeps no room + // state of its own that a missed leave could leave stale. + + join(room) { + return this.#rooms.join(this, room); + } + + leave(room) { + return this.#rooms.leave(this, room); + } + + /** The rooms this client is in — a copy, safe to iterate while leaving. */ + get rooms() { + return new Set(this.#rooms.roomsOf(this)); + } + + in(room) { + return this.#rooms.members(room).has(this); + } + + getStream(id) { + if (!this.#transport.connection) { + throw refusal(`Can't receive stream from http transport`); + } + if (!this.binary) throw refusal(`Can't receive stream over a text-only transport`); + const stream = this.streams.get(id); + if (stream) return stream; + throw new Error(`Stream ${id} is not initialized`); + } + + createStream(name, size) { + if (!this.#transport.connection) { + throw refusal(`Can't send wrpc streams to http transport`); + } + if (!this.binary) throw refusal(`Can't send wrpc streams over a text-only transport`); + if (!name) throw new Error('Stream name is not provided'); + if (!size) throw new Error('Stream size is not provided'); + const id = this.generateId(); + if (typeof id !== 'string' || id.length === 0 || id.length > 255) { + throw new TypeError('createStream: generateId must return a string of at most 255 characters'); + } + const stream = new WrpcWritable(id, name, size, this.#transport); + this.streams.set(id, stream); + return stream; + } + + initializeSession(token, data = {}) { + // Re-initializing the SAME token must not finalize first: with an async + // store the fire-and-forget delete(token) could land after the new + // set(token) and silently wipe the fresh session + if (this.session && this.session.token !== token) void this.finalizeSession(); + this.session = this.#sessions.create(token, data); + return true; + } + + async finalizeSession() { + if (!this.session) return false; + const { token } = this.session; + this.session = null; + await this.#sessions.destroy(token); + return true; + } + + startSession(token, data = {}) { + this.initializeSession(token, data); + if (!this.#transport.connection) { + // The carrier stamps the response when it can (a cookie); a bearer + // carrier answers null and the handler returns tokens in its result. + const header = this.#sessions.transport.write(this.session.token); + if (header) this.#transport.sendSessionCookie(header); + } + return true; + } + + async restoreSession(token) { + const session = await this.#sessions.restore(token); + if (!session) return false; + this.session = session; + return true; + } + + close() { + this.#transport.close(); + } + + // A dropped connection does NOT delete the session from the store — + // that is what makes restoreSession after a reconnect possible. + // Sessions end via finalizeSession or store-side expiry only. + destroy() { + const log = this.#log; + this.#rooms.leaveAll(this); + // A gone peer cannot receive an answer, so everything still running on + // its behalf is told to stop — this is what runs a subscription + // handler's `finally`, releasing whatever it had open. + const disconnected = new Error('Client disconnected'); + for (const controller of this.calls.values()) controller.abort(disconnected); + for (const controller of this.subscriptions.values()) controller.abort(disconnected); + this.calls.clear(); + this.subscriptions.clear(); + // An answer can no longer arrive: whoever asked is settled NOW instead + // of waiting out the ask timeout on a peer that is gone. + if (this.#asks.size > 0) { + const gone = new Error('Client disconnected'); + gone.code = 503; + for (const pending of this.#asks.values()) { + clearTimeout(pending.timer); + pending.reject(gone); + } + this.#asks.clear(); + } + // Contained: destroy() runs from the transport's close handler, where a + // throwing app listener would otherwise be an unhandled rejection. + Promise.resolve(this.emit('close')).catch((error) => { + log.error({ err: error, event: 'listener.close' }); + }); + for (const stream of this.streams.values()) { + if (typeof stream.terminate !== 'function') continue; + Promise.resolve(stream.terminate()).catch((error) => { + log.error({ err: error, event: 'stream.terminate', stream: stream.id }); + }); + } + this.streams.clear(); + } +} + +module.exports = { + Context, + Client, + DEFAULT_MAX_SUBSCRIPTIONS, + DEFAULT_MAX_CALLS, + FROZEN_EMPTY, + EMPTY_META, + buildMeta, +}; diff --git a/src/rpc/cluster.js b/src/rpc/cluster.js new file mode 100644 index 0000000..50fadea --- /dev/null +++ b/src/rpc/cluster.js @@ -0,0 +1,867 @@ +'use strict'; + +const crypto = require('node:crypto'); + +const { Emitter, jsonParse } = require('../utils.js'); +const { generateUUID } = require('../runtime/node.js'); +const { createLoggerWriter } = require('../logging.js'); +const { DISABLED, SPAN_KIND_CONSUMER } = require('../telemetry/shared.js'); + +// Cluster: presence, introspection and node-to-node messaging across every +// wrpc instance sharing a backplane. Built ON TOP of the pub/sub contract +// (publish/subscribe/close) rather than into it, so every adapter — memory, +// redis, anything isBackplane-shaped — gets cluster operations without +// implementing correlation, timeouts or aggregation itself. The same wire +// mechanics socket.io's cluster adapter arrives at (a shared channel plus a +// per-node response channel), expressed over the contract instead of a class +// hierarchy. +// +// Two channels, both held for the life of the process: +// +// cluster — every node; presence, wide requests and commands +// inst: — one node; its answers and addressed commands +// +// Presence is REPLICATED, not requested: join/leave publishes a ±1 delta, +// a periodic snapshot corrects whatever an at-most-once broker dropped, and +// count()/presence() read a local map with no network at all. Liveness +// counts every message a node sends — the snapshot only fills silence — and +// a graceful close() says goodbye so eviction is immediate. +// +// Requests (fetchClients, ask) know their respondent set from presence and +// complete the moment the last live node answers; the timeout is a backstop +// that resolves with `incomplete: true`, never silently. + +const ENVELOPE_VERSION = 1; +const CLUSTER_CHANNEL = 'cluster'; +const INSTANCE_CHANNEL_PREFIX = 'inst:'; + +const DEFAULT_PRESENCE_INTERVAL = 5_000; +const DEFAULT_REQUEST_TIMEOUT = 2_000; +// The per-node ceiling on one fetchClients reply: each descriptor embeds +// client.data, and an unbounded reply is ONE pub/sub message — big enough +// fan-ins used to be able to kill the requester's broker connection +// (redis's client-output-buffer-limit) and take every room channel with it. +const DEFAULT_MAX_FETCH = 1_000; + +// The rooms an app opts into replicating: an array, a predicate or a +// RegExp. Null replicates everything — fine for topic rooms, expensive for +// the per-user `user:` pattern, where presence pays O(nodes x rooms) +// heap for rooms nobody ever queries with presence()/count(). +const normalizeRoomsFilter = (rooms) => { + if (typeof rooms === 'function') return rooms; + if (Array.isArray(rooms)) { + const set = new Set(rooms); + return (room) => set.has(room); + } + if (rooms instanceof RegExp) return (room) => rooms.test(room); + return null; +}; + +// Order-independent 32-bit digest of a room->count presence table: FNV-1a +// per entry, summed mod 2^32. Receivers hold the same entries in arrival +// order, so an order-sensitive digest would false-mismatch forever. +const entryHash = (room, count) => { + let h = 0x811c9dc5; + for (let i = 0; i < room.length; i++) { + h ^= room.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + h ^= count; + return Math.imul(h, 0x01000193) >>> 0; +}; + +const instanceChannel = (instance) => INSTANCE_CHANNEL_PREFIX + instance; + +// The instance a prefixed client id belongs to: everything before the first +// dot. A standalone Client's id has no dot and no instance — not addressable. +const instanceOfClientId = (id) => { + if (typeof id !== 'string') return null; + const dot = id.indexOf('.'); + return dot > 0 ? id.slice(0, dot) : null; +}; + +class Cluster extends Emitter { + #backplane; + #otel; + #instance; + #epoch; + #log; + #local; + #presenceInterval; + #presenceTimeout; + #requestTimeout; + #maxFetch; + #roomsFilter; + #secret; + #generateId; + // instance -> { epoch, lastSeen, clients, rooms: Map } + #nodes = new Map(); + // requestId -> { missing: Set, answers, errors, expected, timer, resolve } + #requests = new Map(); + #responders = new Map(); + #unsubscribes = []; + #timer = null; + #closed = false; + // Channels whose subscribe failed and is being retried (see #subscribeTo). + #pendingSubs = 0; + + /** + * `local` is the seam to the owning RpcServer: how remote requests and + * commands reach this node's clients without Cluster knowing the server. + * count(room), snapshot() -> { clients, rooms } + * descriptors(sel) -> Array + * join(sel, rooms) / leave(sel, rooms) / disconnect(sel) + * ask(rooms, name, data, timeout, onCount) -> Promise<{answers, errors}> + */ + constructor({ + backplane = null, + instance, + local, + log = globalThis.console, + otel = null, + generateId = null, + options = {}, + }) { + super(); + this.#backplane = backplane; + this.#otel = otel ?? DISABLED; + this.#instance = instance; + // The boot marker. instanceId may be STABLE across restarts ('node-1'); + // the epoch never is, which is how a receiver tells "restarted, replace + // its counters" from "same process, merge". + this.#epoch = generateUUID(); + this.#local = local; + this.#log = createLoggerWriter(log); + this.#generateId = typeof generateId === 'function' ? generateId : generateUUID; + const interval = options.presenceInterval > 0 ? options.presenceInterval : DEFAULT_PRESENCE_INTERVAL; + this.#presenceInterval = interval; + this.#presenceTimeout = options.presenceTimeout > 0 ? options.presenceTimeout : interval * 3; + this.#requestTimeout = options.requestTimeout > 0 ? options.requestTimeout : DEFAULT_REQUEST_TIMEOUT; + this.#maxFetch = options.maxFetch === 0 ? 0 : options.maxFetch > 0 ? options.maxFetch : DEFAULT_MAX_FETCH; + this.#roomsFilter = normalizeRoomsFilter(options.rooms); + // Opt-in envelope authentication (`cluster: { secret }`). The backplane + // is a TRUST PEER of every node: without a secret, anything that can + // publish on the `cluster` channel can disconnect every client or join + // anyone to any room. The HMAC turns "can publish" into "can publish + // AND holds the shared secret". Node-only by construction (node:crypto) + // — this file never ships to a browser. + this.#secret = typeof options.secret === 'string' && options.secret.length > 0 ? options.secret : null; + } + + get instanceId() { + return this.#instance; + } + + get epoch() { + return this.#epoch; + } + + /** Without a backplane every operation degrades to its local half. */ + get connected() { + return this.#backplane !== null; + } + + /** + * False while a channel subscribe is failing (and being retried): the + * node is half-connected — it can publish but cannot hear — which is + * exactly what a readiness probe should drain it on. 'degraded' and + * 'recovered' fire on the transitions. + */ + get healthy() { + return this.#pendingSubs === 0; + } + + // ----------------------------------------------------------------------- + // Lifecycle + + start() { + if (!this.#backplane || this.#closed) return; + const ready = [this.#subscribeTo(CLUSTER_CHANNEL), this.#subscribeTo(instanceChannel(this.#instance))]; + // The newcomer announce goes out only once this node's own inbox is + // live: existing nodes answer the hello with ADDRESSED state, and an + // answer racing our SUBSCRIBE would be lost — the newcomer would stay + // cold until the first periodic snapshot instead of warming instantly. + Promise.all(ready).then(() => { + if (this.#closed) return; + this.#post(CLUSTER_CHANNEL, { t: 'hello', ...this.#snapshot() }); + }); + const timer = setInterval(() => this.#tick(), this.#presenceInterval); + if (typeof timer.unref === 'function') timer.unref(); + this.#timer = timer; + } + + close() { + if (this.#closed) return; + this.#closed = true; + if (this.#timer) clearInterval(this.#timer); + this.#timer = null; + // Goodbye first, while the publish path still works: receivers evict + // immediately instead of waiting out presenceTimeout. + if (this.#backplane) this.#post(CLUSTER_CHANNEL, { t: 'bye' }); + for (const off of this.#unsubscribes) this.#safeOff(off); + this.#unsubscribes.length = 0; + // Whatever was waiting on the cluster resolves with what it has. + for (const [requestId, request] of Array.from(this.#requests)) { + this.#requests.delete(requestId); + clearTimeout(request.timer); + request.settle(true); + } + this.#nodes.clear(); + } + + // Subscribes with capped-backoff RETRY on rejection: a rejected subscribe + // used to be terminal and silent — the node kept publishing but could + // never hear again, a half-connected state nothing surfaced. + #subscribeTo(channel, attempt = 0) { + const handler = (message) => this.#receive(message); + return Promise.resolve() + .then(() => this.#backplane.subscribe(channel, handler)) + .then( + (off) => { + if (attempt > 0) { + this.#pendingSubs--; + this.#log.warn({ event: 'cluster.recovered', channel, attempt }); + if (this.#pendingSubs === 0) { + void Promise.resolve(this.emit('recovered', { channel })).catch((error) => + this.#log.error({ err: error, event: 'cluster.listener', name: 'recovered' }), + ); + } + } + if (typeof off !== 'function') return; + if (this.#closed) return void this.#safeOff(off); + this.#unsubscribes.push(off); + }, + (error) => { + this.#log.error({ err: error, event: 'cluster.subscribe', channel, attempt }); + if (this.#closed) return; + if (attempt === 0) { + this.#pendingSubs++; + void Promise.resolve(this.emit('degraded', { channel, error })).catch((e) => + this.#log.error({ err: e, event: 'cluster.listener', name: 'degraded' }), + ); + } + const delay = Math.min(30_000, 500 * 2 ** attempt); + const timer = setTimeout(() => { + if (!this.#closed) this.#subscribeTo(channel, attempt + 1); + }, delay); + if (typeof timer.unref === 'function') timer.unref(); + }, + ); + } + + // An adapter's unsubscribe may throw or reject; either way it is the + // adapter's problem to report, never this layer's crash. + #safeOff(off) { + try { + const result = off(); + if (result && typeof result.catch === 'function') { + result.catch((error) => this.#log.error({ err: error, event: 'cluster.unsubscribe' })); + } + } catch (error) { + this.#log.error({ err: error, event: 'cluster.unsubscribe' }); + } + } + + // The snapshot this node REPLICATES: the local one, through the rooms + // filter when the app configured one. + #snapshot() { + const snapshot = this.#local.snapshot(); + if (!this.#roomsFilter) return snapshot; + const rooms = {}; + for (const room in snapshot.rooms) { + if (this.#roomsFilter(room)) rooms[room] = snapshot.rooms[room]; + } + return { rooms, clients: snapshot.clients }; + } + + // One periodic tick does both presence jobs: publish the corrective + // signal (an at-most-once broker WILL drop a delta eventually) and sweep + // for nodes that went silent. The corrective signal is a DIGEST — a hash + // over the room table, not the table itself: a full snapshot every tick + // cost O(nodes^2 x rooms) backplane bytes at steady state, all of it + // usually confirming nothing changed. A receiver whose view hashes + // differently asks THAT node for a full state with an addressed 'sync' + // (see #receive), so the table travels only when it is actually wrong. + #tick() { + const { rooms, clients } = this.#snapshot(); + let hash = 0; + let count = 0; + for (const room in rooms) { + hash = (hash + entryHash(room, rooms[room])) >>> 0; + count++; + } + this.#post(CLUSTER_CHANNEL, { t: 'digest', clients, n: count, h: hash }); + const deadline = Date.now() - this.#presenceTimeout; + for (const [instance, node] of this.#nodes) { + if (node.lastSeen < deadline) this.#evict(instance, 'timeout'); + } + } + + // True when the message left synchronously intact; false on a serialize + // failure or a synchronous publish throw. Fire-and-forget callers ignore + // it; #request settles immediately on false instead of waiting out a + // timeout for a question that never went anywhere. (An async publish + // rejection still only logs — by then at-most-once already owns it.) + #post(channel, body) { + const envelope = { v: ENVELOPE_VERSION, from: this.#instance, epoch: this.#epoch, ...body }; + // The active trace context rides the envelope as tp/ts (ignored by + // receivers that predate it — the additive-fields rule): the cross-node + // hop is where a trace is most valuable and used to be exactly where + // context was dropped. + this.#otel.inject(envelope); + let message = null; + try { + message = JSON.stringify(envelope); + if (this.#secret) { + // Signed over the serialized body, sig appended LAST: the receiver + // deletes `sig` from the parsed object and re-serializes — key + // order survives a JSON round trip, so the bytes match. + envelope.sig = crypto.createHmac('sha256', this.#secret).update(message).digest('hex'); + message = JSON.stringify(envelope); + } + } catch (error) { + this.#log.error({ err: error, event: 'cluster.serialize', type: body.t }); + return false; + } + try { + const result = this.#backplane.publish(channel, message); + if (result && typeof result.catch === 'function') { + result.catch((error) => this.#log.error({ err: error, event: 'cluster.publish', channel })); + } + } catch (error) { + // A broken backplane must never break the caller's own request path. + this.#log.error({ err: error, event: 'cluster.publish', channel }); + return false; + } + return true; + } + + // ----------------------------------------------------------------------- + // Presence: replicated counters, local reads + + /** Fired by the registry on every successful local join/leave. */ + delta(room, d) { + if (!this.#backplane || this.#closed) return; + if (this.#roomsFilter && !this.#roomsFilter(room)) return; + this.#post(CLUSTER_CHANNEL, { t: 'delta', room, d }); + } + + /** Cluster-wide membership of `room`: a local sum, no network. */ + count(room) { + let total = this.#local.count(room); + for (const node of this.#nodes.values()) total += node.rooms.get(room) ?? 0; + return total; + } + + /** Per-instance breakdown of `room`; zero-count instances are omitted. */ + presence(room) { + const instances = {}; + let total = 0; + const local = this.#local.count(room); + if (local > 0) { + instances[this.#instance] = local; + total += local; + } + for (const [instance, node] of this.#nodes) { + const count = node.rooms.get(room) ?? 0; + if (count === 0) continue; + instances[instance] = count; + total += count; + } + return { total, instances }; + } + + /** Ids of the live instances, this one first. */ + instances() { + return [this.#instance, ...this.#nodes.keys()]; + } + + // Envelope authentication, the receiving half: a message without a valid + // signature is dropped and logged. Constant-time compare — the signature + // is the credential here. + #verify(envelope, from) { + const sig = envelope.sig; + if (typeof sig !== 'string' || sig.length === 0) { + this.#log.warn({ event: 'cluster.unsigned', from }); + return false; + } + delete envelope.sig; + let expected = null; + try { + expected = crypto.createHmac('sha256', this.#secret).update(JSON.stringify(envelope)).digest('hex'); + } catch { + return false; + } + const a = Buffer.from(sig); + const b = Buffer.from(expected); + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { + this.#log.warn({ event: 'cluster.badsig', from }); + return false; + } + return true; + } + + #seen(from, epoch) { + let node = this.#nodes.get(from); + if (node && node.epoch !== epoch) { + // A restart: REPLACE the counters, never merge — the old process' + // rooms are gone with it, only its name survived. And like any other + // death proof, open requests stop waiting for the OLD process now: + // it missed their question, and the reborn one never will answer it. + this.#nodes.delete(from); + node = null; + this.#stopWaitingFor(from); + } + if (!node) { + node = { epoch, lastSeen: 0, clients: 0, rooms: new Map() }; + this.#nodes.set(from, node); + // Symmetric with eviction: membership changes are the cluster events + // an operator reasons about, and joins used to be silent. + this.#log.info({ event: 'cluster.join', instance: from }); + this.#otel.recordClusterInstances(1); + } + node.lastSeen = Date.now(); + return node; + } + + #applySnapshot(node, envelope) { + const { rooms, clients } = envelope; + node.clients = typeof clients === 'number' ? clients : 0; + node.rooms = new Map(); + if (rooms && typeof rooms === 'object') { + // for...in, not Object.entries: this runs per presence snapshot per peer, + // and entries allocated an array plus a pair object for every room. + for (const room in rooms) { + const count = rooms[room]; + if (typeof count === 'number' && count > 0) node.rooms.set(room, count); + } + } + } + + #evict(instance, reason) { + const node = this.#nodes.get(instance); + if (!node) return; + this.#nodes.delete(instance); + // A timeout eviction is a node that went SILENT — likely partitioned or + // dead without a goodbye — which deserves warn; a bye is routine. + if (reason === 'timeout') this.#log.warn({ event: 'cluster.evict', instance, reason }); + else this.#log.debug({ event: 'cluster.evict', instance, reason }); + this.#otel.recordClusterInstances(-1); + this.#stopWaitingFor(instance); + } + + // A dead node's answers are never coming: every open request stops + // waiting for it right now, not at its timeout. `incomplete` stays + // honest: a node that promised answers (bask's count phase) but never + // delivered them makes the settled result incomplete even though nobody + // is left to wait for. + #stopWaitingFor(instance) { + for (const [requestId, request] of Array.from(this.#requests)) { + if (!request.missing.delete(instance)) continue; + if (request.missing.size > 0) continue; + this.#requests.delete(requestId); + clearTimeout(request.timer); + request.settle(this.#hasUndelivered(request)); + } + } + + #hasUndelivered(request) { + for (const instance of request.counted) { + if (!request.finals.has(instance)) return true; + } + return false; + } + + // ----------------------------------------------------------------------- + // Node-to-node messaging (the serverSideEmit pair) + + // NOTE: as everywhere in wrpc, `emit` is the LOCAL Emitter emit — it is + // what delivers a remote node's message to `cluster.on(name, fn)` + // listeners here. The wire send has its own name, same as Client's. + + /** Fire-and-forget to every OTHER node's `cluster.on(name, ...)`. */ + sendEvent(name, data) { + if (typeof name !== 'string' || name.length === 0) { + throw new TypeError('Event name must be a non-empty string'); + } + if (!this.#backplane || this.#closed) return; + this.#post(CLUSTER_CHANNEL, { t: 'e', name, data }); + } + + /** + * Asks every other node and collects their answers. Each node answers + * through its registered responder — `cluster.respond(name, fn)` — or + * contributes an error when it has none. Resolves as soon as every live + * node answered; the timeout resolves `incomplete: true`. + */ + ask(name, data, options = {}) { + if (typeof name !== 'string' || name.length === 0) { + throw new TypeError('Event name must be a non-empty string'); + } + return this.#request('emit', { name, data }, options.timeout ?? this.#requestTimeout).then( + ({ payloads, incomplete }) => { + const answers = []; + const errors = []; + for (const payload of payloads) { + if (payload && typeof payload === 'object' && 'error' in payload) errors.push(payload.error); + else answers.push(payload?.value); + } + return { answers, errors, incomplete }; + }, + ); + } + + /** + * Registers this node's answer to `cluster.ask(name)` from other nodes. + * One responder per name: two answers to one question are ambiguous. + */ + respond(name, handler) { + if (typeof handler !== 'function') { + throw new TypeError('Cluster.respond: handler must be a function'); + } + if (this.#responders.has(name)) { + throw new Error(`Duplicate responder for '${name}'`); + } + this.#responders.set(name, handler); + } + + unrespond(name) { + return this.#responders.delete(name); + } + + // ----------------------------------------------------------------------- + // Introspection and commands + + /** + * Descriptors of matching clients across the cluster. `sel` narrows by + * room (`{ room }`) or matches everyone (`{}`). Resolves with an array; + * when a node never answered inside `timeout`, the partial array carries + * a non-enumerable `incomplete: true` and the miss is logged. + */ + fetchClients(sel = {}, options = {}) { + const local = this.#local.descriptors(sel); + return this.#request('fetch', { sel }, options.timeout ?? this.#requestTimeout).then(({ payloads, incomplete }) => { + const clients = local; + let truncated = false; + for (const payload of payloads) { + // push(...payload) is Function.prototype.apply in disguise and throws + // RangeError once a fan-in exceeds the engine's argument limit — a hard + // failure on exactly the large deployments this path exists to serve. + const list = Array.isArray(payload) ? payload : Array.isArray(payload?.list) ? payload.list : null; + if (payload?.truncated === true) truncated = true; + if (list) for (let i = 0; i < list.length; i++) clients.push(list[i]); + } + if (incomplete) { + this.#log.warn({ event: 'cluster.fetch.incomplete', received: clients.length }); + Object.defineProperty(clients, 'incomplete', { value: true, enumerable: false, configurable: true }); + } + // A node over its maxFetch cap answered with its first `cap` entries + // and said so — surfaced the same non-enumerable way as `incomplete`. + if (truncated) { + this.#log.warn({ event: 'cluster.fetch.truncated', received: clients.length }); + Object.defineProperty(clients, 'truncated', { value: true, enumerable: false, configurable: true }); + } + return clients; + }); + } + + /** + * `target` is a client id (addressed: one instance hears it) or a + * selector object (`{ room }` / `{}`: applied everywhere). Commands are + * fire-and-forget with the backplane's at-most-once delivery. + */ + join(target, ...rooms) { + this.#command('join', target, rooms); + } + + leave(target, ...rooms) { + this.#command('leave', target, rooms); + } + + disconnect(target) { + this.#command('disconnect', target, undefined); + } + + // The three room ops, in one place. Both call sites below used to spell this + // chain out themselves and disagreed on the unknown-op case — one fell + // through to disconnect, the other ignored it — so the default is now the + // caller's to state: this returns false rather than picking one. + #applyOp(op, sel, rooms) { + if (op === 'join') this.#local.join(sel, rooms); + else if (op === 'leave') this.#local.leave(sel, rooms); + else if (op === 'disconnect') this.#local.disconnect(sel); + else return false; + return true; + } + + #command(op, target, rooms) { + const apply = (sel) => { + // `op` here is a literal from join()/leave()/disconnect(), never user + // input, so an unknown one is a bug in this file and should be loud. + if (!this.#applyOp(op, sel, rooms)) throw new Error(`Unknown cluster command op '${op}'`); + }; + // An addressed command rides the target instance's own channel — one + // publish, one receiver — instead of asking every node to filter. + if (typeof target === 'string') { + const sel = { id: target }; + const instance = instanceOfClientId(target); + if (instance === this.#instance || instance === null) return void apply(sel); + if (!this.#backplane || this.#closed) return; + return void this.#post(instanceChannel(instance), { t: 'cmd', op, sel, rooms }); + } + const sel = target && typeof target === 'object' ? target : {}; + apply(sel); + if (!this.#backplane || this.#closed) return; + this.#post(CLUSTER_CHANNEL, { t: 'cmd', op, sel, rooms }); + } + + /** + * The remote leg of Broadcast.ask(): every node asks its own members and + * answers twice — first how many it asked (so the caller knows how many + * client answers exist at all), then the answers themselves. + */ + broadcastAsk(rooms, name, data, timeout) { + // The backstop covers the slowest possible answer: the remote node's own + // per-client timeout, plus the request round-trip margin. + const budget = timeout + this.#requestTimeout; + return this.#request('bask', { rooms, name, data, timeout }, budget).then(({ payloads, meta, incomplete }) => { + const answers = []; + const errors = []; + for (const payload of payloads) { + if (!payload || typeof payload !== 'object') continue; + // Appended by index, not spread: see the note on fetchClients above. + const { answers: mine, errors: theirs } = payload; + if (Array.isArray(mine)) for (let i = 0; i < mine.length; i++) answers.push(mine[i]); + if (Array.isArray(theirs)) for (let i = 0; i < theirs.length; i++) errors.push(theirs[i]); + } + return { answers, errors, expected: meta.expected, incomplete }; + }); + } + + // ----------------------------------------------------------------------- + // The request/reply engine + + #request(op, args, timeout) { + const missing = new Set(this.#nodes.keys()); + const result = { payloads: [], meta: { expected: 0 }, incomplete: false }; + if (!this.#backplane || this.#closed || missing.size === 0) return Promise.resolve(result); + const requestId = this.#generateId(); + return new Promise((resolve) => { + const request = { + missing, + // Who promised more (bask's count phase) vs who delivered a final: + // what keeps `incomplete` honest when eviction empties the missing + // set with promised answers still undelivered. + counted: new Set(), + finals: new Set(), + result, + timer: null, + settle: (incomplete) => { + result.incomplete = incomplete; + this.#otel.recordClusterRequest(op, !incomplete); + resolve(result); + }, + }; + request.timer = setTimeout(() => { + this.#requests.delete(requestId); + request.settle(true); + }, timeout); + if (typeof request.timer.unref === 'function') request.timer.unref(); + this.#requests.set(requestId, request); + // A question that never left — unserializable args, a synchronously + // broken backplane — settles NOW: nobody will ever answer it, and + // waiting out the full timeout would just park the caller. + if (!this.#post(CLUSTER_CHANNEL, { t: 'q', q: requestId, op, args })) { + this.#requests.delete(requestId); + clearTimeout(request.timer); + request.settle(true); + } + }); + } + + #answer(envelope) { + const request = this.#requests.get(envelope.a); + if (!request) return; // late answer after timeout/eviction: already settled + if (envelope.fin === false) { + // An intermediate reply carries accounting, not an answer — the count + // phase of a broadcast ask. + const count = envelope.payload?.count; + if (typeof count === 'number') { + request.result.meta.expected += count; + request.counted.add(envelope.from); + } + return; + } + request.result.payloads.push(envelope.payload); + request.finals.add(envelope.from); + if (!request.missing.delete(envelope.from) || request.missing.size > 0) return; + this.#requests.delete(envelope.a); + clearTimeout(request.timer); + request.settle(this.#hasUndelivered(request)); + } + + // Answers a request from another node. `reply(payload, fin)` publishes to + // the requester's own channel; the op's resolved value is the final reply. + #serve(envelope) { + const { q: requestId, op, args = {}, from } = envelope; + const reply = (payload, fin) => { + // A node that already said goodbye must not speak again: the bye told + // the requester to stop waiting, and a post-bye answer would arrive + // from an instance the receiver just evicted. + if (this.#closed) return; + this.#post(instanceChannel(from), { t: 'a', a: requestId, fin, payload }); + }; + const run = () => { + if (op === 'fetch') { + const list = this.#local.descriptors(args.sel ?? {}); + // A truncated reply says so — silent truncation is the one thing + // this layer never does. The cap exists because the reply is ONE + // pub/sub message; see DEFAULT_MAX_FETCH. + if (this.#maxFetch > 0 && list.length > this.#maxFetch) { + this.#log.warn({ event: 'cluster.fetch.truncated', count: list.length, cap: this.#maxFetch }); + list.length = this.#maxFetch; + return { list, truncated: true }; + } + return { list }; + } + if (op === 'emit') return this.#respondTo(args, from); + if (op === 'bask') { + const { rooms, name, data, timeout } = args; + return this.#local.ask(rooms ?? null, name, data, timeout, (count) => reply({ count }, false)); + } + throw new Error(`Unknown cluster op '${op}'`); + }; + // A CONSUMER span parented on the envelope's tp/ts (see #post): the + // requester's client span in one process becomes the parent of the + // serving span in another — the same linkage a call packet gets. The + // client stub carries the two attributes buildCallAttributes reads. + const stub = { transportKind: 'backplane', persistent: true }; + this.#otel.withSpan({ client: stub, packet: envelope, target: `cluster/${op}`, kind: SPAN_KIND_CONSUMER }, (h) => + Promise.resolve() + .then(run) + .then( + (payload) => { + this.#otel.endSpan(h, { 'wrpc.status': 'ok' }); + reply(payload, true); + }, + (error) => { + this.#otel.recordError(h, error); + this.#otel.endSpan(h, { 'wrpc.status': 'error' }); + this.#log.error({ err: error, event: 'cluster.serve', op }); + reply({ error: error?.message ?? 'Cluster op failed' }, true); + }, + ), + ); + } + + async #respondTo(args, from) { + const responder = this.#responders.get(args.name); + if (!responder) return { error: `No responder for '${args.name}'` }; + try { + return { value: await responder(args.data, from) }; + } catch (error) { + return { error: error?.message ?? 'Responder failed' }; + } + } + + #receive(message) { + if (this.#closed) return; + const envelope = typeof message === 'string' ? jsonParse(message) : message; + if (!envelope || typeof envelope !== 'object') return; + const { from, epoch, t } = envelope; + if (from === this.#instance || typeof from !== 'string' || from.length === 0) return; + if (this.#secret && !this.#verify(envelope, from)) return; + this.#otel.recordClusterMessage(typeof t === 'string' ? t : ''); + if (t === 'bye') { + // Only the life we actually track may say goodbye: a bye from a + // PREVIOUS epoch, arriving late while the restarted process is + // already up, must not evict the fresh entry. + const node = this.#nodes.get(from); + if (node && node.epoch === epoch) this.#evict(from, 'bye'); + return; + } + if (t === 'a') { + // Settled BEFORE the liveness/epoch bookkeeping: were #seen to run + // first, a reborn node answering the very question in this envelope + // would trigger the epoch sweep and settle the request without the + // answer we are holding in our hands. + this.#answer(envelope); + this.#seen(from, epoch); + return; + } + // EVERY message is a liveness proof — the snapshot only fills silence. + const node = this.#seen(from, epoch); + switch (t) { + case 'hello': + this.#applySnapshot(node, envelope); + // Addressed, not broadcast: N answers reach one newcomer instead of + // N answers reaching N nodes. + this.#post(instanceChannel(from), { t: 'state', ...this.#snapshot() }); + return; + case 'state': + node.syncing = false; + return void this.#applySnapshot(node, envelope); + case 'digest': { + const { clients, n, h } = envelope; + if (typeof clients === 'number') node.clients = clients; + if (typeof n !== 'number' || typeof h !== 'number') return; + let hash = 0; + let count = 0; + for (const [room, roomCount] of node.rooms) { + hash = (hash + entryHash(room, roomCount)) >>> 0; + count++; + } + if (count === n && hash === h) return; // the view is correct + // One outstanding sync per node: the answering 'state' clears it, + // so a slow answer cannot stack requests every tick. + if (node.syncing) return; + node.syncing = true; + this.#post(instanceChannel(from), { t: 'sync' }); + return; + } + case 'sync': + // Addressed: one node's view of us drifted; hand it the table. + return void this.#post(instanceChannel(from), { t: 'state', ...this.#snapshot() }); + case 'delta': { + const { room, d } = envelope; + if (typeof room !== 'string' || typeof d !== 'number') return; + const next = (node.rooms.get(room) ?? 0) + d; + if (next > 0) node.rooms.set(room, next); + else node.rooms.delete(room); + return; + } + case 'e': { + const { name, data } = envelope; + if (typeof name !== 'string' || name.length === 0) return; + return void Promise.resolve(this.emit(name, data)).catch((error) => { + this.#log.error({ err: error, event: 'cluster.listener', name }); + }); + } + case 'q': + return void this.#serve(envelope); + case 'cmd': { + const { op, sel, rooms } = envelope; + // Wire-shape sanity on the most powerful envelope type: op a + // string, sel a plain object, rooms (when present) an array of + // strings — a malformed command is dropped, never partially run. + if (typeof op !== 'string') return; + if (sel !== undefined && (typeof sel !== 'object' || sel === null || Array.isArray(sel))) return; + if (rooms !== undefined && (!Array.isArray(rooms) || rooms.some((room) => typeof room !== 'string'))) { + return; + } + try { + // `op` arrived from a peer: an unrecognised one is ignored, the same + // way the switch's own default ignores an unrecognised envelope type. + this.#applyOp(op, sel ?? {}, rooms); + } catch (error) { + this.#log.error({ err: error, event: 'cluster.command', op }); + } + break; + } + default: + break; + } + } +} + +module.exports = { Cluster, instanceOfClientId, CLUSTER_CHANNEL, INSTANCE_CHANNEL_PREFIX }; diff --git a/src/rpc/core.js b/src/rpc/core.js new file mode 100644 index 0000000..45bd16a --- /dev/null +++ b/src/rpc/core.js @@ -0,0 +1,961 @@ +'use strict'; + +const { Emitter, jsonParse, isCodec } = require('../utils.js'); +const { generateUUID } = require('../runtime/node.js'); +const { ServerTransport, buildHeaders, isOriginAllowed } = require('../transport.js'); +const { SessionManager } = require('./sessions.js'); +const { defineRouter, procedure, runHooksSafe } = require('./router.js'); +const { RoomRegistry, Broadcast, RoomsBackplane } = require('./rooms.js'); +const { Cluster } = require('./cluster.js'); +const { SseChannels } = require('../sse/server.js'); +// The channel header from the import-free constants module, NOT from +// sse/server.js: the string is shared, the implementation is not. +const { CHANNEL_HEADER } = require('../wire.js'); +const { isBackplane } = require('../scaling/index.js'); +const { + handleMessage, + handleBinary, + handleRpc, + split, + parseParams, + DEFAULT_MAX_BATCH, + UNKNOWN_TARGET, +} = require('./dispatcher.js'); +const { createLoggerWriter } = require('../logging.js'); +const { createServerTelemetry } = require('../telemetry/server.js'); +const { TRACEPARENT, TRACESTATE } = require('../telemetry/shared.js'); +const { Context, Client, DEFAULT_MAX_SUBSCRIPTIONS, DEFAULT_MAX_CALLS, buildMeta } = require('./client.js'); +const { DEFAULT_META_MAX, declaredHeaders, declaredData } = require('./meta.js'); + +// After this long an unsettled onConnect chain logs a warning: a hook that +// never resolves holds the client's dispatch (see #addClient), and the warn +// is the only trace that hang would leave. +const ONCONNECT_STALL_MS = 5_000; + +const ServerHttpTransport = ServerTransport.transport.http; +const ServerWsTransport = ServerTransport.transport.ws; +const ServerEventTransport = ServerTransport.transport.event; + +// call joins the caller's trace exactly like a packet call does. +const copyTraceHeaders = (headers, packet) => { + const parent = headers?.traceparent; + if (typeof parent !== 'string' || parent.length === 0) return; + packet[TRACEPARENT] = parent; + const state = headers.tracestate; + if (typeof state === 'string' && state.length > 0) packet[TRACESTATE] = state; +}; + +// A capability refusal ("this transport cannot carry that") is part of the +// protocol conversation, not a server internal: 400-coded and exposed so +// the peer reads the actual reason instead of a masked 500. + +const DEFAULT_BASE_PATH = '/api'; + +// The options the core owns. Every shell and adapter funnels its own option +// bag through here, so adding a core option cannot be silently dropped by +// one of the four places that construct an RpcServer. +const RPC_OPTION_KEYS = [ + 'router', + 'sessions', + 'cors', + 'basePath', + 'logger', + 'telemetry', + 'backplane', + 'instanceId', + 'generateId', + 'introspection', + 'maxBatch', + 'maxSubscriptions', + 'maxCalls', + 'sse', + 'cluster', + 'rooms', + 'querystring', + 'codec', + 'metaMaxBytes', +]; + +const rpcOptions = (options = {}) => { + const picked = {}; + for (const key of RPC_OPTION_KEYS) { + if (options[key] !== undefined) picked[key] = options[key]; + } + return picked; +}; + +const normalizeBasePath = (basePath) => { + if (!basePath) return ''; + let path = basePath.startsWith('/') ? basePath : `/${basePath}`; + if (path.endsWith('/')) path = path.slice(0, -1); + return path; +}; + +// Engine-agnostic RPC core: no node:http imports on the request path. +// Sockets come in through attachSocket (any WrpcSocket-shaped engine +// connection), HTTP calls through handleHttpCall (an abstract call +// description), worker ports through attachPort. +class RpcServer extends Emitter { + #router; + #sessions; + #rooms; + #backplane = null; + #cluster = null; + #instance; + #cors; + #basePath; + #log; + #roomsLog; + #sseLog; + #otel; + #limits; + #generateId; + #sse = null; + #querystring = null; + #metaMax = DEFAULT_META_MAX; + #codec = null; + #codecOption = null; + #restCodec = null; + #draining = false; + #clients = new Set(); + #byId = new Map(); + + constructor(options = {}) { + super(); + const { + router, + sessions, + cors = null, + basePath = DEFAULT_BASE_PATH, + logger = globalThis.console, + telemetry = null, + backplane = null, + instanceId = generateUUID(), + generateId = null, + introspection = true, + maxBatch = DEFAULT_MAX_BATCH, + maxSubscriptions = DEFAULT_MAX_SUBSCRIPTIONS, + maxCalls = DEFAULT_MAX_CALLS, + sse = {}, + cluster = {}, + rooms = {}, + querystring = null, + codec = null, + metaMaxBytes = DEFAULT_META_MAX, + } = options; + // The cap on peer-declared metadata (the ws wrpc_h query parameter and + // the per-packet meta field), measured on the encoded input. + this.#metaMax = Number.isInteger(metaMaxBytes) && metaMaxBytes > 0 ? metaMaxBytes : DEFAULT_META_MAX; + if (!router || typeof router.getProcedure !== 'function') { + throw new TypeError('RpcServer: options.router (a Router from defineRouter) is required'); + } + if (backplane && !isBackplane(backplane)) { + throw new TypeError('RpcServer: options.backplane does not implement the backplane contract'); + } + // The dot separates the instance prefix from the rest of a client id + // (`.`), so an instance name carrying one + // would make every one of its client ids parse to the wrong address. + if (String(instanceId).includes('.')) { + throw new TypeError('RpcServer: options.instanceId must not contain "."'); + } + // Pluggable query-string codec (qs and friends). Structural: anything + // with parse(str) -> object. The default is the prototype-safe + // URLSearchParams path in parseParams; an injected parser takes over + // prototype-pollution responsibility (documented). + if (querystring !== null && typeof querystring.parse !== 'function') { + throw new TypeError('RpcServer: options.querystring must provide a parse(text) function'); + } + if (codec !== null && !isCodec(codec)) { + throw new TypeError('RpcServer: options.codec must provide encode(packet)/decode(text), a rest section, or both'); + } + this.#log = createLoggerWriter(logger); + this.#otel = createServerTelemetry(telemetry); + // Built once here rather than per broadcast or per channel: Broadcast is + // constructed on every to()/except()/broadcast(). + this.#roomsLog = this.#log.child({ component: 'rooms' }); + this.#sseLog = this.#log.child({ component: 'sse' }); + this.#sessions = new SessionManager(sessions, this.#log.child({ component: 'sessions' })); + this.#cors = cors; + this.#basePath = normalizeBasePath(basePath); + this.#instance = instanceId; + this.#generateId = typeof generateId === 'function' ? generateId : generateUUID; + this.#querystring = querystring; + // Two halves, two fields: #codec is the PACKET codec (ws/http/sse/worker + // frames — a rest-only codec leaves packet mode JSON), #restCodec the + // REST body codec. The raw option survives for the public getter. + this.#codecOption = codec; + this.#codec = codec && typeof codec.encode === 'function' && typeof codec.decode === 'function' ? codec : null; + this.#restCodec = codec?.rest ?? null; + this.#limits = { maxBatch, maxSubscriptions, maxCalls }; + this.#router = this.#withIntrospection(router, introspection); + // A compiled fjs serializer emits JSON; a packet codec re-frames the + // whole wire. Both at once would mean the serializer's output is thrown + // away (or worse, double-encoded) — refusal beats a silent precedence. + // (codec.rest is compatible: the fast path serializes envelopes, and + // REST bodies never carry envelopes.) + if (this.#codec && this.#router.hasSerializers) { + throw new TypeError('RpcServer: options.codec and compiled response serializers are mutually exclusive'); + } + this.#initRooms(backplane, cluster, rooms); + // A channel's client is built from the GET that opened the stream, so it + // restores the session from that request's cookie the way attachSocket + // does — otherwise a browser holding a valid cookie starts the channel + // anonymous and every `access: 'session'` procedure on it answers 403. + const addClient = (transport, call) => { + const data = declaredData(call.headers, split(call.url ?? '', '?')[1], this.#metaMax, this.#sseLog); + return this.#addClient( + transport, + (client) => + this.#restoreToken(client, { headers: call.headers, url: call.url, declared: call.headers, meta: data }), + buildMeta({ headers: call.headers, data, url: call.url, remoteAddress: call.remoteAddress }), + ); + }; + // The identity a request presents, bound to a channel at creation and + // required again on every re-attach and channel POST — the id alone + // must never be enough to act as the channel's session. + const channelKey = (headers) => this.#requestKey(headers); + this.#sse = + sse === false ? null : new SseChannels({ ...sse, log: this.#sseLog, otel: this.#otel, addClient, channelKey }); + } + + #initRooms(backplane, clusterOptions, roomsOptions = {}) { + // The cluster exists with or without a backplane — without one every + // operation degrades to its local half, so application code written + // against `server.cluster` never branches on the deployment. + // `cluster: false` opts out of the cluster layer HONESTLY (it used to + // be silently ignored): the Cluster is built without the backplane, so + // presence replication, commands and asks degrade to their local + // halves, while the rooms backplane below is untouched. + const enabled = clusterOptions !== false; + const cluster = new Cluster({ + backplane: enabled ? backplane : null, + instance: this.#instance, + local: this.#clusterOps(), + log: this.#log.child({ component: 'cluster' }), + otel: this.#otel, + generateId: this.#generateId, + options: enabled ? clusterOptions : {}, + }); + this.#cluster = cluster; + if (!backplane) { + this.#rooms = new RoomRegistry(); + return; + } + const binder = new RoomsBackplane({ + backplane, + instance: this.#instance, + log: this.#roomsLog, + linger: roomsOptions?.linger, + // A replayed event is delivered LOCALLY: publishing it again would + // bounce it between instances forever. + deliver: (rooms, name, data) => { + const target = rooms ? this.#target().to(...rooms) : this.#target(); + target.local().emit(name, data); + }, + }); + this.#backplane = binder; + this.#rooms = new RoomRegistry({ + onSubscribe: (room) => binder.joinRoom(room), + onUnsubscribe: (room) => binder.leaveRoom(room), + // Every membership change is a presence delta; the periodic snapshot + // corrects whatever the broker drops. + onJoin: (room) => cluster.delta(room, 1), + onLeave: (room) => cluster.delta(room, -1), + }); + binder.start(); + cluster.start(); + } + + // The seam the cluster reaches this node's clients through: selectors and + // descriptors here, correlation and channels there. + #clusterOps() { + const select = (sel = {}) => { + if (typeof sel.id === 'string') { + const client = this.#byId.get(sel.id); + return client ? [client] : []; + } + if (typeof sel.room === 'string') return Array.from(this.#rooms.members(sel.room)); + // Persistent connections only: a per-request HTTP client is not a + // peer anyone means to enumerate, join or disconnect. Filtered while + // collecting — Array.from().filter() built the whole client list first + // and then threw most of it away. + const persistent = []; + for (const client of this.#clients) if (client.persistent) persistent.push(client); + return persistent; + }; + return { + count: (room) => this.#rooms.count(room), + snapshot: () => { + const rooms = {}; + for (const room of this.#rooms.list()) rooms[room] = this.#rooms.count(room); + let clients = 0; + for (const client of this.#clients) if (client.persistent) clients++; + return { rooms, clients }; + }, + // One pass: the filter+map chain allocated an intermediate array on top + // of the one select() already built. + descriptors: (sel) => { + const selected = select(sel); + const out = []; + for (let i = 0; i < selected.length; i++) { + const client = selected[i]; + if (!client.persistent) continue; + const rooms = []; + for (const room of client.rooms) rooms.push(room); + out.push({ + id: client.id, + instance: this.#instance, + rooms, + data: client.data, + transport: client.transportKind, + session: Boolean(client.session), + }); + } + return out; + }, + join: (sel, rooms) => { + if (!Array.isArray(rooms)) return; + for (const client of select(sel)) { + for (const room of rooms) client.join(room); + } + }, + leave: (sel, rooms) => { + if (!Array.isArray(rooms)) return; + for (const client of select(sel)) { + for (const room of rooms) client.leave(room); + } + }, + disconnect: (sel) => { + for (const client of select(sel)) client.close(); + }, + // The remote leg of a broadcast ask: LOCAL delivery only — the + // question already reached every other node as its own request. An + // ARRAY of rooms is a narrowing even when empty (to() with no rooms + // reaches nobody); only null means "everyone" — collapsing [] into + // the all-clients target would resurrect the WHERE-id-IN-() mistake + // for any envelope arriving with rooms: [] on the wire. + ask: (rooms, name, data, timeout, onCount) => { + const target = Array.isArray(rooms) ? this.#target().to(...rooms) : this.#target(); + return target.local().ask(name, data, { timeout, onCount }); + }, + }; + } + + /** Cluster-wide presence, introspection and node-to-node messaging. */ + get cluster() { + return this.#cluster; + } + + /** The local client with this id; undefined when not on this instance. */ + getClient(id) { + return this.#byId.get(id); + } + + get router() { + return this.#router; + } + + get sessions() { + return this.#sessions; + } + + get rooms() { + return this.#rooms; + } + + /** Identifies this instance on the backplane (echo suppression). */ + get instanceId() { + return this.#instance; + } + + get basePath() { + return this.#basePath; + } + + /** The injected codec option, verbatim — how an adapter inspects codec.rest. */ + get codec() { + return this.#codecOption; + } + + get clients() { + return new Set(this.#clients); + } + + // The telemetry writer, for hosts that run procedures OUTSIDE the + // dispatcher (the fastify adapter's delegated routes): they bracket + // invokeBare with the same spans/metrics the packet path gets, without + // reaching into private state. The writer's shape is @experimental, like + // the telemetry option it reflects. + get otel() { + return this.#otel; + } + + /** + * A host-delegated REST route (the fastify adapter's native routes) runs + * its procedure outside handleHttpCall: the host owns routing, validation + * and serialization; wrpc still owns the session, the rooms and the + * client lifecycle. Returns the per-request client + context; call + * `release()` when the response is done (wired to its close event) so the + * client is evicted. The safe-method CSRF rule is the same one + * #handleRest applies. + */ + async delegatedContext({ method = 'GET', headers = {}, remoteAddress = '', url = '' } = {}, target = null) { + const transport = new ServerHttpTransport({ headers, remoteAddress, respond: () => {} }, { headers: {} }); + const verb = String(method).toUpperCase(); + const safeMethod = verb === 'GET' || verb === 'HEAD'; + // The safe-method CSRF rule guards AMBIENT authority (a browser cookie + // attached without script). A non-ambient carrier — a bearer header the + // page's own code must set — has nothing to guard, so it restores on + // safe methods too. + const ambient = this.#sessions.transport.ambient === true; + const data = declaredData(headers, split(url ?? '', '?')[1], this.#metaMax, this.#log); + const restore = + !safeMethod || !ambient || this.#isSameOriginFetch(headers) + ? (c) => this.#restoreToken(c, { headers, url, declared: headers, meta: data }) + : null; + const client = this.#addClient(transport, restore, buildMeta({ headers, data, url, remoteAddress })); + await client.ready; + const context = client.createContext(null, target); + return { client, context, transport, release: () => transport.emit('close') }; + } + + #target() { + return new Broadcast({ + registry: this.#rooms, + clients: () => this.#clients, + publish: this.#backplane ? (envelope) => this.#backplane.publish(envelope) : null, + cluster: this.#backplane ? this.#cluster : null, + log: this.#roomsLog, + otel: this.#otel, + codec: this.#codec, + }); + } + + /** Everyone in any of `rooms`, each client once. */ + to(...rooms) { + return this.#target().to(...rooms); + } + + /** Everyone connected, minus `clients`. */ + except(...clients) { + return this.#target().except(...clients); + } + + /** Everyone connected; returns the number of LOCAL recipients. */ + broadcast(name, data) { + return this.#target().emit(name, data); + } + + // `mode`: true mounts system/introspect as public (the default the typed + // client and the codegen CLI rely on), 'session' gates it behind a + // session, false leaves the API surface unadvertised entirely. A router + // that already defines its own introspect always wins. + // `introspection` is a boolean, 'session', or the object form + // { access?: true | 'session' | false, schemas?: boolean } — the latter + // controls whether input schema parts travel to clients. + #withIntrospection(router, mode) { + const config = mode !== null && typeof mode === 'object' ? mode : { access: mode }; + const { access = true, schemas = true } = config; + if (access === false || mode === false) return router; + if (router.getProcedure('system', '*', 'introspect')) return router; + const system = defineRouter({ + system: { + introspect: procedure({ + access: access === 'session' ? 'session' : 'public', + handler: async (_context, units) => this.#router.introspect(units, { schemas }), + }), + }, + }); + return router.merge(system); + } + + #addClient(transport, restore = null, meta = null) { + // The transport encodes outbound packets; the Client decodes inbound + // ones. One server-wide codec — which is what keeps the broadcast + // fan-out single-encode. + if (this.#codec) transport.codec = this.#codec; + const options = { + codec: this.#codec, + sessions: this.#sessions, + rooms: this.#rooms, + server: this, + log: this.#log, + otel: this.#otel, + maxSubscriptions: this.#limits.maxSubscriptions, + maxCalls: this.#limits.maxCalls, + generateId: this.#generateId, + meta, + metaMax: this.#metaMax, + }; + const client = new Client(transport, options); + this.#clients.add(client); + this.#byId.set(client.id, client); + this.#otel.recordConnection(1, transport.kind); + // Assigned BEFORE the hooks run: the documented recipe is + // `onConnect: async (client) => { await client.sessionReady; ... }`, and + // a hook that ran ahead of this assignment awaited the constructor's + // resolved default and saw session === null. A thunk, not a promise — + // the restore needs the client this method is what creates. + if (restore) client.sessionReady = restore(client); + // Router-level connection lifecycle hooks. Contained (a throwing hook is + // logged, never fatal — refusing a connection is verifyClient's job) but + // ORDERED for dispatch: `client.ready` is session restore plus settled + // hooks, and the dispatcher awaits it before the access check, so a + // subscribe racing the hooks can no longer miss a room broadcast the + // hook's re-join was about to earn it. Fires for every attached client — + // the per-request HTTP ones included. + const { onConnect, onDisconnect } = this.#router.connectionHooks; + if (onConnect.length > 0) { + const hooks = runHooksSafe(onConnect, client, null, this.#log, 'onConnect'); + client.ready = client.sessionReady.then(() => hooks); + // A hook that never settles now holds this client's dispatch (before, + // it silently ran late and lost broadcasts) — a stall leaves a trace. + const stall = setTimeout(() => { + this.#log.warn({ event: 'onConnect.stalled', peer: client.source, ms: ONCONNECT_STALL_MS }); + }, ONCONNECT_STALL_MS); + if (typeof stall.unref === 'function') stall.unref(); + void client.ready.then(() => clearTimeout(stall)); + } + transport.once('close', () => { + // Snapshotted BEFORE destroy(): its first act is rooms.leaveAll(), so + // by hook time the registry is empty — the payload is the only way a + // disconnect hook learns which rooms the client was in. (client.rooms + // already returns a fresh Set copy.) Not reordered: the hooks run + // fire-and-forget, so ordering would not guarantee visibility anyway. + const payload = onDisconnect.length > 0 ? { rooms: client.rooms } : null; + client.destroy(); + this.#clients.delete(client); + this.#byId.delete(client.id); + this.#otel.recordConnection(-1, transport.kind); + if (onDisconnect.length > 0) void runHooksSafe(onDisconnect, client, payload, this.#log, 'onDisconnect'); + }); + return client; + } + + // The injected token carrier decides what "this request presents a + // session" means: a cookie by default, an Authorization header or a + // payload field when the app swapped the strategy (sessions.transport). + // Besides the raw { headers, url }, read() receives what the core already + // parsed — `declared` (the merged declared+observed header bag, wrpc_h + // included and capped on the configurable metaMaxBytes) and `meta` (the + // sanitized connection-metadata bag, both spellings merged) — so a + // strategy never re-implements the wire parsing and cannot drift from it. + #restoreToken(client, request) { + const token = this.#sessions.transport.read(request); + if (!token) return Promise.resolve(false); + return client.restoreSession(token).then( + (restored) => { + this.#otel.recordSession('restore', restored ? 'hit' : 'miss'); + return restored; + }, + (error) => { + this.#log.error({ err: error, event: 'session.restore' }); + this.#otel.recordSession('restore', 'error'); + return false; + }, + ); + } + + attachSocket(socket, meta = {}) { + const transport = new ServerWsTransport(socket, meta); + // Declared-then-observed: the wrpc_h query can only add names the + // upgrade request did not carry (see declaredHeaders). + const declared = declaredHeaders(meta.url, this.#metaMax, this.#log); + const merged = declared ? { ...declared, ...meta.headers } : meta.headers; + const data = declaredData(meta.headers, split(meta.url ?? '', '?')[1], this.#metaMax, this.#log); + const client = this.#addClient( + transport, + (c) => this.#restoreToken(c, { headers: meta.headers, url: meta.url, declared: merged, meta: data }), + buildMeta({ + headers: merged, + data, + url: meta.url, + remoteAddress: meta.remoteAddress ?? socket.remoteAddress, + protocol: socket.protocol, + }), + ); + + // Receive-side flow control: while binary chunks are being consumed + // (WrpcReadable.push applies its high-water mark), stop reading from + // the socket so the pressure reaches the peer through TCP. + let inflight = 0; + const done = () => { + inflight--; + if (inflight === 0 && typeof socket.resume === 'function') socket.resume(); + }; + socket.on('message', (data, isBinary) => { + if (!isBinary) return void handleMessage(client, data, this.#router, this.#limits); + inflight++; + if (inflight === 1 && typeof socket.pause === 'function') socket.pause(); + handleBinary(client, new Uint8Array(data)).then(done, done); + }); + socket.on('error', () => transport.emit('close')); + return client; + } + + attachPort(port, meta = null) { + const transport = new ServerEventTransport(port); + // A MessagePort carries no request, so there is nothing to observe; a + // consumer that received declared headers in the 'wrpc:connect' message + // may hand them over here. + const client = this.#addClient(transport, null, meta?.headers ? buildMeta({ headers: meta.headers }) : null); + port.on('message', (data) => { + if (typeof data === 'string' || Buffer.isBuffer(data)) { + handleMessage(client, data, this.#router, this.#limits); + } else if (data instanceof Uint8Array) { + handleBinary(client, data); + } + }); + return client; + } + + // Path contract under basePath (default '/api'): + // POST — a JSON call packet in the body + // ANY /unit/method?args — REST mode, args from query + body + matchPath(pathname) { + const base = this.#basePath || ''; + if (pathname === (base || '/')) return { mode: 'packet' }; + const prefix = `${base}/`; + if (base && pathname.startsWith(prefix)) { + return { mode: 'rest', rest: pathname.slice(prefix.length) }; + } + if (!base && pathname.length > 1) return { mode: 'rest', rest: pathname.slice(1) }; + return null; + } + + // Packet-mode bodies speak the codec when one is configured; REST-mode + // bodies stay JSON on purpose (curl and browsers are that mode's + // audience). Malformed input answers null either way. + #decodeBody(body) { + if (!this.#codec) return jsonParse(body); + try { + return this.#codec.decode(typeof body === 'string' ? body : String(body)); + } catch { + return null; + } + } + + // A batch frame needs to be recognized BEFORE the transport exists: the + // transport has to know how many answers to collect and in which order to + // emit them, which only the request's own id list can tell it. + #batchIds(body) { + const packet = this.#decodeBody(body); + if (!Array.isArray(packet)) return null; + if (packet.length === 0 || packet.length > this.#limits.maxBatch) return null; + const ids = new Array(packet.length); + for (let i = 0; i < packet.length; i++) { + const item = packet[i]; + ids[i] = item && typeof item === 'object' ? item.id : undefined; + } + return ids; + } + + get sse() { + return this.#sse; + } + + // The SSE endpoint sits just under basePath so it moves with it, and it is + // the one route whose response is a stream rather than a body. + get eventsPath() { + return `${this.#basePath}/events`; + } + + // The identity an HTTP request presents: its cookie's session token, or + // '' when it carries none. What SSE channels are keyed by. + #requestKey(headers = {}) { + // The identity a request presents, whatever the injected carrier is — + // the cookie token by default, the Authorization header under a bearer + // strategy. An SSE channel is keyed on it at creation and must present + // the same one on every re-attach and POST. + return this.#sessions.transport.read({ headers, url: '' }) ?? ''; + } + + // A POST carrying a live channel id belongs to that channel's client, not + // to a fresh request/response one: that is what lets a subscription opened + // by a POST deliver its values down the peer's event stream. The POST + // itself answers 202 — every reply travels on the stream. + // + // The channel id alone is NOT enough: the POST must also present the + // cookie identity the channel was created under, or knowing an id (they + // ride in URLs and logs) would be a bearer token for someone else's + // session-carrying client. + // + // `headers` are the same CORS-bearing response headers every other HTTP + // answer carries: without them a browser on another origin cannot read + // this response at all, which makes cross-origin SSE impossible. + #handleChannelPost(call, channelId, headers) { + // Channel POSTs answer packet-mode bodies, so the packet codec's type. + if (this.#codec?.contentType) headers = { ...headers, 'Content-Type': this.#codec.contentType }; + const channel = this.#sse.get(channelId); + const respond = (status, packet) => { + const body = Buffer.from(this.#codec ? this.#codec.encode(packet) : JSON.stringify(packet)); + call.respond({ status, headers: { ...headers, 'Content-Length': body.length }, body }); + }; + if (!channel) { + // 409, matching the events endpoint: "this channel is gone" is the + // signal the client recovers from by starting a fresh channel. + return void respond(409, { type: 'callback', id: '', error: { message: 'Unknown channel', code: 409 } }); + } + if (!this.#sse.authorized(channel, call.headers)) { + const error = { message: 'Channel belongs to another session', code: 403 }; + return void respond(403, { type: 'callback', id: '', error }); + } + handleMessage(channel.client, call.body, this.#router, this.#limits); + call.respond({ status: 202, headers: { ...headers, 'Content-Length': 0 } }); + } + + async handleHttpCall(call) { + const headers = buildHeaders(this.#cors, call.headers?.origin); + if (call.method === 'OPTIONS') { + return void call.respond({ status: 200, headers }); + } + // With cors.origins configured, a browser request from a disallowed + // origin is refused outright, not merely denied the response headers: + // the page could not read the answer either way, but the call itself + // would still have RUN — with the cookie session restored — which is + // exactly the cross-site request an origin allowlist exists to stop. + if (!isOriginAllowed(this.#cors, call.headers?.origin)) { + // A configuration error, not routine traffic: the offending origin is + // the one fact the operator needs. Counted on the calls series with + // UNKNOWN_TARGET so refused traffic shows up next to answered traffic + // without new cardinality — the rule every early refusal below shares + // (these paths run before any Client exists, so nothing else records). + this.#log.warn({ event: 'cors.refused', origin: call.headers?.origin }); + this.#otel.recordCall(UNKNOWN_TARGET, 'error', 403); + return void new ServerHttpTransport(call, { headers }).error(403); + } + // No Content-Type override here: which codec's type applies depends on + // the MODE (packet vs REST), decided below — a blanket header would + // advertise the packet framing on REST bodies it never framed. + const [pathname, params] = split(call.url ?? '/', '?'); + const match = this.matchPath(pathname); + if (this.#sse) { + if (pathname === this.eventsPath && (call.method ?? 'GET').toUpperCase() === 'GET') { + return void this.#handleSseOpen(call, params, headers); + } + const channelId = call.headers?.[CHANNEL_HEADER]; + if (channelId && call.method === 'POST' && match?.mode === 'packet') { + return void this.#handleChannelPost(call, channelId, headers); + } + } + if (!match) { + this.#log.warn({ event: 'http.refused', code: 404, path: pathname }); + this.#otel.recordCall(UNKNOWN_TARGET, 'error', 404); + return void new ServerHttpTransport(call, { headers }).error(404); + } + if (match.mode === 'packet') return this.#handlePacketPost(call, headers, params); + return this.#handleRest(call, match.rest, params, headers); + } + + // The one query-string seam: the injected parser (qs and friends) when + // configured, the prototype-safe URLSearchParams default otherwise. + #parseQuery(params) { + return this.#querystring ? this.#querystring.parse(params ?? '') : parseParams(params); + } + + // GET {basePath}/events — opens (or re-attaches) an SSE channel. The id + // may arrive by header (preferred — URLs end up in logs) or query param. + #handleSseOpen(call, params, headers) { + const query = this.#parseQuery(params); + const channelId = call.headers?.[CHANNEL_HEADER] || query.channel || null; + const lastEventId = call.headers?.['last-event-id'] ?? query.lastEventId ?? null; + this.#sse.open(call, { channelId, lastEventId, headers }); + } + + // POST {basePath} — a JSON call packet (or a batch array) in the body. + async #handlePacketPost(call, headers, params) { + // Mode-aware: only packet-mode responses carry the packet codec's type. + if (this.#codec?.contentType) headers['Content-Type'] = this.#codec.contentType; + const batch = call.method === 'POST' ? this.#batchIds(call.body) : null; + const transport = new ServerHttpTransport(call, { headers, batch }); + if (call.method !== 'POST') { + this.#log.warn({ event: 'http.refused', code: 403, method: call.method }); + this.#otel.recordCall(UNKNOWN_TARGET, 'error', 403); + return void transport.error(403); + } + const data = declaredData(call.headers, params, this.#metaMax, this.#log); + const client = this.#addClient( + transport, + (c) => this.#restoreToken(c, { headers: call.headers, url: call.url, declared: call.headers, meta: data }), + buildMeta({ headers: call.headers, data, url: call.url, remoteAddress: call.remoteAddress }), + ); + // An aborted or never-answered request must still evict the client: + // the transport only self-closes when it writes a response. + if (typeof call.onAbort === 'function') call.onAbort(() => transport.emit('close')); + await client.ready; + return void handleMessage(client, call.body, this.#router, this.#limits); + } + + // ANY {basePath}/unit/method?args — REST mode, args from query + body. + // + // A cross-site GET/HEAD carries the SameSite=Lax session cookie on + // top-level navigation, so ambient-authority dispatch of session + // procedures would be a CSRF hole. Safe methods therefore run WITHOUT the + // cookie-restored session (public procedures only) unless the request + // proves intent with a same-origin fetch header. + async #handleRest(call, rest, params, headers) { + const method = (call.method ?? 'GET').toUpperCase(); + // The REST body codec (codec.rest), when configured, re-frames every + // REST body — declared and conventional, results, errors and requests. + // The PACKET codec never applies here: REST's default audience is curl + // and browsers, and its bodies are values, not packet frames. + const restCodec = this.#restCodec; + if (restCodec?.contentType) headers['Content-Type'] = restCodec.contentType; + // Declarative routes first: a procedure that mapped itself onto a verb + // and path owns that path. The conventional /:unit/:method mode stays + // as the fallback, so introspection-driven callers keep working. + const route = this.#router.hasRestRoutes ? this.#matchDeclaredRoute(method, rest) : null; + // Declarative-route refusals answer in REST shape too (a plain wire + // error object), not as callback envelopes — same contract as a hit. + if (route?.malformed) { + this.#log.warn({ event: 'http.refused', code: 400, path: rest }); + this.#otel.recordCall(UNKNOWN_TARGET, 'error', 400); + return void new ServerHttpTransport(call, { headers, rest: { codec: restCodec } }).error(400); + } + if (route?.allowed) { + this.#log.warn({ event: 'http.refused', code: 405, method, path: rest }); + this.#otel.recordCall(UNKNOWN_TARGET, 'error', 405); + const headersWithAllow = { ...headers, Allow: route.allowed.join(', ') }; + return void new ServerHttpTransport(call, { headers: headersWithAllow, rest: { codec: restCodec } }).error(405); + } + const transport = new ServerHttpTransport(call, { + headers, + rest: route ? { status: route.http.status, codec: restCodec } : null, + }); + const safeMethod = method === 'GET' || method === 'HEAD'; + // For a per-request client the connection IS the call, so the declared + // data doubles as this call's meta: a curl caller passes x-wrpc-meta and + // a hook reads context.callMeta, same as on ws. + const data = declaredData(call.headers, params, this.#metaMax, this.#log); + // Same ambient-only CSRF reasoning as delegatedContext above. + const restore = + !safeMethod || this.#sessions.transport.ambient !== true || this.#isSameOriginFetch(call.headers) + ? (c) => this.#restoreToken(c, { headers: call.headers, url: call.url, declared: call.headers, meta: data }) + : null; + const client = this.#addClient( + transport, + restore, + buildMeta({ headers: call.headers, data, url: call.url, remoteAddress: call.remoteAddress }), + ); + // #addClient assigned the packet codec; REST bodies are not packet + // frames, so it comes back off. The conventional mode's callback + // envelope IS the body value, so the rest codec (when configured) + // takes the packet codec's slot on the transport. + transport.codec = restCodec; + if (typeof call.onAbort === 'function') call.onAbort(() => transport.emit('close')); + await client.ready; + // A request body under a rest codec that fails to decode is the + // caller's malformed input: 400 in REST shape, never a throw upward. + const decodeBody = (fallback) => { + const raw = call.body; + if (raw === undefined || raw === null || raw.length === 0) return fallback; + if (!restCodec) return jsonParse(raw) ?? fallback; + return restCodec.decode(raw); + }; + const id = this.#generateId(); + if (route) { + // REST semantics: the request arrives structured, and the SAME shape + // is what a ws caller passes by hand — the mapping only defines how + // an HTTP request is unpacked into args. + let body; + try { + body = decodeBody(undefined); + } catch { + return void transport.error(400); + } + const args = { params: route.params, query: this.#parseQuery(params), body }; + const packet = { type: 'call', id, method: `${route.unitKey}/${route.methodName}`, args }; + if (data) packet.meta = data; + copyTraceHeaders(call.headers, packet); + return void handleRpc(client, packet, this.#router); + } + const parameters = this.#parseQuery(params); + const [unit, name] = split(rest, '/'); + let body; + try { + body = decodeBody(null) ?? {}; + } catch { + return void transport.error(400); + } + const args = { ...parameters, ...body }; + const packet = { type: 'call', id, method: `${unit}/${name}`, args }; + if (data) packet.meta = data; + copyTraceHeaders(call.headers, packet); + return void handleRpc(client, packet, this.#router); + } + + // Splits and percent-decodes the path, then consults the router's trie. + // A malformed escape answers 400 rather than throwing into the host. + #matchDeclaredRoute(method, rest) { + const raw = rest.split('/'); + const segments = new Array(raw.length); + for (let i = 0; i < raw.length; i++) { + try { + segments[i] = decodeURIComponent(raw[i]); + } catch { + return { malformed: true }; + } + } + return this.#router.matchRest(method, segments); + } + + // Fetch metadata (sent by every modern browser, absent for non-browser + // peers): a cross-site top-level navigation is exactly what CSRF uses. + #isSameOriginFetch(headers = {}) { + const site = headers['sec-fetch-site']; + if (!site) return true; // curl, server-to-server, older clients + return site === 'same-origin' || site === 'none'; + } + + /** True while drain() runs: new calls are refused with 503. */ + get draining() { + return this.#draining; + } + + /** + * False while a backplane channel subscribe is failing and being retried + * (rooms or cluster): the node can publish but cannot HEAR — the + * half-connected state a readiness probe should drain it on. Always true + * without a backplane. + */ + get healthy() { + return (this.#backplane === null || this.#backplane.healthy) && this.#cluster.healthy; + } + + /** + * The graceful half of a shutdown: stop taking new calls (they answer + * 503) and wait up to `timeout` ms for the in-flight ones to settle. + * Subscriptions are deliberately NOT waited for — a live feed has no + * natural end; it is ended by the close that follows. Resolves early the + * moment nothing is in flight; a 0/absent timeout is a no-op. + */ + async drain(timeout = 0) { + if (!(timeout > 0)) return; + this.#draining = true; + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + let busy = 0; + for (const client of this.#clients) busy += client.calls.size; + if (busy === 0) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + + async close() { + if (this.#sse) this.#sse.close(); + for (const client of this.#clients) client.close(); + this.#clients.clear(); + this.#byId.clear(); + // The goodbye goes out first, while the backplane binder still works: + // other nodes evict this instance immediately instead of waiting out + // the presence timeout. + this.#cluster.close(); + // Unsubscribe before dropping the rooms, so the registry's last-member + // callbacks have nothing left to do. The injected backplane itself is + // never closed here: its lifetime belongs to whoever created it. + if (this.#backplane) this.#backplane.close(); + this.#rooms.clear(); + } +} + +module.exports = { RpcServer, Client, Context, rpcOptions, DEFAULT_MAX_SUBSCRIPTIONS, DEFAULT_MAX_CALLS }; diff --git a/src/rpc/dispatcher.js b/src/rpc/dispatcher.js new file mode 100644 index 0000000..2dd3eb1 --- /dev/null +++ b/src/rpc/dispatcher.js @@ -0,0 +1,548 @@ +'use strict'; + +const { jsonParse } = require('../utils.js'); +const { WrpcReadable } = require('../streams.js'); +const { chunkDecode } = require('../chunks.js'); +const { runSubscription } = require('./subscriptions.js'); +const { runHooks, runHooksSafe } = require('./router.js'); +const { publicErrorMessage, publicErrorDetails, wireError } = require('../transport.js'); +const { SPAN_KIND_CONSUMER } = require('../telemetry/shared.js'); + +const DEFAULT_VERSION = '*'; + +// A cancelled call answers with 499 (nginx's "client closed request"): the +// caller asked for it, so it is neither a server fault nor a success. +const CANCELLED = 499; + +// A batch frame is a JSON array of packets. Unbounded, one frame could ask +// for unbounded work — the cap is what keeps a single message from being a +// denial of service. +const DEFAULT_MAX_BATCH = 128; + +// Peer-declared metadata: the optional `meta` field on call/subscribe/event +// packets, and its connection-phase twin decoded in rpc/core. A plain +// object or nothing — capped on the serialized size, own `__proto__` +// dropped so application code can spread the bag safely, frozen so every +// reader sees one snapshot. A refusal yields NO meta, never an error: this +// is a label, deliberately outside the `validation` option (see +// docs/guide/metadata.md). +const PROTO_KEY = '__proto__'; + +// A conservative UPPER bound on the serialized size of a flat bag: every +// string character worst-cased at 6 encoded bytes (\uXXXX), numbers at +// their longest spelling. -1 means "cannot bound cheaply" (a nested value, +// an exotic type) and the caller falls back to the exact measure. +const metaUpperBound = (value) => { + let bound = 2; // the braces + for (const key in value) { + bound += key.length * 6 + 6; // quotes, colon, comma, escape worst case + const entry = value[key]; + const type = typeof entry; + if (type === 'string') bound += entry.length * 6 + 2; + else if (type === 'number' || type === 'boolean') bound += 24; + else if (entry === null) bound += 4; + else return -1; + } + return bound; +}; + +const sanitizeMeta = (value, limit) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + // The cap measures the SERIALIZED size, but serializing every bag to + // learn it was over-generous: per-call meta is a first-class field now + // (withMeta), so this runs per call/subscribe/event packet. The one-pass + // bound accepts the common small bag without a stringify (~139 -> ~20 ns + // for a three-key bag, bench/meta.js); the exact measure runs only for + // bags near the limit or with nested values. Never admits an over-limit + // bag — the bound only overestimates. + const bound = metaUpperBound(value); + if ((bound < 0 || bound > limit) && JSON.stringify(value).length > limit) return null; + // An own data property here (JSON.parse never pollutes by itself), dropped + // for the sake of application code that would spread the bag into a config. + if (Object.hasOwn(value, PROTO_KEY)) delete value[PROTO_KEY]; + return Object.freeze(value); +}; + +// What unresolved method/event names become in metric series and span +// names. The raw string is peer-controlled: a scanner spraying random +// method names used to mint a new time series per guess. +const UNKNOWN_TARGET = ''; + +const isError = (err) => err?.constructor?.name?.includes('Error') || false; + +// Monotonic where available; Date.now is the fallback for a host without it. +const now = () => (typeof performance === 'object' ? performance.now() : Date.now()); + +const split = (s, separator) => { + const i = s.indexOf(separator); + if (i < 0) return [s, '']; + return [s.slice(0, i), s.slice(i + separator.length)]; +}; + +const parseParams = (params) => Object.fromEntries(new URLSearchParams(params)); + +// 'unit/name', 'unit.vN/name' -> { unit, version, name }. +// Split on the FIRST dot only: 'unit.v1.2/m' must look up version 'v1.2' +// (a guaranteed miss -> 404), not silently truncate to version 'v1' +const parseTarget = (target) => { + const [unitName, name] = split(target, '/'); + const dot = unitName.indexOf('.'); + const unit = dot < 0 ? unitName : unitName.slice(0, dot); + const version = dot < 0 ? DEFAULT_VERSION : unitName.slice(dot + 1); + return { unit, version, name }; +}; + +const handleRpc = async (client, packet, router) => { + const { id, method, args } = packet; + const { unit, version, name: methodName } = parseTarget(method); + const proc = router.getProcedure(unit, version, methodName); + if (!proc) { + client.otel.recordCall(UNKNOWN_TARGET, 'error', 404); + return void client.error(404, { id }); + } + if (client.calls.has(id)) { + client.otel.recordCall(method, 'error', 400); + return void client.error(400, { id, error: new Error(`Call ${id} is already in flight`) }); + } + // Subscriptions are capped, and calls have to be too: each in-flight call + // holds a controller, a context and (with a queue) a semaphore slot, so an + // unbounded burst from one connection is memory the app never agreed to. + if (client.calls.size >= client.maxCalls) { + client.otel.recordCall(method, 'error', 429); + return void client.error(429, { id }); + } + // A draining server finishes what it started and takes nothing new: the + // 503 tells a well-behaved client to reconnect elsewhere. + if (client.server?.draining) { + client.otel.recordCall(method, 'error', 503); + return void client.error(503, { id }); + } + // The controller is what `{type:'cancel'}` and a disconnect reach: the + // handler sees it as ctx.signal, and once it is aborted the call has + // already been answered, so nothing this returns may be sent. + // + // Registered BEFORE the first await: a cancel batched behind the call in + // the same frame is dispatched in the same turn, and would otherwise find + // nothing to cancel. + const controller = new AbortController(); + client.calls.set(id, controller); + // The disabled writer's recorders are no-ops, but the OPTIONS OBJECTS and + // the timestamp fed to them were still built per call — with telemetry + // off, the guards below skip the allocations, not just the calls. + const enabled = client.otel.enabled; + const started = enabled ? now() : 0; + const hooks = router.hooksFor(proc); + const compiled = router.compiledFor(proc); + // Created before the first phase so onRequest can already enrich + // ctx.state — the context is what ties the phases of one call together. + const context = client.createContext(controller.signal, { + method, + procedure: proc, + callMeta: packet.meta === undefined ? null : sanitizeMeta(packet.meta, client.metaMax), + }); + // The span covers the whole invocation including session wait, access + // check, validation and the timeout race — an argument error deserves an + // error span and a duration sample exactly as much as a slow handler does. + return client.otel.withSpan({ client, packet, target: method }, async (handle) => { + let status = 'ok'; + let code; + try { + if (hooks.onRequest.length > 0) await runHooks(hooks.onRequest, context, packet); + await client.ready; + if (controller.signal.aborted) return void (status = 'cancelled'); + if (!client.session && proc.access !== 'public') { + status = 'error'; + code = 403; + return void client.error(403, { id }); + } + // The caller's per-call deadline, validated as a bounded positive + // number — an additive packet field, absent on every packet that + // never set CallOptions.timeout. + const budget = typeof packet.timeout === 'number' && packet.timeout > 0 ? packet.timeout : 0; + const result = await proc.invoke(context, args, hooks, compiled, budget); + if (controller.signal.aborted) return void (status = 'cancelled'); + if (isError(result)) { + status = 'error'; + code = result.code; + client.otel.recordError(handle, result, code); + if (hooks.onError.length > 0) await runHooksSafe(hooks.onError, context, result, client.log, 'onError'); + return void client.error(code, { id, error: result }); + } + const callback = { type: 'callback', id, result }; + // onSend sees (and may mutate) the exact packet about to be written — + // the last chance to redact or reshape a response. + if (hooks.onSend.length > 0) await runHooks(hooks.onSend, context, callback); + // The compiled-serializer fast path: the envelope is assembled around + // the schema-compiled result text instead of JSON.stringify walking + // the whole packet (~1.16x for the seam alone, more with a real + // fast-json-stringify — bench/serialize-callback.js). Correctness + // gates: an onSend hook may have mutated the packet after the shape + // the serializer was compiled for, so hooks win over the fast path. + const text = + compiled?.serialize && hooks.onSend.length === 0 + ? `{"type":"callback","id":${JSON.stringify(id)},"result":${compiled.serialize(result)}}` + : undefined; + client.send(callback, { method, text }); + if (hooks.onResponse.length > 0) { + await runHooksSafe(hooks.onResponse, context, callback, client.log, 'onResponse'); + } + } catch (error) { + if (controller.signal.aborted) return void (status = 'cancelled'); + code = error.code === 'ETIMEOUT' ? 408 : 500; + if (typeof error.code === 'number') code = error.code; + status = code === 408 ? 'timeout' : 'error'; + client.otel.recordError(handle, error, code); + if (code === 408 && hooks.onTimeout.length > 0) { + await runHooksSafe(hooks.onTimeout, context, error, client.log, 'onTimeout'); + } + if (hooks.onError.length > 0) await runHooksSafe(hooks.onError, context, error, client.log, 'onError'); + return void client.error(code, { id, error }); + } finally { + if (client.calls.get(id) === controller) client.calls.delete(id); + if (enabled) { + client.otel.endSpan(handle, { 'rpc.wrpc.status_code': code, 'wrpc.status': status }); + client.otel.recordCall(method, status, code, now() - started); + } + } + }); +}; + +// Cancellation is best-effort by nature: a handler that never looks at +// ctx.signal keeps running. What IS guaranteed is that the caller gets its +// 499 immediately and that whatever the handler eventually returns is +// dropped rather than delivered late. +const handleCancel = (client, packet) => { + const { id } = packet; + const controller = client.calls.get(id); + if (!controller) return; // unknown or already settled: nothing to cancel + client.calls.delete(id); + controller.abort(new Error('Cancelled by the caller')); + client.error(CANCELLED, { id, error: new Error('Cancelled by the caller') }); +}; + +// A subscription's terminal packet is `end`, refusals included: answering +// with a `callback` would make the client look for a call it never made. +const refuse = (client, id, code, message, target, details) => { + const error = { message, code }; + if (details !== undefined) error.details = details; + client.send({ type: 'end', id, error }); + client.warn(`SUBSCRIBE\t${id}\t${code}\t${message}`, { event: 'subscribe.refused', id, code }); + client.otel.recordCall(target, 'error', code); +}; + +const handleSubscribe = async (client, packet, router) => { + const { id, method, args, lastEventId } = packet; + const { unit, version, name: methodName } = parseTarget(method); + const proc = router.getProcedure(unit, version, methodName); + if (!proc) return void refuse(client, id, 404, `${method} not found`, UNKNOWN_TARGET); + if (!proc.subscription) { + return void refuse(client, id, 400, `${method} is not a subscription`, method); + } + // Re-subscribing an id this client already holds is a RESUME, not a + // collision: an SSE channel outlives its stream, so a reconnecting peer + // re-opens its subscriptions on the very client that still has them. + // Refusing would strand the old generator and kill the new subscription. + const previous = client.subscriptions.get(id); + if (previous) { + client.subscriptions.delete(id); + previous.abort(new Error('Replaced by a re-subscribe')); + } + if (client.subscriptions.size >= client.maxSubscriptions) { + return void refuse(client, id, 429, 'Too many subscriptions', method); + } + // Registered before the first await, so an unsubscribe dispatched in the + // same turn has something to find. + const controller = new AbortController(); + client.subscriptions.set(id, controller); + await client.ready; + if (controller.signal.aborted) { + client.subscriptions.delete(id); + return; + } + if (!client.session && proc.access !== 'public') { + client.subscriptions.delete(id); + return void refuse(client, id, 403, 'Forbidden', method); + } + const hooks = router.hooksFor(proc); + const compiled = router.compiledFor(proc); + const context = client.createContext(controller.signal, { + method, + procedure: proc, + callMeta: packet.meta === undefined ? null : sanitizeMeta(packet.meta, client.metaMax), + }); + if (hooks.onSubscribe.length > 0) { + try { + await runHooks(hooks.onSubscribe, context, packet); + } catch (error) { + client.subscriptions.delete(id); + const code = typeof error.code === 'number' ? error.code : 500; + return void refuse(client, id, code, publicErrorMessage(code, error), method, publicErrorDetails(code, error)); + } + } + const options = { id, procedure: proc, context, args, lastEventId, signal: controller.signal, hooks, compiled }; + // The span covers the subscription's whole life, not just its setup: the + // interesting number is how long a feed ran and how much it yielded. + const started = now(); + client.otel.recordSubscription(1, method); + return client.otel.withSpan({ client, packet, target: method, suffix: ' subscribe' }, async (handle) => { + let terminal = { type: 'end', id }; + // A mutable counter rather than a changed return shape: runSubscription's + // contract is "the terminal packet", and it should stay that. + const stats = { values: 0 }; + try { + terminal = await runSubscription(client, { ...options, stats }); + } catch (error) { + const code = typeof error.code === 'number' ? error.code : 500; + terminal = { type: 'end', id, error: wireError(code, error) }; + client.otel.recordError(handle, error, code); + } finally { + if (client.subscriptions.get(id) === controller) client.subscriptions.delete(id); + } + // The peer is told, but nobody was: a subscription that dies server-side + // used to leave no trace at all on this side of the wire. + const entry = { event: 'subscribe.end', id, method, code: terminal.error?.code }; + if (terminal.error) client.log.warn(entry, `${client.source}\tSUBSCRIBE\t${id}\t${terminal.error.code}`); + else client.log.debug(entry); + let outcome = 'complete'; + if (terminal.error) outcome = 'error'; + else if (controller.signal.aborted) outcome = 'unsubscribed'; + // Fires on EVERY end — completion, error, unsubscribe, disconnect — so + // a quota taken in onSubscribe always comes back. + if (hooks.onUnsubscribe.length > 0) { + await runHooksSafe(hooks.onUnsubscribe, context, terminal, client.log, 'onUnsubscribe'); + } + client.otel.recordSubscription(-1, method); + client.otel.recordSubscriptionValues(stats.values, method); + client.otel.endSpan(handle, { + 'wrpc.subscription.values': stats.values, + 'wrpc.subscription.terminal': outcome, + 'wrpc.subscription.resumed': lastEventId !== undefined && lastEventId !== null, + 'rpc.wrpc.status_code': terminal.error?.code, + }); + client.otel.recordCall(method, outcome === 'error' ? 'error' : 'ok', terminal.error?.code, now() - started); + client.send(terminal); + }); +}; + +const handleUnsubscribe = (client, packet) => { + const { id } = packet; + const controller = client.subscriptions.get(id); + if (!controller) return; // already finished, or never existed + client.subscriptions.delete(id); + // The pump notices the abort, stops pulling, and answers with `end`. + controller.abort(new Error('Unsubscribed by the client')); +}; + +const handleStream = async (client, packet) => { + const { id, name, size, status } = packet; + const tag = `${id}/${name}`; + try { + // Request/response transports cannot carry streams; without this the + // call would register a stream and never answer, hanging the request + if (!client.persistent) throw new Error('Streams require a persistent connection'); + if (!client.binary) throw new Error('Streams require a binary transport'); + const stream = client.streams.get(id); + if (status) { + if (!stream) throw new Error(`Stream ${tag} is not initialized`); + if (status === 'end') await stream.close(); + if (status === 'terminate') await stream.terminate(); + return void client.streams.delete(id); + } + const valid = typeof name === 'string' && Number.isSafeInteger(size); + if (!valid) throw new Error('Stream packet structure error'); + if (stream) throw new Error(`Stream ${tag} is already initialized`); + { + const stream = new WrpcReadable(id, name, size); + client.streams.set(id, stream); + } + } catch (error) { + client.error(400, { id, error }); + } +}; + +const handleBinary = async (client, data) => { + const { id, payload } = chunkDecode(data); + try { + const upstream = client.streams.get(id); + if (upstream) { + await upstream.push(payload); + client.otel.recordStreamBytes('receive', payload?.byteLength ?? 0); + return; + } + const error = new Error(`Stream ${id} is not initialized`); + client.error(400, { id, error }); + } catch (error) { + client.error(400, { id, error }); + } +}; + +// Inbound events (client -> server) are fire-and-forget: the packet carries +// no id, so there is nothing to answer on and nothing to answer with. A +// rejected or failing event is therefore reported to the server log — the +// wire stays silent, which is what makes an event an event. +// +// The handler is resolved BEFORE any span opens: an unresolved name is +// peer-controlled text, and a span per random string is a cardinality bomb. +// The log line keeps the raw target — logs are not aggregated by name. +const handleEvent = async (client, packet, router) => { + const { name: target, data } = packet; + // Request/response transports cannot carry events. Without this the + // handler would run and the HTTP request would never be answered — the + // response is only written when something calls send/error — pinning the + // socket and its server-side Client open for as long as the peer keeps + // the connection. `handleStream` guards the same hazard. + if (!client.persistent) { + return void client.error(400, { error: new Error('Events require a persistent connection') }); + } + const { unit, version, name } = parseTarget(target); + const handler = router.getEventHandler(unit, version, name); + if (!handler) return void client.warn(`EVENT\t${target}\tno handler`); + await client.ready; + if (!client.session && handler.access !== 'public') { + return void client.warn(`EVENT\t${target}\tsession required`); + } + // Events run the invocation phases (preValidation/preHandler/onError); + // onRequest/onSend do not apply — there is no packet to answer with. + const hooks = router.hooksFor(handler); + const compiled = router.compiledFor(handler); + const context = client.createContext(null, { + method: target, + procedure: handler, + callMeta: packet.meta === undefined ? null : sanitizeMeta(packet.meta, client.metaMax), + }); + const run = async (handle) => { + try { + await handler.invoke(context, data, hooks, compiled); + } catch (error) { + client.otel.recordError(handle, error); + if (hooks.onError.length > 0) await runHooksSafe(hooks.onError, context, error, client.log, 'onError'); + client.warn(`EVENT\t${target}\t${error.stack ?? error.message}`); + } + }; + // Telemetry off: no span closure, no options object — just the work. + if (!client.otel.enabled) return run({ span: null, error: false }); + return client.otel.withSpan( + { client, packet, target, kind: SPAN_KIND_CONSUMER, suffix: ' event' }, + async (handle) => { + try { + return await run(handle); + } finally { + client.otel.endSpan(handle); + } + }, + ); +}; + +// Every dispatch below is fire-and-forget, so a rejection has nobody to +// reach: under node's default --unhandled-rejections=throw one would take +// the process down. The handlers answer their own errors; this is the last +// line of defence for the ones that cannot (a broken transport, a console +// that throws), and it must never throw itself. +const contain = (client, label) => (error) => { + try { + client.warn(`${label}\t${error?.stack ?? error}`); + } catch { + // Nothing left to report through. + } +}; + +// Only a connection that stays open can carry a stream of values or a +// cancellation that arrives after the call: on a request/response transport +// these have to be refused, or the request is never answered. +const needsConnection = (client, id, what) => { + if (client.persistent) return false; + client.error(400, { id, error: new Error(`${what} requires a persistent connection`) }); + return true; +}; + +const handlePacket = (client, packet, router) => { + const { id, type, method, name } = packet; + // The target is type-checked, not just truthiness-checked: a non-string + // `method`/`name` would throw inside parseTarget, and these calls are not + // awaited. A malformed packet has to fall through to the error below. + if (type === 'call' && id && typeof method === 'string') { + return void handleRpc(client, packet, router).catch(contain(client, 'CALL')); + } else if (type === 'subscribe' && id && typeof method === 'string') { + if (!client.persistent) { + return void refuse(client, id, 400, 'Subscriptions require a persistent connection', UNKNOWN_TARGET); + } + return void handleSubscribe(client, packet, router).catch(contain(client, 'SUBSCRIBE')); + } else if (type === 'unsubscribe' && id) { + if (needsConnection(client, id, 'Subscriptions')) return; + return void handleUnsubscribe(client, packet); + } else if (type === 'cancel' && id) { + if (needsConnection(client, id, 'Cancellation')) return; + return void handleCancel(client, packet); + } else if (type === 'stream' && id) { + return void handleStream(client, packet).catch(contain(client, 'STREAM')); + } else if (type === 'event' && typeof name === 'string' && name) { + return void handleEvent(client, packet, router).catch(contain(client, 'EVENT')); + } else if (type === 'callback' && typeof id === 'string' && id) { + // Asks only travel on persistent transports, so a callback on HTTP can + // never match one — and it MUST still be answered, or the request (and + // every other slot of its batch) would hang unanswered forever. + if (needsConnection(client, id, 'Callbacks')) return; + // The answer to an ask this server sent (client.ask / Broadcast.ask). + // An unmatched one — late after its timeout, or simply never asked — is + // logged, not answered: an error callback to a callback could ping-pong. + // The id is peer-controlled text: bounded and escaped before it becomes + // a log line. + if (!client.settleAnswer(packet)) { + const shown = id.length > 128 ? `${id.slice(0, 128)}…` : id; + client.warn(`ANSWER\t${JSON.stringify(shown)}\tno pending ask`); + } + return; + } else if (type === 'ping') { + // App-level heartbeat: a browser WebSocket cannot see protocol pings, + // so liveness is measured with packets the client can observe. + return void client.send({ type: 'pong' }); + } else if (type === 'pong' && client.persistent) { + return; // answer to a server-initiated ping; liveness is the transport's + } + // The id travels with the refusal when the packet carried one: an HTTP + // batch answers positionally, so an id-less error would lose this slot and + // shift every answer after it. + const error = new Error('Packet structure error'); + client.error(500, { id: typeof id === 'string' ? id : '', error }); +}; + +// A JSON array is a batch frame: several packets in one message, each +// answered on its own (on a request/response transport the answers come +// back as an array in the same order — see ServerHttpTransport). +const handleMessage = (client, data, router, options = {}) => { + const parsed = client.decodePacket ? client.decodePacket(data) : jsonParse(data); + // jsonParse answers null for both "malformed" and "the literal null", and + // the `|| {}` below hides the difference. This is the single funnel every + // unparseable packet in the system passes through, so it is worth a line. + if (parsed === null) client.log.warn({ event: 'packet.malformed', bytes: data?.length ?? 0 }); + const packet = parsed || {}; + if (!Array.isArray(packet)) return void handlePacket(client, packet, router); + const { maxBatch = DEFAULT_MAX_BATCH } = options; + if (packet.length === 0 || packet.length > maxBatch) { + const error = new Error(`Batch size must be between 1 and ${maxBatch}`); + return void client.error(400, { error }); + } + for (const item of packet) { + handlePacket(client, item && typeof item === 'object' ? item : {}, router); + } +}; + +module.exports = { + sanitizeMeta, + UNKNOWN_TARGET, + handleRpc, + handleStream, + handleBinary, + handleEvent, + handleSubscribe, + handleUnsubscribe, + handleCancel, + handlePacket, + handleMessage, + isError, + split, + parseTarget, + parseParams, + DEFAULT_MAX_BATCH, + CANCELLED, +}; diff --git a/src/rpc/meta.js b/src/rpc/meta.js new file mode 100644 index 0000000..a84611d --- /dev/null +++ b/src/rpc/meta.js @@ -0,0 +1,137 @@ +'use strict'; + +// The connection-metadata parser: the declared-headers bag (the ws wrpc_h +// connect-URL parameter) and the connection-phase `meta` bag in both its +// spellings. Split out of rpc/core.js — this is a self-contained pure +// module the core, and nothing else, feeds raw requests through; the token +// transports receive its OUTPUT (see sessions.js), never re-run it. + +const { jsonParse, toKebab } = require('../utils.js'); +const { HEADERS_PARAM, META_PARAM, META_HEADER, META_PREFIX } = require('../wire.js'); +const { sanitizeMeta, split } = require('./dispatcher.js'); + +// Connection-phase headers on the ws leg: the WHATWG WebSocket constructor +// cannot set real upgrade headers, so the client carries declared ones as +// ONE query parameter on the connect URL. PEER-CONTROLLED, so every step +// below is a refusal rather than a throw — an oversize or malformed label +// leaves the connection with no label, never without a connection. Observed +// upgrade headers always win the merge: the query can only ADD names the +// request did not carry, and the reserved names it could spoof are dropped +// outright (on http/sse, fetch itself refuses to send them, so the deny +// list exists exactly for this query path). +const DEFAULT_META_MAX = 2048; +const RESERVED_DECLARED = /^(?:cookie|host|origin)$|^(?:sec-|content-|proxy-|x-wrpc-)/; + +const declaredHeaders = (url, limit, log) => { + const query = split(url ?? '', '?')[1]; + if (!query) return null; + // Capped on the ENCODED length, before any decoding work — that is the + // string the peer actually controls. + if (query.length > limit) { + log.warn({ event: 'meta.oversize', bytes: query.length }); + return null; + } + const raw = new URLSearchParams(query).get(HEADERS_PARAM); + if (!raw) return null; + const value = jsonParse(raw); + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + let declared = null; + for (const key of Object.keys(value)) { + if (typeof value[key] !== 'string') continue; // a flat string map only + // Kebab-cased: node lowercases observed header names, and schema.headers + // validation must see one casing convention, not two. Kebab rather than a + // bare lowercase because `xAppVersion` would otherwise land as + // `xappversion` — a key nobody would write in a schema. + // Tested AFTER the transform: toKebab can only lowercase and insert + // hyphens, so it can never turn a permitted name into a reserved one, but + // the honest order is to check the name that will actually be kept. + const name = toKebab(key); + if (name === '__proto__' || RESERVED_DECLARED.test(name)) continue; + (declared ??= { __proto__: null })[name] = value[key]; + } + return declared; +}; + +// The connection-phase half of `meta` — the x-wrpc-meta request header +// (http/sse; percent-encoded JSON, since header values must stay latin-1), +// its per-key x-wrpc-meta- spelling, or the wrpc_meta connect-URL +// parameter (ws); the wire names themselves live in ../wire.js. Same +// sanitizer as the per-packet field, same refusal-not-throw discipline. +// Unlike headers this bag is deliberately outside schema validation: it is +// a label for cross-cutting hooks, not procedure input. + +// The prefixed spelling — the S3 x-amz-meta-* idiom: `x-wrpc-meta-idem: 9f3c` +// is a header a human can type and a gateway can inject, strip or route on, +// where the percent-encoded JSON one is not. wrpc's own client emits it when +// asked (`metaFormat: 'prefixed'`); an external HTTP caller can always use it. +// Values stay STRINGS — the same by-design semantics as REST query args — so +// the canonical JSON header remains the type-faithful form and wins a key +// collision. Names are kebab-normalized to match what the client sends, but +// see toKebab: HTTP has already lowercased them, so a caller who writes +// `x-wrpc-meta-userId` by hand gets `userid` and no transform can undo it. +const prefixedData = (headers, limit) => { + let data = null; + let bytes = 0; + for (const key in headers) { + if (!key.startsWith(META_PREFIX)) continue; + const name = key.slice(META_PREFIX.length); + // A duplicated header arrives as an array — skipped, refusal-style, + // like every other malformed label; '__proto__' never carries over. + if (name.length === 0 || name === '__proto__' || typeof headers[key] !== 'string') continue; + bytes += name.length + headers[key].length; + if (bytes > limit) return null; + (data ??= { __proto__: null })[toKebab(name)] = headers[key]; + } + return data; +}; + +// Copies `source` onto `target` under normalized names. `target` is always a +// bag this function's caller just built, never one it was handed, so writing +// through it is safe; '__proto__' is skipped before it can be a key at all. +const kebabKeys = (source, target) => { + for (const key in source) { + if (key === '__proto__') continue; + target[toKebab(key)] = source[key]; + } + return target; +}; + +// `query` is the request's ALREADY-SPLIT query text: every HTTP caller has +// it from handleHttpCall's one split, so this function re-splitting the URL +// was pure rework — and the URLSearchParams parse behind it is gated on a +// substring probe, because the common REST/curl request carries no +// wrpc_meta at all (bench/meta.js measures both). +const declaredData = (headers, query, limit, log) => { + const prefixed = headers ? prefixedData(headers, limit) : null; + let raw = null; + const header = headers?.[META_HEADER]; + if (typeof header === 'string' && header.length > 0) { + if (header.length > limit) { + log.warn({ event: 'meta.oversize', bytes: header.length }); + return sanitizeMeta(prefixed, limit); + } + try { + raw = decodeURIComponent(header); + } catch { + return sanitizeMeta(prefixed, limit); + } + } else if (query && query.length <= limit && query.includes(META_PARAM)) { + raw = new URLSearchParams(query).get(META_PARAM); + } + const declared = raw ? jsonParse(raw) : null; + const canonical = typeof declared === 'object' && declared !== null && !Array.isArray(declared) ? declared : null; + if (!canonical) return sanitizeMeta(prefixed, limit); + // Normalized as it merges, so the two spellings reduce to the SAME key and + // the collision is real: without this, `x-wrpc-meta: {"userId":1}` and + // `x-wrpc-meta-user-id: 2` would sit side by side as lookalike keys and + // nothing would win. Prefixed is the (already-kebab) seed, canonical + // overwrites it — the JSON header is the type-faithful form, so it wins. + return sanitizeMeta(kebabKeys(canonical, prefixed ?? { __proto__: null }), limit); +}; + +// The REST leg's trace context arrives as REAL HTTP headers (the client's +// #restCall injects traceparent/tracestate — there is no packet on the wire +// to carry tp/ts). Mapped onto the synthetic packet's tp/ts fields, the +// ordinary extract path in the telemetry writer picks them up, so a REST + +module.exports = { DEFAULT_META_MAX, RESERVED_DECLARED, declaredHeaders, declaredData }; diff --git a/src/rpc/rest.js b/src/rpc/rest.js new file mode 100644 index 0000000..069f498 --- /dev/null +++ b/src/rpc/rest.js @@ -0,0 +1,205 @@ +'use strict'; + +// The declarative REST bridge, split out of router.js: the http-mapping +// normalizer, the rest.version strategy, and the per-verb segment trie the +// dispatcher matches requests against. Everything here is COLD by the +// performance conventions — the trees are built when the unit set changes, +// and matchRestTrees runs once per HTTP request, not per packet. + +const DEFAULT_VERSION = '*'; + +// User-defined names ('__proto__' params included) must land as own +// properties — the same guard router.js applies (see assignKey there); a +// local copy keeps this module dependency-free of the router. +const assignKey = (target, key, value) => { + Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true }); +}; + +const HTTP_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']; +const PARAM_SEGMENT = /^:[A-Za-z_$][\w$]*$/; +const STATIC_SEGMENT = /^[^/:*]+$/; + +const normalizeHttp = (http) => { + if (http === null || http === undefined) return null; + if (typeof http !== 'object') throw new TypeError('procedure() http must be an object'); + const { method, path, status = null } = http; + if (!HTTP_METHODS.includes(method)) { + throw new TypeError(`procedure() http.method must be one of ${HTTP_METHODS.join(', ')}`); + } + if (typeof path !== 'string' || !path.startsWith('/')) { + throw new TypeError("procedure() http.path must be a string starting with '/'"); + } + const segments = path === '/' ? [] : path.slice(1).split('/'); + for (const segment of segments) { + if (!PARAM_SEGMENT.test(segment) && !STATIC_SEGMENT.test(segment)) { + throw new TypeError(`procedure() http.path has an invalid segment '${segment}' in '${path}'`); + } + } + if (status !== null && !(Number.isInteger(status) && status >= 200 && status <= 599)) { + throw new TypeError('procedure() http.status must be an integer HTTP status (200-599)'); + } + return { method, path, status }; +}; + +// Router-level REST options; today one strategy: how a versioned unit's +// declared paths surface. `null` means "declared paths verbatim". +const normalizeRestOptions = (rest) => { + if (rest === undefined || rest === null) return null; + if (typeof rest !== 'object' || Array.isArray(rest)) { + throw new TypeError('defineRouter: rest must be an options object'); + } + const { version } = rest; + if (version === undefined) return null; + if (version !== 'path' && typeof version !== 'function') { + throw new TypeError("defineRouter: rest.version must be 'path' or a function (version, path) => path"); + } + return { version }; +}; + +// The version-aware view of a procedure's http mapping: with a rest.version +// strategy, a versioned unit's declared path gains its '/vN' prefix here — +// computed at build/introspection time, never by mutating proc.http +// (Procedure instances are shared across routers by merge()). The version +// token already carries the 'v' ('auth.v1' -> 'v1'), so 'path' is a plain +// prefix. The one seam serves the trie, restRoutes() and introspect(), which +// is what keeps dispatch, host adapters and clients version-consistent. +const effectiveHttp = (http, version, restOptions) => { + if (!http || version === DEFAULT_VERSION || !restOptions?.version) return http; + const spec = restOptions.version; + const path = + typeof spec === 'function' ? spec(version, http.path) : `/${version}${http.path === '/' ? '' : http.path}`; + if (typeof path !== 'string' || !path.startsWith('/')) { + throw new TypeError(`rest.version must produce a path starting with '/', got ${JSON.stringify(path)}`); + } + return { ...http, path }; +}; + +// The per-verb segment trie over every procedure's effective http mapping, +// or null when no procedure declares one. Throws on conflicting routes — +// a definition error, loud at build time. +const buildRestTrees = (units, restOptions) => { + const makeNode = () => ({ static: new Map(), param: null, terminal: null }); + let trees = null; + for (const [unit, versions] of units) { + for (const [version, entry] of versions) { + const unitKey = version === DEFAULT_VERSION ? unit : `${unit}.${version}`; + for (const [methodName, proc] of entry.methods) { + if (!proc.http) continue; + trees ??= new Map(); + // Effective, not declared: with rest.version, two versions of one + // declared path diverge by their '/vN' prefix BEFORE the conflict + // check below ever sees them. + const http = effectiveHttp(proc.http, version, restOptions); + const { method, path } = http; + let node = trees.get(method); + if (!node) { + node = makeNode(); + trees.set(method, node); + } + const segments = path === '/' ? [] : path.slice(1).split('/'); + const paramNames = []; + for (const segment of segments) { + if (segment.startsWith(':')) { + const name = segment.slice(1); + if (node.param && node.param.name !== name) { + throw new TypeError( + `REST route conflict: ${method} ':${node.param.name}' and ':${name}' at the same position ` + + `(${unitKey}/${methodName} vs an earlier route)`, + ); + } + node.param ??= { name, node: makeNode() }; + paramNames.push(name); + node = node.param.node; + continue; + } + let next = node.static.get(segment); + if (!next) { + next = makeNode(); + node.static.set(segment, next); + } + node = next; + } + if (node.terminal) { + const existing = node.terminal; + throw new TypeError( + `REST route conflict: ${method} ${path} is declared by both ` + + `${existing.unitKey}/${existing.methodName} and ${unitKey}/${methodName}`, + ); + } + node.terminal = { proc, unitKey, methodName, paramNames, http }; + } + } + } + return trees; +}; + +const walk = (node, segments) => { + if (!node) return null; + const values = []; + for (const segment of segments) { + const next = node.static.get(segment); + if (next) { + node = next; + continue; + } + if (node.param) { + values.push(segment); + node = node.param.node; + continue; + } + return null; + } + return node.terminal ? { route: node.terminal, values } : null; +}; + +/** + * Matches an HTTP verb and decoded path segments against the trie set. + * Returns null (no path match — the caller falls back to the conventional + * /:unit/:method mode), `{ allowed }` (some OTHER verb matches this path — + * a 405 with an Allow list), or the full route + * `{ proc, unitKey, methodName, params, http }`. + */ +const matchRestTrees = (trees, method, segments) => { + if (!trees) return null; + const hit = walk(trees.get(method), segments); + if (hit) { + const params = {}; + for (let i = 0; i < hit.route.paramNames.length; i++) { + assignKey(params, hit.route.paramNames[i], hit.values[i]); + } + const { proc, unitKey, methodName, http } = hit.route; + return { proc, unitKey, methodName, params, http }; + } + // No route under this verb — probe the other verbs' trees so the answer + // distinguishes "unknown path" (fall through, maybe the conventional mode + // knows it) from "known path, wrong verb" (405). + const allowed = []; + for (const [verb, tree] of trees) { + if (verb !== method && walk(tree, segments)) allowed.push(verb); + } + return allowed.length > 0 ? { allowed } : null; +}; + +/** Every declared REST route — what a host adapter registers natively. */ +const collectRestRoutes = (units, restOptions) => { + const routes = []; + for (const [unit, versions] of units) { + for (const [version, entry] of versions) { + const unitKey = version === DEFAULT_VERSION ? unit : `${unit}.${version}`; + for (const [methodName, proc] of entry.methods) { + if (!proc.http) continue; + routes.push({ unitKey, methodName, proc, http: effectiveHttp(proc.http, version, restOptions) }); + } + } + } + return routes; +}; + +module.exports = { + normalizeHttp, + normalizeRestOptions, + effectiveHttp, + buildRestTrees, + matchRestTrees, + collectRestRoutes, +}; diff --git a/src/rpc/rooms.js b/src/rpc/rooms.js new file mode 100644 index 0000000..bd7c8d4 --- /dev/null +++ b/src/rpc/rooms.js @@ -0,0 +1,587 @@ +'use strict'; + +const { jsonParse } = require('../utils.js'); +const { createLoggerWriter } = require('../logging.js'); + +// Rooms: named groups of clients, layered on top of the existing +// `{ type: 'event' }` packets — no new wire type is needed, a room broadcast +// is just the same event packet sent to several connections. +// +// The registry is the single source of truth for both directions (which +// clients a room holds, which rooms a client joined) so a disconnect only has +// to call leaveAll(client): Client itself keeps no room state that could drift. +// +// Cross-instance fan-out is optional and lives behind the backplane contract +// (see src/scaling/): RoomsBackplane publishes every non-local emit and +// replays what other instances published. Delivery is at-most-once — see +// docs/reference/protocol.md. + +const BROADCAST_CHANNEL = 'broadcast'; +const ROOM_CHANNEL_PREFIX = 'room:'; +const ENVELOPE_VERSION = 1; + +// How long a broadcast question waits for each client's answer. Matches the +// client's default call timeout: an ask is a call in the other direction. +const DEFAULT_ASK_TIMEOUT = 7_000; +const EMPTY_ASK = { answers: [], errors: [], expected: 0, incomplete: false }; + +const roomChannel = (room) => ROOM_CHANNEL_PREFIX + room; + +const EMPTY = new Set(); + +const checkRoom = (room) => { + if (typeof room !== 'string' || room.length === 0) { + throw new TypeError('Room name must be a non-empty string'); + } + return room; +}; + +class RoomRegistry { + #rooms = new Map(); // room -> Set + #joined = new Map(); // Client -> Set + #onSubscribe; + #onUnsubscribe; + #onJoin; + #onLeave; + + // onSubscribe/onUnsubscribe fire when a room gains its first member and + // loses its last one: that is exactly when a backplane has to (un)subscribe + // the room's channel. onJoin/onLeave fire on EVERY membership change: the + // cluster's presence deltas. Plain callbacks rather than events — + // join/leave is a hot path and Emitter.emit allocates a promise per call. + constructor({ onSubscribe = null, onUnsubscribe = null, onJoin = null, onLeave = null } = {}) { + this.#onSubscribe = onSubscribe; + this.#onUnsubscribe = onUnsubscribe; + this.#onJoin = onJoin; + this.#onLeave = onLeave; + } + + get size() { + return this.#rooms.size; + } + + list() { + return Array.from(this.#rooms.keys()); + } + + members(room) { + return this.#rooms.get(room) ?? EMPTY; + } + + count(room) { + return this.#rooms.get(room)?.size ?? 0; + } + + has(room) { + return this.#rooms.has(room); + } + + roomsOf(client) { + return this.#joined.get(client) ?? EMPTY; + } + + join(client, room) { + checkRoom(room); + let members = this.#rooms.get(room); + const created = !members; + if (created) { + members = new Set(); + this.#rooms.set(room, members); + } + if (members.has(client)) return false; + members.add(client); + let rooms = this.#joined.get(client); + if (!rooms) { + rooms = new Set(); + this.#joined.set(client, rooms); + } + rooms.add(room); + if (created && this.#onSubscribe) this.#onSubscribe(room); + if (this.#onJoin) this.#onJoin(room, client); + return true; + } + + leave(client, room) { + const members = this.#rooms.get(room); + if (!members || !members.delete(client)) return false; + const rooms = this.#joined.get(client); + if (rooms) { + rooms.delete(room); + if (rooms.size === 0) this.#joined.delete(client); + } + if (this.#onLeave) this.#onLeave(room, client); + if (members.size > 0) return true; + this.#rooms.delete(room); + if (this.#onUnsubscribe) this.#onUnsubscribe(room); + return true; + } + + leaveAll(client) { + const rooms = this.#joined.get(client); + if (!rooms) return; + // Copy: leave() mutates the very set being iterated. + for (const room of Array.from(rooms)) this.leave(client, room); + this.#joined.delete(client); + } + + clear() { + const rooms = this.list(); + this.#rooms.clear(); + this.#joined.clear(); + if (!this.#onUnsubscribe) return; + for (const room of rooms) this.#onUnsubscribe(room); + } +} + +// An immutable, chainable delivery target. Every modifier returns a NEW +// Broadcast, so `const room = server.to('chat')` can be kept and reused +// without later `.except()` calls leaking into it. +class Broadcast { + #registry; + #clients; + #publish; + #cluster; + #targets; + #excluded; + #localOnly; + #log; + #otel; + #codec; + + constructor({ + registry, + clients, + publish = null, + cluster = null, + log = globalThis.console, + otel = null, + codec = null, + targets = null, + excluded = null, + localOnly = false, + }) { + this.#registry = registry; + this.#clients = clients; + this.#publish = publish; + this.#cluster = cluster; + this.#log = createLoggerWriter(log); + this.#otel = otel; + this.#codec = codec; + this.#targets = targets; + this.#excluded = excluded; + this.#localOnly = localOnly; + } + + #derive(changes) { + return new Broadcast({ + registry: this.#registry, + clients: this.#clients, + publish: this.#publish, + cluster: this.#cluster, + log: this.#log, + otel: this.#otel, + codec: this.#codec, + targets: this.#targets, + excluded: this.#excluded, + localOnly: this.#localOnly, + ...changes, + }); + } + + // Narrowing to rooms is a union, not an intersection: to('a').to('b') + // reaches everyone in either room, each client exactly once. + // + // to() with no rooms narrows to NOBODY, not to everybody. `to(...list)` + // with an empty list is the dangerous case — the reading where it falls + // back to every connected client is the `WHERE id IN ()` mistake. + to(...rooms) { + for (const room of rooms) checkRoom(room); + const targets = this.#targets ? [...this.#targets, ...rooms] : [...rooms]; + return this.#derive({ targets }); + } + + except(...clients) { + if (clients.length === 0) return this; + const excluded = new Set(this.#excluded); + for (const client of clients) excluded.add(client); + return this.#derive({ excluded }); + } + + // Suppresses the backplane publish: the event stays on this instance. + // Also how replayed remote events are delivered, so they are not echoed + // back onto the backplane. + local() { + return this.#derive({ localOnly: true }); + } + + get rooms() { + return this.#targets ? [...this.#targets] : null; + } + + #recipients() { + if (!this.#targets) return this.#clients(); + if (this.#targets.length === 1) return this.#registry.members(this.#targets[0]); + const recipients = new Set(); + for (const room of this.#targets) { + for (const client of this.#registry.members(room)) recipients.add(client); + } + return recipients; + } + + /** + * Sends `{ type: 'event', name, data }` to every matching client and + * returns how many received it LOCALLY — remote instances are reached + * through the backplane, whose delivery this number says nothing about. + */ + emit(name, data) { + if (typeof name !== 'string' || name.length === 0) { + throw new TypeError('Event name must be a non-empty string'); + } + // Narrowed to no rooms at all: nobody here, nobody anywhere — so there + // is nothing to publish either. + if (this.#targets && this.#targets.length === 0) return 0; + // Serialized ONCE for the whole fan-out: a room of N used to pay + // JSON.stringify (and utf8 encoding downstream) N times for one emit. + // A payload that cannot serialize (circular) is reported, not thrown: + // the broadcaster is usually far from whoever built the value. + let text; + try { + // A configured codec is server-wide, so this stays a SINGLE encode + // for the whole fan-out — same property the JSON path has. + const packet = { type: 'event', name, data }; + text = this.#codec ? this.#codec.encode(packet) : JSON.stringify(packet); + } catch (error) { + this.#log.error({ err: error, event: 'broadcast.serialize', name }); + return 0; + } + let sent = 0; + for (const client of this.#recipients()) { + if (this.#excluded?.has(client)) continue; + // HTTP clients cannot carry events; skipping beats throwing mid-fan-out. + if (!client.persistent) continue; + try { + const flushed = client.sendRaw(text); + sent++; + // Not silently discarded any more: a recipient above its high-water + // mark is visible in the metrics, and the engine's maxBackpressure + // cap is what disconnects one that never drains. + if (flushed === false) this.#otel?.recordBackpressure(client.transportKind); + } catch (error) { + // One dead socket must not truncate the fan-out. + this.#log.error({ err: error, event: 'broadcast.send', name }); + } + } + const published = Boolean(!this.#localOnly && this.#publish); + if (published) { + this.#publish({ rooms: this.#targets, name, data }); + } + this.#otel?.recordBroadcast(name, sent, published); + return sent; + } + + /** + * Emits `name` to every matching client AND waits for each one's answer + * (the peer registers one with `client.respond(name, fn)`). Resolves + * `{ answers, errors, expected, incomplete }` — never rejects: a broadcast + * question has many answerers, so per-client failures are data, not an + * exception that discards the answers that DID arrive. + * + * With a cluster attached the question reaches every instance's members + * too; `local()` keeps it on this one. + */ + ask(name, data, options = {}) { + if (typeof name !== 'string' || name.length === 0) { + throw new TypeError('Event name must be a non-empty string'); + } + // Narrowed to no rooms at all: nobody here, nobody anywhere — the same + // short-circuit emit() has, extended to the cluster leg. Without it the + // "WHERE id IN ()" mistake comes back through the wire: an empty rooms + // array used to reach every client of every OTHER instance. + if (this.#targets && this.#targets.length === 0) { + if (options.onCount) options.onCount(0); + return Promise.resolve({ answers: [], errors: [], expected: 0, incomplete: false }); + } + const timeout = options.timeout > 0 ? options.timeout : DEFAULT_ASK_TIMEOUT; + const local = this.#askLocal(name, data, timeout); + if (options.onCount) options.onCount(local.expected); + const remote = + !this.#localOnly && this.#cluster + ? this.#cluster.broadcastAsk(this.#targets, name, data, timeout) + : Promise.resolve(EMPTY_ASK); + return Promise.all([local.done, remote]).then(([mine, theirs]) => ({ + answers: [...mine.answers, ...theirs.answers], + errors: [...mine.errors, ...theirs.errors], + expected: local.expected + theirs.expected, + incomplete: Boolean(theirs.incomplete), + })); + } + + // The local leg. The payload is serialized ONCE for the whole fan-out — + // an ack needs a per-recipient id, but that is a suffix concatenation on + // the shared prefix, not a second JSON.stringify of the data. + #askLocal(name, data, timeout) { + let prefix; + try { + // The suffix surgery below splices a per-recipient id into JSON text; + // under a codec the whole packet is encoded per recipient instead — + // a deliberate slow path, since only the codec knows its framing. + prefix = this.#codec ? null : JSON.stringify({ type: 'event', name, data }).slice(0, -1); + if (this.#codec) this.#codec.encode({ type: 'event', name, data }); // surface a circular payload NOW + } catch (error) { + this.#log.error({ err: error, event: 'broadcast.serialize', name }); + return { expected: 0, done: Promise.resolve({ answers: [], errors: [] }) }; + } + const pending = []; + if (!this.#targets || this.#targets.length > 0) { + for (const client of this.#recipients()) { + if (this.#excluded?.has(client)) continue; + if (!client.persistent) continue; + const id = client.generateId(); + try { + const frame = this.#codec + ? this.#codec.encode({ type: 'event', name, data, id }) + : `${prefix},"id":${JSON.stringify(id)}}`; + client.sendRaw(frame); + } catch (error) { + this.#log.error({ err: error, event: 'broadcast.send', name }); + continue; + } + // Registered AFTER the write went through: a send that threw never + // parks an answer slot that nothing will settle. + pending.push(client.expectAnswer(id, timeout)); + } + } + const done = Promise.allSettled(pending).then((settled) => { + const answers = []; + const errors = []; + for (const outcome of settled) { + if (outcome.status === 'fulfilled') { + answers.push(outcome.value); + continue; + } + const failure = { message: outcome.reason?.message ?? 'Ask failed', code: outcome.reason?.code ?? 500 }; + if (outcome.reason?.details !== undefined) failure.details = outcome.reason.details; + errors.push(failure); + } + return { answers, errors }; + }); + return { expected: pending.length, done }; + } +} + +// Binds a room registry to an injected backplane. +// +// Channel scheme (the socket.io Redis-adapter shape): a broadcast, or an emit +// targeting more than one room, goes to the single `broadcast` channel every +// instance subscribes to; a single-room emit goes to that room's own channel, +// which only instances holding members of it subscribe to. Either way an +// envelope reaches a given instance through exactly ONE channel, so no +// receiver-side deduplication is needed. +// How long an emptied room's channel stays subscribed (ms): the grace +// window that absorbs reconnect churn. 0 disables (`rooms: { linger: 0 }`). +const DEFAULT_LINGER = 5_000; + +class RoomsBackplane { + #backplane; + #instance; + #deliver; + #log; + #linger; + #channels = new Map(); // channel -> { count, off, stale, timer, lingerTimer } + #closed = false; + // Channels whose subscribe FAILED and is being retried: while any are + // pending, cross-instance delivery on them is dark and `healthy` is + // false — what a readiness probe should drain the node on. + #pending = 0; + + constructor({ backplane, instance, deliver, log = globalThis.console, linger = DEFAULT_LINGER }) { + this.#backplane = backplane; + this.#instance = instance; + this.#deliver = deliver; + this.#log = createLoggerWriter(log); + this.#linger = linger > 0 ? linger : 0; + } + + get healthy() { + return this.#pending === 0; + } + + // The broadcast channel is retained for the process' whole life: an + // instance with no rooms at all still has to hear server.broadcast(). + start() { + this.retain(BROADCAST_CHANNEL); + } + + retain(channel) { + if (this.#closed) return; + const entry = this.#channels.get(channel); + if (entry) { + entry.count++; + entry.stale = false; + // Re-populated inside the linger window: cancel the disposal, no + // broker round trip happened at all. + if (entry.lingerTimer) { + clearTimeout(entry.lingerTimer); + entry.lingerTimer = null; + } + return; + } + const record = { count: 1, off: null, stale: false, timer: null, lingerTimer: null }; + this.#channels.set(channel, record); + this.#subscribe(channel, record, 0); + } + + // Subscribes with capped-backoff RETRY on rejection. A rejected subscribe + // used to delete the record outright, and nothing ever re-attempted: + // while the room stayed populated the instance was permanently deaf on + // its channel — and on BROADCAST_CHANNEL, permanently deaf to every + // server.broadcast() — while looking perfectly healthy. + #subscribe(channel, record, attempt) { + const handler = (message) => this.#receive(message); + Promise.resolve() + .then(() => this.#backplane.subscribe(channel, handler)) + .then( + (off) => { + if (attempt > 0) { + this.#pending--; + this.#log.warn({ event: 'backplane.recovered', channel, attempt }); + } + record.off = typeof off === 'function' ? off : null; + // The room emptied (or the server closed) while subscribe was in + // flight — unsubscribe now that there is something to unsubscribe. + if (record.stale || this.#closed) this.#dispose(channel, record); + }, + (error) => { + this.#log.error({ err: error, event: 'backplane.subscribe', channel, attempt }); + if (record.stale || this.#closed) { + if (attempt > 0) this.#pending--; + return void this.#channels.delete(channel); + } + if (attempt === 0) this.#pending++; + const delay = Math.min(30_000, 500 * 2 ** attempt); + record.timer = setTimeout(() => { + record.timer = null; + this.#subscribe(channel, record, attempt + 1); + }, delay); + record.timer.unref?.(); + }, + ); + } + + release(channel) { + const record = this.#channels.get(channel); + if (!record) return; + record.count--; + if (record.count > 0) return; + if (!record.off) { + record.stale = true; // subscribe still in flight; dispose on arrival + return; + } + // Linger before unsubscribing: for a single-member room (the per-user + // pattern) every connect/disconnect used to be a SUBSCRIBE/UNSUBSCRIBE + // pair on the broker, and every reconnect re-opened the documented + // "published while between subscriptions" loss window. Holding the + // emptied channel for a grace period collapses that churn to nothing. + if (this.#linger > 0) { + record.lingerTimer = setTimeout(() => { + record.lingerTimer = null; + if (record.count === 0 && !this.#closed) this.#dispose(channel, record); + }, this.#linger); + if (typeof record.lingerTimer.unref === 'function') record.lingerTimer.unref(); + return; + } + this.#dispose(channel, record); + } + + #dispose(channel, record) { + this.#channels.delete(channel); + if (record.timer) { + clearTimeout(record.timer); + record.timer = null; + } + if (record.lingerTimer) { + clearTimeout(record.lingerTimer); + record.lingerTimer = null; + } + if (!record.off) return; + try { + const result = record.off(); + if (result && typeof result.catch === 'function') { + result.catch((error) => this.#log.error({ err: error, event: 'backplane.unsubscribe', channel })); + } + } catch (error) { + this.#log.error({ err: error, event: 'backplane.unsubscribe', channel }); + } + } + + joinRoom(room) { + this.retain(roomChannel(room)); + } + + leaveRoom(room) { + this.release(roomChannel(room)); + } + + publish({ rooms, name, data }) { + if (this.#closed) return; + const envelope = { v: ENVELOPE_VERSION, instance: this.#instance, rooms: rooms ?? null, name, data }; + let message = null; + try { + message = JSON.stringify(envelope); + } catch (error) { + // Non-serializable payload: local delivery already happened, so this + // is a cross-instance loss, not a lost event. + return void this.#log.error({ err: error, event: 'backplane.serialize', name }); + } + const single = rooms && rooms.length === 1; + const channel = single ? roomChannel(rooms[0]) : BROADCAST_CHANNEL; + try { + const result = this.#backplane.publish(channel, message); + if (result && typeof result.catch === 'function') { + result.catch((error) => this.#log.error({ err: error, event: 'backplane.publish', channel })); + } + } catch (error) { + // A broken backplane must never break local delivery. + this.#log.error({ err: error, event: 'backplane.publish', channel }); + } + } + + #receive(message) { + if (this.#closed) return; + const envelope = typeof message === 'string' ? jsonParse(message) : message; + if (!envelope || typeof envelope !== 'object') return; + // Echo suppression: every instance sees its own publishes. + if (envelope.instance === this.#instance) return; + const { rooms, name, data } = envelope; + if (typeof name !== 'string' || name.length === 0) return; + if (rooms !== null && rooms !== undefined && !Array.isArray(rooms)) return; + try { + this.#deliver(rooms ?? null, name, data); + } catch (error) { + this.#log.error({ err: error, event: 'backplane.deliver', name }); + } + } + + // Unsubscribes every channel this server holds. The backplane itself is + // NOT closed: it was injected, so its lifetime belongs to the caller. + close() { + if (this.#closed) return; + this.#closed = true; + for (const [channel, record] of Array.from(this.#channels)) { + if (!record.off) { + record.stale = true; + continue; + } + this.#dispose(channel, record); + } + } +} + +module.exports = { + RoomRegistry, + Broadcast, + RoomsBackplane, + roomChannel, + BROADCAST_CHANNEL, + ENVELOPE_VERSION, +}; diff --git a/src/rpc/router.js b/src/rpc/router.js new file mode 100644 index 0000000..d655d0e --- /dev/null +++ b/src/rpc/router.js @@ -0,0 +1,1025 @@ +'use strict'; + +const { Semaphore } = require('../utils.js'); +const { isTracked, tracked } = require('./subscriptions.js'); +const { + normalizeHttp, + normalizeRestOptions, + effectiveHttp, + buildRestTrees, + matchRestTrees, + collectRestRoutes, +} = require('./rest.js'); + +const DEFAULT_VERSION = '*'; +// The version token of a unit key: 'auth.v1' -> 'v1'. Closed on purpose — +// the token rides in wire targets and (with rest.version) URL paths, so it +// must stay a safe path segment. +const VERSION_TOKEN = /^v\d+$/; + +const CALL = 'call'; +const SUBSCRIPTION = 'subscription'; + +const AsyncGeneratorFunction = Object.getPrototypeOf(async function* () {}).constructor; + +// An async generator handler IS the subscription declaration: writing +// `procedure.subscription({...})` around one is allowed but never required. +const isGeneratorHandler = (handler) => handler instanceof AsyncGeneratorFunction; + +// Dispatcher maps `error.code` (number) onto the wire error code: +// 400 invalid input, 408 timeout, 500 invalid output, 503 queue overflow. +// `expose` marks the message as protocol surface: these strings are written +// for the caller, so the transport sends them verbatim even on a 5xx, where +// an ordinary error's message is replaced by the status line. +const codedError = (message, code, details) => { + const error = new Error(message); + error.code = code; + error.expose = true; + if (details !== undefined) error.details = details; + return error; +}; + +// A validator is either a plain function `(value) => value | throws` +// (returning undefined keeps the original value) or a Standard Schema +// (https://standardschema.dev) object carrying `~standard`. `context` is +// forwarded so a compiled input validator can reach what is NOT in args — +// the connection headers a schema.headers part checks. +// Synchronous when the validator is — which the injected-ajv compiled fast +// path always is: forcing it through an async wrapper cost two promise +// allocations and microtask hops per validated call AND per yielded +// subscription value (bench/validate.js). A function validator returning a +// thenable (an async custom validator) still settles through it; the +// Standard Schema branch stays async by its own contract. Callers therefore +// use try/catch (a sync validator THROWS synchronously now) and only await +// a result that is actually a thenable. +const runValidator = (validator, value, context = null) => { + if (typeof validator === 'function') { + const result = validator(value, context); + if (result && typeof result.then === 'function') { + return result.then((settled) => (settled === undefined ? value : settled)); + } + return result === undefined ? value : result; + } + return runStandardSchema(validator, value); +}; + +const runStandardSchema = async (validator, value) => { + const standard = validator['~standard']; + const result = await standard.validate(value); + if (result.issues) { + // The joined message stays the human line; the issues keep their paths + // as structured `details`, which the transport forwards on 4xx (or on + // expose) as the wire error's optional `details` field. + const issues = []; + for (const issue of result.issues) { + issues.push({ message: issue.message, path: issue.path }); + } + const error = new Error(issues.map((issue) => issue.message).join('; ')); + error.details = { issues }; + throw error; + } + return result.value; +}; + +const isValidator = (value) => + typeof value === 'function' || (typeof value === 'object' && value !== null && '~standard' in value); + +// --------------------------------------------------------------------------- +// Hooks: named lifecycle phases, fastify-style. No `next` — a hook runs, and +// either returns (letting the pipeline continue) or throws a coded error +// (ending the call with that code). "After" is a later phase, not code after +// a next() call, which is what keeps every hook a plain awaited function and +// the empty case a skipped `if`. +// +// Registration is three-level — defineRouter(units, { hooks }), a unit's +// reserved `hooks` key, and procedure({ preHandler }) — and the levels are +// FLATTENED ONCE per procedure when the router is built: dispatch walks a +// frozen array, never a chain of closures. + +// Phases that run around one invocation (call, subscribe, inbound event). +const INVOCATION_PHASES = [ + 'onRequest', // packet accepted, before session restore and access + 'preValidation', // after access, before input validation + 'preHandler', // after input validation, before the handler + 'preSerialization', // after the handler, before output validation + 'onSend', // before the callback packet is written; may mutate it + 'onResponse', // after the write; observational + 'onError', // any failure; observational + 'onTimeout', // a 408 specifically; observational, fires before onError + 'onSubscribe', // after access, before a subscription starts + 'onUnsubscribe', // after a subscription ended, whatever ended it +]; +// Phases that run around a connection's lifetime (router-level only). +const CONNECTION_PHASES = ['onConnect', 'onDisconnect']; +const ROUTER_PHASES = [...INVOCATION_PHASES, ...CONNECTION_PHASES]; +// The subset a single procedure can carry in its own options. +const PROCEDURE_PHASES = ['preValidation', 'preHandler', 'preSerialization', 'onError']; + +const toHookList = (value, phase, label) => { + if (value === undefined || value === null) return []; + const list = Array.isArray(value) ? value : [value]; + for (const fn of list) { + if (typeof fn !== 'function') { + throw new TypeError(`${label}: hooks.${phase} must be a function or an array of functions`); + } + } + return list; +}; + +// Unknown phase names throw: a typo'd hook that silently never runs is an +// authorization check that silently never runs. +const normalizeHooks = (hooks, allowed, label) => { + if (hooks === undefined || hooks === null) hooks = {}; + if (typeof hooks !== 'object') throw new TypeError(`${label}: hooks must be an object of phase handlers`); + for (const key of Object.keys(hooks)) { + if (!allowed.includes(key)) throw new TypeError(`${label}: unknown hook phase '${key}'`); + } + const result = {}; + for (const phase of allowed) result[phase] = toHookList(hooks[phase], phase, label); + return result; +}; + +const EMPTY_LIST = Object.freeze([]); +const EMPTY_HOOKS = Object.freeze(Object.fromEntries(INVOCATION_PHASES.map((phase) => [phase, EMPTY_LIST]))); + +/** Runs one phase in order; a throw ends the call with the error's code. */ +const runHooks = async (hooks, context, payload) => { + for (const hook of hooks) await hook(context, payload); +}; + +// The observational phases must never break what they observe: a throwing +// onResponse/onError/onDisconnect is reported to the log and contained. +const runHooksSafe = async (hooks, context, payload, log, phase) => { + for (const hook of hooks) { + try { + await hook(context, payload); + } catch (error) { + log?.warn?.({ event: 'hook.error', phase, err: error }, `HOOK\t${phase}\t${error?.stack ?? error}`); + } + } +}; + +// Unit and method names come from user definitions: a '__proto__' key must +// become an own property instead of mutating the result's prototype, which +// a plain assignment would do. +const assignKey = (target, key, value) => { + Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true }); +}; + +// --------------------------------------------------------------------------- +// Declarative REST mapping and the fastify-shaped schema object. +// +// `http: { method, path, status? }` maps a procedure onto a real HTTP +// endpoint under the server's basePath; args then arrive structured as +// { params, query, body }, over EVERY transport — the mapping only defines +// how an HTTP request is unpacked, a ws caller passes the same shape itself. +// +// `schema` follows fastify.route.schema: `params`, `querystring` (or its +// alias `query` — interchangeable, normalized here), `body`, `headers`, +// `response` keyed by status code, plus any passthrough keys (tags, +// summary, security, ...) that wrpc never interprets but forwards to hosts +// (fastify/swagger) verbatim. + +// The parts wrpc itself understands; everything else on `schema` passes +// through untouched. `query` and `querystring` are interchangeable spellings +// of the same part — normalized to `querystring` (fastify's canon). +const SCHEMA_PARTS = ['params', 'querystring', 'body', 'headers', 'response']; + +const normalizeSchema = (schema, label) => { + if (schema === null || schema === undefined) return null; + if (typeof schema !== 'object' || Array.isArray(schema)) { + throw new TypeError(`${label} schema must be an object (fastify route schema shape)`); + } + if (schema.query !== undefined && schema.querystring !== undefined && schema.query !== schema.querystring) { + throw new TypeError(`${label} schema has both 'query' and 'querystring' and they differ — use one`); + } + const normalized = {}; + for (const key of Object.keys(schema)) { + if (key === 'query') continue; // folded into querystring below + assignKey(normalized, key, schema[key]); + } + if (schema.query !== undefined && schema.querystring === undefined) normalized.querystring = schema.query; + for (const part of SCHEMA_PARTS) { + const value = normalized[part]; + if (value !== undefined && (typeof value !== 'object' || value === null)) { + throw new TypeError(`${label} schema.${part} must be an object`); + } + } + return normalized; +}; + +const timeoutRace = (promise, ms) => { + let timer = null; + const failure = new Promise((resolve, reject) => { + timer = setTimeout(() => { + reject(codedError('Procedure timeout', 408)); + }, ms); + }); + return Promise.race([promise, failure]).finally(() => clearTimeout(timer)); +}; + +class Procedure { + constructor(options = {}) { + const { + handler, + access = 'session', + input = null, + output = null, + timeout = 0, + queue = null, + meta = {}, + signature = null, + kind = null, + http = null, + schema = null, + preValidation = null, + preHandler = null, + preSerialization = null, + onError = null, + } = options; + if (typeof handler !== 'function') { + throw new TypeError('procedure() requires a handler function'); + } + // Anything but the two known levels used to silently mean 'session' — + // an access model that LOOKS custom but is not is an auth bug waiting. + // Custom policies are hooks' job (preValidation reads proc.meta). + if (access !== 'public' && access !== 'session') { + throw new TypeError(`procedure() access must be 'public' or 'session', got '${access}'`); + } + if (input !== null && !isValidator(input)) { + throw new TypeError('procedure() input must be a function or a Standard Schema'); + } + if (output !== null && !isValidator(output)) { + throw new TypeError('procedure() output must be a function or a Standard Schema'); + } + this.kind = kind ?? (isGeneratorHandler(handler) ? SUBSCRIPTION : CALL); + if (this.kind !== CALL && this.kind !== SUBSCRIPTION) { + throw new TypeError(`procedure() kind must be '${CALL}' or '${SUBSCRIPTION}'`); + } + // The procedure's own slice of the pipeline; router and unit levels are + // merged in front of these when the router is built. + this.hooks = normalizeHooks( + { preValidation, preHandler, preSerialization, onError }, + PROCEDURE_PHASES, + 'procedure()', + ); + this.handler = handler; + this.access = access; + this.input = input; + this.output = output; + this.timeout = timeout; + this.meta = meta; + this.signature = signature; + this.http = normalizeHttp(http); + this.schema = normalizeSchema(schema, 'procedure()'); + // One declarative source of truth per direction: a schema beside a + // programmatic validator would leave "which one ran?" ambiguous. + if (this.schema && (input || output)) { + throw new TypeError('procedure() schema and input/output are mutually exclusive'); + } + if (queue && !(Number.isInteger(queue.concurrency) && queue.concurrency > 0)) { + throw new TypeError('procedure() queue.concurrency must be a positive integer'); + } + // A subscription lives until it is cancelled, so both of these would + // mean something different from what they mean for a call — and quietly + // meaning something else is worse than refusing. + if (this.kind === SUBSCRIPTION && (queue || timeout)) { + throw new TypeError('procedure.subscription() does not support queue or timeout'); + } + // A subscription is already refused on plain HTTP (400), so an HTTP + // route mapping could never mean anything for one. + if (this.kind === SUBSCRIPTION && this.http) { + throw new TypeError('procedure.subscription() does not support http'); + } + this.semaphore = queue ? new Semaphore(queue) : null; + } + + get subscription() { + return this.kind === SUBSCRIPTION; + } + + /** + * The value stream behind `{type:'subscribe'}`. Input validation and the + * per-value output validation happen here, so a subscription gets the same + * guarantees a call does; `lastEventId` and `signal` reach the handler as + * its third argument. + */ + async *subscribe(context, args, options = {}, hooks = EMPTY_HOOKS, compiled = null) { + if (this.kind !== SUBSCRIPTION) { + throw codedError('Not a subscription', 400); + } + const inputValidator = compiled?.input ?? this.input; + const outputValidator = compiled?.output ?? this.output; + if (hooks.preValidation.length > 0) await runHooks(hooks.preValidation, context, args); + let input = args; + if (inputValidator) { + try { + const checked = runValidator(inputValidator, args, context); + input = checked && typeof checked.then === 'function' ? await checked : checked; + } catch (error) { + throw codedError(`Invalid arguments: ${error.message}`, 400, error.details); + } + } + if (hooks.preHandler.length > 0) await runHooks(hooks.preHandler, context, input); + const source = this.handler(context, input, options); + if (!source || typeof source[Symbol.asyncIterator] !== 'function') { + throw codedError('Subscription handler must return an async iterable', 500); + } + for await (const value of source) { + if (!outputValidator) { + yield value; + continue; + } + // Validate the payload, not the tracking wrapper: an output schema + // describes what the client receives, not how it is labelled. + const payload = isTracked(value) ? value.data : value; + let checked; + try { + checked = runValidator(outputValidator, payload); + if (checked && typeof checked.then === 'function') checked = await checked; + } catch (error) { + throw codedError(`Invalid subscription value: ${error.message}`, 500, error.details); + } + yield isTracked(value) ? tracked(value.id, checked) : checked; + } + } + + // The host-delegated entry (the fastify adapter's native routes): the + // semaphore and the deadline behave EXACTLY as in invoke() — the slot is + // held until the handler settles — but hooks, access and validators are + // the host's job, already run by the time this is called. + async invokeBare(context, args) { + if (this.kind === SUBSCRIPTION) { + throw codedError('This procedure is a subscription: use {type:"subscribe"}', 400); + } + const deadline = this.timeout > 0 ? Date.now() + this.timeout : 0; + if (this.semaphore) { + try { + await this.semaphore.enter(context.signal ?? null); + } catch (error) { + throw codedError(error.message, 503); + } + } + let handlerStarted = false; + try { + if (deadline > 0 && Date.now() >= deadline) { + throw codedError('Procedure timeout', 408); + } + handlerStarted = true; + const invocation = Promise.resolve().then(() => this.handler(context, args)); + if (this.semaphore) { + const release = () => this.semaphore.leave(); + invocation.then(release, release); + } + return deadline > 0 ? await timeoutRace(invocation, Math.max(1, deadline - Date.now())) : await invocation; + } finally { + if (this.semaphore && !handlerStarted) this.semaphore.leave(); + } + } + + async invoke(context, args, hooks = EMPTY_HOOKS, compiled = null, budget = 0) { + if (this.kind === SUBSCRIPTION) { + throw codedError('This procedure is a subscription: use {type:"subscribe"}', 400); + } + // The procedure's deadline starts NOW, queue wait included: a caller + // that waited 900 ms of a 1000 ms timeout in the queue has 100 ms of + // handler budget left, not a fresh 1000 — overload must not do work for + // callers that already gave up. + // `budget` is the CALLER's per-call deadline (the packet's optional + // `timeout` field — CallOptions.timeout, the gRPC-deadline shape). It + // can only SHORTEN the procedure's own limit, never widen it: the + // procedure's timeout is the server's protection, the caller's budget + // is a courtesy — no point doing work whose answer nobody awaits. + const cap = this.timeout > 0 ? this.timeout : 0; + const limit = budget > 0 && (cap === 0 || budget < cap) ? budget : cap; + const deadline = limit > 0 ? Date.now() + limit : 0; + if (this.semaphore) { + try { + // Abort-aware: a queued waiter whose caller cancelled or + // disconnected leaves the queue instead of taking a slot later. + await this.semaphore.enter(context.signal ?? null); + } catch (error) { + throw codedError(error.message, 503); + } + } + if (deadline > 0 && Date.now() >= deadline) { + throw codedError('Procedure timeout', 408); + } + let handlerStarted = false; + try { + const inputValidator = compiled?.input ?? this.input; + const outputValidator = compiled?.output ?? this.output; + if (hooks.preValidation.length > 0) await runHooks(hooks.preValidation, context, args); + if (inputValidator) { + try { + const checked = runValidator(inputValidator, args, context); + args = checked && typeof checked.then === 'function' ? await checked : checked; + } catch (error) { + throw codedError(`Invalid arguments: ${error.message}`, 400, error.details); + } + } + if (hooks.preHandler.length > 0) await runHooks(hooks.preHandler, context, args); + handlerStarted = true; + const invocation = Promise.resolve().then(() => this.handler(context, args)); + if (this.semaphore) { + // The queue slot is held until the HANDLER settles: a timeout + // rejects the caller but cannot cancel the handler, and freeing + // the slot early would break the concurrency guarantee. + const release = () => this.semaphore.leave(); + invocation.then(release, release); + } + let result = deadline > 0 ? await timeoutRace(invocation, Math.max(1, deadline - Date.now())) : await invocation; + // A preSerialization hook that returns something replaces the result; + // one that returns undefined leaves it alone. Runs BEFORE the output + // validator, so what the hook shaped is what the schema checks. + for (const hook of hooks.preSerialization) { + const replaced = await hook(context, result); + if (replaced !== undefined) result = replaced; + } + if (outputValidator) { + try { + const checked = runValidator(outputValidator, result); + result = checked && typeof checked.then === 'function' ? await checked : checked; + } catch (error) { + throw codedError(`Invalid procedure result: ${error.message}`, 500, error.details); + } + } + return result; + } finally { + // The handler never ran (validation failed) — free the slot here + if (this.semaphore && !handlerStarted) this.semaphore.leave(); + } + } +} + +const procedure = (options) => { + if (typeof options === 'function') return new Procedure({ handler: options }); + return new Procedure(options); +}; + +// Explicit spelling for a subscription. Redundant when the handler is an +// async generator (which is detected), required when it is a plain function +// returning an async iterable. +procedure.subscription = (options) => { + if (typeof options === 'function') return new Procedure({ handler: options, kind: SUBSCRIPTION }); + return new Procedure({ ...options, kind: SUBSCRIPTION }); +}; + +const toProcedure = (value, unitKey, methodName) => { + if (value instanceof Procedure) return value; + if (typeof value === 'function') return new Procedure({ handler: value }); + if (typeof value === 'object' && value !== null && typeof value.handler === 'function') { + return new Procedure(value); + } + throw new TypeError( + `Router definition ${unitKey}/${methodName} must be a procedure, a handler function, or an options object`, + ); +}; + +// `on` is reserved inside a unit definition: it declares the unit's inbound +// (client -> server) event handlers rather than a method named 'on'. +// `hooks` is reserved too: the unit's slice of the lifecycle pipeline. +const EVENTS_KEY = 'on'; +const HOOKS_KEY = 'hooks'; +// Declaration-only: the unit's OUTBOUND (server -> client) events, as +// `signature`-style descriptors ({ data, returns? }). No runtime behavior — +// the map travels through introspection so `wrpc types` can generate the +// contract's `events` key, the same way `signature` types calls. +const EMITS_KEY = 'emits'; + +// unit-level hooks may not carry the connection phases: a connection is not +// scoped to a unit, so an onConnect there could never mean anything. +const concatHooks = (base, extra, phases) => { + const result = {}; + for (const phase of phases) result[phase] = [...(base?.[phase] ?? []), ...(extra?.[phase] ?? [])]; + return result; +}; + +// --------------------------------------------------------------------------- +// Injected JSON Schema compilers. Structural, per the zero-dependency rule: +// `ajv` is anything with compile(schema) -> validateFn (ajv-shaped: the fn +// answers a boolean and exposes `.errors`), `serializer` anything with +// compile(schema) -> (value) -> string (fast-json-stringify-shaped). wrpc +// never imports either — the application passes its own. + +const normalizeValidation = (validation) => { + if (validation === null || validation === undefined) return null; + if (typeof validation !== 'object') { + throw new TypeError('defineRouter: options.validation must be an object'); + } + const { ajv = null, serializer = null } = validation; + if (ajv !== null && typeof ajv.compile !== 'function') { + throw new TypeError('defineRouter: validation.ajv must provide compile(schema)'); + } + if (serializer !== null && typeof serializer.compile !== 'function') { + throw new TypeError('defineRouter: validation.serializer must provide compile(schema)'); + } + if (!ajv && !serializer) return null; + return { ajv, serializer }; +}; + +// The schema parts and the args keys they validate. +const INPUT_PARTS = [ + ['params', 'params'], + ['querystring', 'query'], + ['body', 'body'], +]; + +// Compiles the input parts into ONE function validator, fastify-style: each +// part validates its slice of `{ params, query, body }`, issues carry +// part-prefixed paths, and ajv's own coercions land in place. The wrapper is +// an ordinary function validator, so `runValidator` stays the single +// execution seam. +const compileInput = (ajv, schema, label) => { + const parts = []; + for (const [part, argsKey] of INPUT_PARTS) { + if (schema[part] === undefined) continue; + let validate; + try { + validate = ajv.compile(schema[part]); + } catch (error) { + throw new TypeError(`${label}: schema.${part} failed to compile: ${error.message}`); + } + parts.push({ part, argsKey, validate }); + } + // The asymmetric part: headers are not an args slice — they live on + // context.meta.headers — so they get their own check instead of a row in + // INPUT_PARTS, and the closure takes the context runValidator forwards. + let headersValidate = null; + if (schema.headers !== undefined) { + try { + headersValidate = ajv.compile(schema.headers); + } catch (error) { + throw new TypeError(`${label}: schema.headers failed to compile: ${error.message}`); + } + } + if (parts.length === 0 && !headersValidate) return null; + return (args, context) => { + const value = args && typeof args === 'object' ? args : {}; + let issues = null; + for (const { part, argsKey, validate } of parts) { + if (validate(value[argsKey] ?? {})) continue; + issues ??= []; + for (const item of validate.errors ?? []) { + issues.push({ message: item.message, path: `/${part}${item.instancePath ?? ''}` }); + } + } + if (headersValidate) { + // A COPY on purpose: client.meta.headers is frozen, and an injected + // ajv compiled with coerceTypes would otherwise throw writing the + // coerced value back (compiled validators are strict-mode code). The + // copy also keeps the snapshot honest — validation observes headers, + // it never rewrites them. + if (!headersValidate({ ...(context?.meta?.headers ?? {}) })) { + issues ??= []; + for (const item of headersValidate.errors ?? []) { + issues.push({ message: item.message, path: `/headers${item.instancePath ?? ''}` }); + } + } + } + if (issues) { + const messages = []; + for (const issue of issues) messages.push(`${issue.path} ${issue.message}`); + const error = new Error(messages.join('; ')); + error.details = { issues }; + throw error; + } + return value; + }; +}; + +// The success-response schema: the mapped status first, then any 2xx entry. +const successResponse = (proc) => { + const response = proc.schema?.response; + if (!response || typeof response !== 'object') return null; + const preferred = proc.http?.status ?? 200; + if (response[preferred] && response[preferred] !== false) return response[preferred]; + for (const key of Object.keys(response)) { + const code = Number(key); + if (code >= 200 && code < 300 && response[key] !== false) return response[key]; + } + return null; +}; + +const compileOutput = (ajv, schema, label) => { + let validate; + try { + validate = ajv.compile(schema); + } catch (error) { + throw new TypeError(`${label}: schema.response failed to compile: ${error.message}`); + } + return (result) => { + if (validate(result)) return result; + const issues = []; + for (const item of validate.errors ?? []) { + issues.push({ message: item.message, path: item.instancePath }); + } + const error = new Error(issues.map((issue) => `${issue.path} ${issue.message}`).join('; ')); + error.details = { issues }; + throw error; + }; +}; + +class Router { + // unit -> Map(version -> { methods: Map(name -> Procedure), + // events: Map(name -> Procedure), + // hooks: { phase: [fns] } }) + #units = new Map(); + #hooks; + // Procedure -> frozen { phase: frozen [fns] }, the flattened pipeline the + // dispatcher walks. Kept on the ROUTER, not the procedure: the same + // Procedure instance may be registered in several routers (merge reuses + // them), each with different router-level hooks. + #chains = new Map(); + // The declarative REST table: a segment trie over every procedure's + // `http` mapping. Per-router like #chains (merge reuses Procedures), and + // rebuilt whenever the unit set changes. `null` until a mapping exists, + // so routers without REST pay one null check. + #rest = null; + // Injected compilers (ajv-shaped, fast-json-stringify-shaped) and the + // per-router artifacts they produce: Procedure -> { input?, output?, + // serialize? }. Per-router like #chains — merge() shares Procedure + // instances, and two routers may carry different compilers. + #validation = null; + #compiled = new Map(); + // The rest.version strategy; `#rest` is taken by the trie above. + #restOptions = null; + + constructor(definition = {}, options = {}) { + this.#hooks = normalizeHooks(options.hooks, ROUTER_PHASES, 'defineRouter'); + this.#validation = normalizeValidation(options.validation); + this.#restOptions = normalizeRestOptions(options.rest); + for (const [unitKey, methods] of Object.entries(definition)) { + this.#addUnit(unitKey, methods); + } + this.#rebuildChains(); + } + + /** + * Adds a unit after construction — how the fastify adapter's mirror + * feature lands units discovered at onReady, when the router already + * exists. Refuses a unit key that is already registered (merge() is the + * tool for combining routers). Returns the router. + */ + addUnit(unitKey, definition) { + const [unit, version = DEFAULT_VERSION] = String(unitKey).split('.'); + if (this.#units.get(unit)?.has(version)) { + throw new TypeError(`addUnit: unit '${unitKey}' is already registered`); + } + this.#addUnit(unitKey, definition); + this.#rebuildChains(); + return this; + } + + /** Adds a router-level hook after construction. Returns the router. */ + addHook(name, fn) { + if (!ROUTER_PHASES.includes(name)) { + throw new TypeError(`addHook: unknown hook phase '${name}'`); + } + if (typeof fn !== 'function') throw new TypeError('addHook: the hook must be a function'); + this.#hooks[name] = [...this.#hooks[name], fn]; + this.#rebuildChains(); + return this; + } + + /** The flattened pipeline for one procedure (router + unit + procedure). */ + hooksFor(proc) { + return this.#chains.get(proc) ?? EMPTY_HOOKS; + } + + /** The compiled { input?, output?, serialize? } for one procedure. */ + compiledFor(proc) { + return this.#compiled.get(proc) ?? null; + } + + /** True when any procedure compiled a response serializer. */ + get hasSerializers() { + for (const artifacts of this.#compiled.values()) { + if (artifacts.serialize) return true; + } + return false; + } + + /** Router-level connection lifecycle hooks, consumed by RpcServer. */ + get connectionHooks() { + return { onConnect: this.#hooks.onConnect, onDisconnect: this.#hooks.onDisconnect }; + } + + #rebuildChains() { + this.#chains = new Map(); + for (const versions of this.#units.values()) { + for (const entry of versions.values()) { + for (const proc of entry.methods.values()) this.#chains.set(proc, this.#chainFor(entry, proc)); + for (const proc of entry.events.values()) this.#chains.set(proc, this.#chainFor(entry, proc)); + } + } + this.#rebuildRest(); + this.#rebuildCompiled(); + } + + // Schemas compile ONCE, when the router is built. A procedure whose + // schema declares validation parts in a router with no injected ajv is a + // build error: the declaration would otherwise be silently unenforced, + // and an unenforced schema is an authorization bug waiting to be found. + #rebuildCompiled() { + this.#compiled = new Map(); + for (const [unit, versions] of this.#units) { + for (const [version, entry] of versions) { + const unitKey = version === DEFAULT_VERSION ? unit : `${unit}.${version}`; + for (const [methodName, proc] of entry.methods) { + const artifacts = this.#compileFor(proc, `${unitKey}/${methodName}`); + if (artifacts) this.#compiled.set(proc, artifacts); + } + for (const [eventName, proc] of entry.events) { + const artifacts = this.#compileFor(proc, `${unitKey}/on.${eventName}`); + if (artifacts) this.#compiled.set(proc, artifacts); + } + } + } + } + + #compileFor(proc, label) { + const schema = proc.schema; + if (!schema) return null; + const hasInput = + schema.params !== undefined || + schema.querystring !== undefined || + schema.body !== undefined || + schema.headers !== undefined; + const success = successResponse(proc); + if (!hasInput && !success) return null; // passthrough-only schema (tags, ...) + const ajv = this.#validation?.ajv ?? null; + if (!ajv) { + throw new TypeError( + `Router: ${label} declares schema validation but no validation.ajv compiler was injected — ` + + 'pass defineRouter(units, { validation: { ajv } })', + ); + } + const artifacts = {}; + const input = hasInput ? compileInput(ajv, schema, label) : null; + if (input) artifacts.input = input; + if (success) { + artifacts.output = compileOutput(ajv, success, label); + const serializer = this.#validation?.serializer; + if (serializer) { + try { + artifacts.serialize = serializer.compile(success); + } catch (error) { + throw new TypeError(`Router: ${label} schema.response failed to compile a serializer: ${error.message}`); + } + } + } + return artifacts; + } + + // The trie — one tree per HTTP verb, the find-my-way shape, so + // `POST /projects/:orgId` and `GET /projects/:id` coexist the way they + // do in fastify. A node is { static: Map(segment -> node), + // param: { name, node } | null, terminal: route | null }. Static beats + // param on match; within one verb a duplicate path or two different + // param names at one position are build-time conflicts, reported with + // both procedures' addresses. + #rebuildRest() { + this.#rest = buildRestTrees(this.#units, this.#restOptions); + } + + #chainFor(entry, proc) { + const chain = {}; + for (const phase of INVOCATION_PHASES) { + const merged = [...this.#hooks[phase], ...(entry.hooks?.[phase] ?? []), ...(proc.hooks[phase] ?? [])]; + chain[phase] = merged.length > 0 ? Object.freeze(merged) : EMPTY_LIST; + } + return Object.freeze(chain); + } + + #addUnit(unitKey, definition) { + const [unit, rawVersion, ...extra] = unitKey.split('.'); + // A silent split would truncate 'unit.v1.2' into unit.v1 and merge + // colliding registrations — reject anything but 'unit' / 'unit.vN'. + // The vN pattern is the whole version syntax: 'auth.1' is invalid. + if ( + !unit || + extra.length > 0 || + (rawVersion !== undefined && !VERSION_TOKEN.test(rawVersion)) || + typeof definition !== 'object' || + definition === null + ) { + throw new TypeError(`Invalid router unit definition: ${unitKey} (a unit key is 'unit' or 'unit.vN')`); + } + const version = rawVersion ?? DEFAULT_VERSION; + let versions = this.#units.get(unit); + if (!versions) { + versions = new Map(); + this.#units.set(unit, versions); + } + let entry = versions.get(version); + if (!entry) { + entry = { methods: new Map(), events: new Map(), emits: null, hooks: null }; + versions.set(version, entry); + } + for (const [name, value] of Object.entries(definition)) { + if (name === EVENTS_KEY) { + this.#addEvents(unitKey, entry.events, value); + continue; + } + if (name === EMITS_KEY) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`Router definition ${unitKey}.emits must be an object of event descriptors`); + } + entry.emits = entry.emits ? { ...entry.emits, ...value } : { ...value }; + continue; + } + if (name === HOOKS_KEY) { + const unitHooks = normalizeHooks(value, INVOCATION_PHASES, `${unitKey}.hooks`); + entry.hooks = entry.hooks ? concatHooks(entry.hooks, unitHooks, INVOCATION_PHASES) : unitHooks; + continue; + } + entry.methods.set(name, toProcedure(value, unitKey, name)); + } + } + + // Event handlers reuse Procedure: an inbound event is a call that never + // answers, so access control, input validation and queueing come for free. + #addEvents(unitKey, events, definition) { + if (typeof definition !== 'object' || definition === null) { + throw new TypeError(`Router definition ${unitKey}.on must be an object of event handlers`); + } + for (const [name, value] of Object.entries(definition)) { + events.set(name, toProcedure(value, unitKey, `on.${name}`)); + } + } + + getProcedure(unit, version = DEFAULT_VERSION, method) { + const entry = this.#units.get(unit)?.get(version); + return entry?.methods.get(method) ?? null; + } + + getEventHandler(unit, version = DEFAULT_VERSION, name) { + const entry = this.#units.get(unit)?.get(version); + return entry?.events.get(name) ?? null; + } + + /** True when at least one procedure declares an `http` mapping. */ + get hasRestRoutes() { + return this.#rest !== null; + } + + /** See matchRestTrees in rest.js — the trie set is per-router state. */ + matchRest(method, segments) { + return matchRestTrees(this.#rest, method, segments); + } + + /** Every declared REST route — what a host adapter registers natively. */ + restRoutes() { + return collectRestRoutes(this.#units, this.#restOptions); + } + + // Introspection v2: { unitKey: { method: { access, meta?, signature? } } } + // where unitKey is 'unit' for the default version and 'unit.vN' otherwise. + // Anything but an array of unit keys means "no filter" — REST calls + // deliver plain-object args here. + introspect(units = null, options = {}) { + const filter = Array.isArray(units) ? units : null; + // The input schema parts travel by default: they are what a client's + // injected ajv pre-validates against, saving the round trip a doomed + // call would make. `schemas: false` strips them (they can be sizable). + const { schemas = true } = options; + const result = {}; + for (const [unit, versions] of this.#units) { + for (const [version, entry] of versions) { + const unitKey = version === DEFAULT_VERSION ? unit : `${unit}.${version}`; + if (filter && !filter.includes(unitKey)) continue; + const methodsInfo = {}; + for (const [methodName, proc] of entry.methods) { + const info = { access: proc.access }; + // Only subscriptions carry `kind`: a client scaffolds a call + // unless told otherwise, so the common case stays one key. + if (proc.subscription) info.kind = proc.kind; + if (Object.keys(proc.meta).length > 0) info.meta = proc.meta; + if (proc.signature) info.signature = proc.signature; + // The REST mapping travels so clients (and codegen/OpenAPI + // tooling) can address the same procedure as a plain endpoint — + // effective, so a client's REST leg calls the versioned URL. + if (proc.http) info.http = effectiveHttp(proc.http, version, this.#restOptions); + if (schemas && proc.schema) { + const parts = {}; + if (proc.schema.params !== undefined) parts.params = proc.schema.params; + if (proc.schema.querystring !== undefined) parts.querystring = proc.schema.querystring; + if (proc.schema.body !== undefined) parts.body = proc.schema.body; + if (Object.keys(parts).length > 0) info.schema = parts; + } + assignKey(methodsInfo, methodName, info); + } + // The unit's INBOUND event handlers, under the same reserved 'on' + // key the definition used — additive, and impossible to collide + // with a method ('on' routes to #addEvents at definition time). + // What `wrpc types` turns into the contract's `sends` key. + if (entry.events.size > 0) { + const inbound = {}; + for (const [eventName, proc] of entry.events) { + const eventInfo = { access: proc.access }; + if (proc.signature) eventInfo.signature = proc.signature; + assignKey(inbound, eventName, eventInfo); + } + assignKey(methodsInfo, EVENTS_KEY, inbound); + } + // The declared OUTBOUND events, verbatim — the contract's `events`. + if (entry.emits) assignKey(methodsInfo, EMITS_KEY, entry.emits); + assignKey(result, unitKey, methodsInfo); + } + } + return result; + } + + // Returns a NEW router; on collision the other router's procedure wins. + // Hooks travel too: router-level hooks concatenate (this first), unit + // hooks ride with their unit, and the chains are rebuilt for the merged + // set — the shared Procedure instances themselves are never mutated. + merge(other) { + const hooks = concatHooks(this.#hooks, other.#hooks, ROUTER_PHASES); + // This router's compilers win, the other's fill in — mirroring the + // hook order (this first). Procedures recompile against the winner. + const validation = + this.#validation || other.#validation + ? { + ajv: this.#validation?.ajv ?? other.#validation?.ajv, + serializer: this.#validation?.serializer ?? other.#validation?.serializer, + } + : undefined; + // The rest strategy must survive a merge (receiver wins, like + // validation): #withIntrospection merges a system router into EVERY + // server's router by default, and dropping it there would silently + // unprefix every versioned route. + const merged = new Router({}, { hooks, validation, rest: this.#restOptions ?? other.#restOptions ?? undefined }); + for (const source of [this, other]) { + for (const [unit, versions] of source.#units) { + for (const [version, entry] of versions) { + const unitKey = version === DEFAULT_VERSION ? unit : `${unit}.${version}`; + const definition = Object.fromEntries(entry.methods); + if (entry.events.size > 0) definition[EVENTS_KEY] = Object.fromEntries(entry.events); + if (entry.emits) definition[EMITS_KEY] = entry.emits; + if (entry.hooks) definition[HOOKS_KEY] = entry.hooks; + merged.#addUnit(unitKey, definition); + } + } + } + merged.#rebuildChains(); + return merged; + } +} + +// --------------------------------------------------------------------------- +// The effective fastify-shaped schema a host (fastify routes, swagger, +// introspection consumers) receives: the user's declaration with wrpc's own +// lifecycle error statuses documented underneath. Which statuses apply is +// derived from the procedure's options; docs/reference/errors.md is the +// dictionary. 499 (cancel) is deliberately absent — a cancelled HTTP +// request gets no response to document. A user's schema.response entry for +// the same code always overrides the default, and `false` removes it. + +const WIRE_ERROR_SCHEMA = Object.freeze({ + type: 'object', + properties: { + message: { type: 'string' }, + code: { type: 'number' }, + details: {}, + }, + required: ['message', 'code'], +}); + +const defaultErrorResponses = (proc) => { + const codes = [429, 500, 503]; + if (proc.schema || proc.input) codes.push(400); + if (proc.access !== 'public') codes.push(403); + if (proc.timeout > 0) codes.push(408); + const responses = {}; + for (const code of codes) responses[code] = WIRE_ERROR_SCHEMA; + return responses; +}; + +const effectiveSchema = (proc) => { + const response = defaultErrorResponses(proc); + const declared = proc.schema?.response ?? {}; + for (const key of Object.keys(declared)) { + if (declared[key] === false) delete response[key]; + else assignKey(response, key, declared[key]); + } + const schema = { ...(proc.schema ?? {}) }; + schema.response = response; + return schema; +}; + +const defineRouter = (definition, options) => new Router(definition, options); + +module.exports = { + Procedure, + Router, + procedure, + defineRouter, + effectiveSchema, + runHooks, + runHooksSafe, + EMPTY_HOOKS, + INVOCATION_PHASES, + CONNECTION_PHASES, +}; diff --git a/src/rpc/sessions.js b/src/rpc/sessions.js new file mode 100644 index 0000000..6085049 --- /dev/null +++ b/src/rpc/sessions.js @@ -0,0 +1,258 @@ +'use strict'; + +const { generateUUID } = require('../runtime/node.js'); +const { createLoggerWriter } = require('../logging.js'); +const { parseCookies } = require('../transport.js'); + +// TokenTransport — where the session token lives on the wire, injected like +// a codec or a logger and checked structurally: +// read(request) -> string | null the token this request presents +// request = { headers, url, declared?, meta? } — besides the raw +// headers/url, the core passes what it already parsed: `declared` (the +// merged declared+observed header bag, ws wrpc_h included, capped on +// the configurable metaMaxBytes) and `meta` (the sanitized +// connection-metadata bag, both x-wrpc-meta spellings merged, +// kebab-keyed). Prefer them: a strategy that re-parses the wire can +// drift from the core parser. Both are absent on the SSE channel-key +// path, so keep a raw-header fallback for the names you read. +// write(token) -> string | null a Set-Cookie-style response +// header value, when the carrier +// can stamp one (HTTP only; a +// bearer carrier returns null and +// the signIn handler hands tokens +// back in its result instead) +// ambient?: boolean true when the BROWSER attaches +// the credential without script +// (a cookie) — which is exactly +// what the safe-method CSRF rule +// exists for; a non-ambient +// carrier is exempt from it +// clear() -> string | null optional; the deleting stamp +const isTokenTransport = (value) => + Boolean(value) && typeof value.read === 'function' && typeof value.write === 'function'; + +// The default: the cookie behaviour wrpc always had, expressed as one +// injectable object so a bearer or payload strategy is a peer, not a fork. +const cookieTokenTransport = (manager) => ({ + ambient: true, + read: ({ headers }) => { + const cookie = headers?.cookie; + if (!cookie) return null; + return manager.readToken(parseCookies(cookie)); + }, + write: (token) => manager.cookieHeader(token), + clear: () => manager.cookieDeleteHeader(), +}); + +const createProxy = (data, save) => + new Proxy(data, { + get: (target, key) => { + const value = Reflect.get(target, key); + return value; + }, + set: (target, key, value) => { + // defineProperty rather than assignment: session state can carry keys + // that came off the wire, and a plain `state['__proto__'] = x` would + // swap the state object's prototype instead of storing a value — the + // same rule router.js applies to unit and method names (assignKey). + Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true }); + if (save) save(target); + return true; + }, + }); + +class Session { + constructor(token, data, save) { + this.token = token; + this.state = createProxy(data, save); + } +} + +const DEFAULT_MAX_SESSIONS = 10000; +const DEFAULT_SESSION_TTL = 24 * 60 * 60 * 1000; // 24h + +// Reference implementation of the structural SessionStore contract: +// { get(token) -> data | null, set(token, data), delete(token) } — all async. +// Anything matching the shape (a Redis wrapper, a DB table) plugs in via +// the `sessions.store` option, per the zero-dependency injection rule. +// +// Sessions outlive their connection (that is what makes reconnect work), +// so this store is bounded on both axes: entries expire after `ttl` and +// the least-recently-used one is evicted past `maxSessions`. Production +// deployments should inject a real store instead. +class MemorySessionStore { + #sessions = new Map(); // token -> { data, expires } + #maxSessions; + #ttl; + + constructor({ maxSessions = DEFAULT_MAX_SESSIONS, ttl = DEFAULT_SESSION_TTL, now = Date.now } = {}) { + this.#maxSessions = maxSessions; + this.#ttl = ttl; + this.now = now; + } + + get size() { + return this.#sessions.size; + } + + #expired(entry) { + return this.#ttl > 0 && entry.expires <= this.now(); + } + + async get(token) { + const entry = this.#sessions.get(token); + if (!entry) return null; + if (this.#expired(entry)) { + this.#sessions.delete(token); + return null; + } + // Map keeps insertion order: re-insert to mark as recently used + this.#sessions.delete(token); + this.#sessions.set(token, entry); + return entry.data; + } + + async set(token, data) { + this.#sessions.delete(token); + this.#sessions.set(token, { data, expires: this.now() + this.#ttl }); + this.#evict(); + } + + async delete(token) { + this.#sessions.delete(token); + } + + /** Sliding expiry: a session in active use should not expire mid-use. */ + async touch(token) { + const entry = this.#sessions.get(token); + if (!entry || this.#expired(entry)) return; + entry.expires = this.now() + this.#ttl; + } + + #evict() { + if (this.#ttl > 0) { + for (const [token, entry] of this.#sessions) { + if (!this.#expired(entry)) break; // oldest first: stop at the first live one + this.#sessions.delete(token); + } + } + while (this.#maxSessions > 0 && this.#sessions.size > this.#maxSessions) { + const oldest = this.#sessions.keys().next().value; + this.#sessions.delete(oldest); + } + } +} + +const DEFAULT_COOKIE = { + name: 'token', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'Lax', + maxAge: null, // session cookie by default +}; + +// RFC 6265 grammar, enforced because the token can come from an injected +// `generateToken` and the attributes from user config: a `;` or a control +// character in either would let a value smuggle extra cookie attributes (or +// a second cookie) into the Set-Cookie line. +const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; // RFC 2616 token +// cookie-octet: %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E +const COOKIE_VALUE = /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*$/; +// path-value: printable ASCII minus ';' (0x3B) — spelled as positive ranges +// so no control character ever matches. +const COOKIE_PATH = /^[\x20-\x3A\x3C-\x7E]*$/; +const SAME_SITE = new Set(['Strict', 'Lax', 'None']); + +const buildCookie = (name, value, options) => { + if (!COOKIE_NAME.test(name)) { + throw new TypeError(`buildCookie: invalid cookie name`); + } + if (typeof value !== 'string' || !COOKIE_VALUE.test(value)) { + throw new TypeError('buildCookie: the value contains characters RFC 6265 forbids in a cookie'); + } + if (typeof options.path !== 'string' || !COOKIE_PATH.test(options.path)) { + throw new TypeError('buildCookie: invalid Path attribute'); + } + const parts = [`${name}=${value}`, `Path=${options.path}`]; + if (options.maxAge !== null && options.maxAge !== undefined) { + if (!Number.isSafeInteger(options.maxAge)) throw new TypeError('buildCookie: maxAge must be an integer'); + parts.push(`Max-Age=${options.maxAge}`); + } + if (options.httpOnly) parts.push('HttpOnly'); + if (options.secure) parts.push('Secure'); + if (options.sameSite) { + if (!SAME_SITE.has(options.sameSite)) { + throw new TypeError(`buildCookie: sameSite must be one of ${[...SAME_SITE].join(', ')}`); + } + parts.push(`SameSite=${options.sameSite}`); + } + return parts.join('; '); +}; + +// Per-server session facility (replaces the old module-global Map shared +// by every Server in the process). +class SessionManager { + #log; + + constructor(options = {}, logger = globalThis.console) { + const { store = new MemorySessionStore(), generateToken = generateUUID, cookie = {}, transport } = options; + this.store = store; + this.generateToken = generateToken; + this.cookie = { ...DEFAULT_COOKIE, ...cookie }; + this.#log = createLoggerWriter(logger); + // The injected token carrier; the cookie default keeps the behaviour + // wrpc always had, byte for byte. + if (transport !== undefined && !isTokenTransport(transport)) { + throw new TypeError('SessionManager: sessions.transport must provide read(request) and write(token)'); + } + this.transport = transport ?? cookieTokenTransport(this); + } + + #saver(token) { + return (state) => { + Promise.resolve(this.store.set(token, state)).catch((error) => { + this.#log.error({ err: error, event: 'session.save' }); + }); + }; + } + + create(token = this.generateToken(), data = {}) { + const save = this.#saver(token); + save(data); // persist the initial state so restore works immediately + return new Session(token, data, save); + } + + async restore(token) { + const data = await this.store.get(token); + if (!data) return null; + // Sliding expiry, when the store supports it: restoring IS active use, + // and a shared-store session must not expire under a connected client. + // Optional and fire-and-forget — a store without touch() keeps absolute + // TTLs, which is a valid policy too. + if (typeof this.store.touch === 'function') { + Promise.resolve(this.store.touch(token)).catch((error) => { + this.#log.error({ err: error, event: 'session.touch' }); + }); + } + return new Session(token, data, this.#saver(token)); + } + + async destroy(token) { + await this.store.delete(token); + } + + cookieHeader(token) { + return buildCookie(this.cookie.name, token, this.cookie); + } + + cookieDeleteHeader() { + return buildCookie(this.cookie.name, 'deleted', { ...this.cookie, maxAge: 0 }); + } + + readToken(cookies) { + return cookies[this.cookie.name] ?? null; + } +} + +module.exports = { Session, MemorySessionStore, SessionManager, createProxy, buildCookie, isTokenTransport }; diff --git a/src/rpc/subscriptions.js b/src/rpc/subscriptions.js new file mode 100644 index 0000000..a721799 --- /dev/null +++ b/src/rpc/subscriptions.js @@ -0,0 +1,211 @@ +'use strict'; + +// Subscriptions: a procedure that answers with a STREAM of values instead of +// one, expressed as an async generator. +// +// onMessage: procedure.subscription({ +// handler: async function* (context, args, { lastEventId, signal }) { +// yield tracked('7', { text: 'hi' }); // resumable +// yield { text: 'hi' }; // not resumable +// }, +// }) +// +// On the wire that is `{type:'subscribe', id, method, args, lastEventId?}` in, +// then `{type:'data', id, eventId?, data}` per yield, then `{type:'end', id}` +// (with an `error` when the generator threw). `{type:'unsubscribe', id}` ends +// it early. See docs/reference/protocol.md. +// +// Three things make resume work, and they are deliberately separate: +// tracked() labels ONE value with an id the client remembers, +// createEventLog() remembers recent values so a handler can replay them, +// lastEventId is what the client sends back to say where it stopped. +// A handler that yields untracked values simply has no resume — which is the +// right default for a live-only feed. + +// The push -> pull adapter a handler yields from lives in utils: the client +// needs the same primitive to back `subscription.iterate()`, and it is no +// more subscription-specific than Emitter is. +const { EventStream, createEventStream } = require('../utils.js'); +const { publicErrorMessage } = require('../transport.js'); + +const TRACKED = Symbol.for('wrpc.tracked'); + +/** Labels a yielded value with the id a resuming client will send back. */ +const tracked = (eventId, data) => ({ [TRACKED]: true, id: String(eventId), data }); + +const isTracked = (value) => typeof value === 'object' && value !== null && value[TRACKED] === true; + +const DEFAULT_LOG_SIZE = 100; + +// A bounded replay buffer. Ids are `.` — a monotonic counter +// stamped with WHICH log minted it. The epoch is random per instance by +// default, so after a restart (or on another node holding its own log) a +// client's lastEventId belongs to a foreign epoch and since() answers +// `null` — an honest "cannot resume, take a snapshot" — instead of a +// numeric coincidence silently pretending nothing was missed. A persisted +// or shared log passes its own stable `epoch`. +class EventLog { + // A preallocated ring (slots + head cursor + count), not push/shift: + // Array#shift on every event at steady state cost ~320 ns per push at the + // default size 100 against ~7 ns for the ring, and the cost was not even + // monotonic in size (V8's left-trimming threshold) — bench/replay-buffer.js. + // Same shape as SegmentQueue (src/websocket/segments.js). + #entries; + #head = 0; + #count = 0; + #size; + #next; + #epoch; + + constructor({ size = DEFAULT_LOG_SIZE, start = 0, epoch = null } = {}) { + if (!(Number.isInteger(size) && size > 0)) { + throw new TypeError('createEventLog: size must be a positive integer'); + } + this.#size = size; + this.#entries = new Array(size); + this.#next = start; + this.#epoch = epoch === null || epoch === undefined ? Math.random().toString(36).slice(2, 10) : String(epoch); + if (this.#epoch.includes('.')) { + throw new TypeError('createEventLog: epoch must not contain a dot'); + } + } + + get size() { + return this.#size; + } + + get length() { + return this.#count; + } + + /** Which log incarnation mints this log's ids. */ + get epoch() { + return this.#epoch; + } + + /** The id of the newest entry, or null while the log is empty. */ + get lastEventId() { + if (this.#count === 0) return null; + return this.#entries[(this.#head + this.#count - 1) % this.#size].id; + } + + push(data) { + const n = this.#next++; + const id = `${this.#epoch}.${n}`; + const entry = { id, n, data }; + if (this.#count < this.#size) { + this.#entries[(this.#head + this.#count) % this.#size] = entry; + this.#count++; + } else { + // Full: the new entry takes the oldest slot and the head advances. + this.#entries[this.#head] = entry; + this.#head = (this.#head + 1) % this.#size; + } + return id; + } + + /** + * Everything after `lastEventId`, as tracked values ready to yield. + * `null` (not an empty array) means the log cannot bridge the gap — the + * id fell out of the buffer, or it was minted by another epoch (another + * process, a restart) — so the caller has to decide between a snapshot + * and an error. Silently skipping the gap is the one thing that must not + * happen. With no `lastEventId` there is nothing to replay: `[]`. + */ + since(lastEventId) { + if (lastEventId === undefined || lastEventId === null || lastEventId === '') return []; + const text = String(lastEventId); + const dot = text.lastIndexOf('.'); + if (dot < 0 || text.slice(0, dot) !== this.#epoch) return null; // foreign epoch + const cursor = Number(text.slice(dot + 1)); + if (!Number.isFinite(cursor)) return null; + // Everything asked for is newer than everything we hold: nothing missed. + if (cursor >= this.#next - 1) return []; + const oldest = this.#count > 0 ? this.#entries[this.#head].n : this.#next; + if (cursor < oldest - 1) return null; // the gap is older than the buffer + // Entries sit in ring order from the head, so one pass from there + // collects the tail past the cursor — one array, no filter().map(). + const missed = []; + for (let i = 0; i < this.#count; i++) { + const entry = this.#entries[(this.#head + i) % this.#size]; + if (entry.n > cursor) missed.push(tracked(entry.id, entry.data)); + } + return missed; + } + + clear() { + // fill(), not length = 0: the slot array is the ring's preallocation, + // and the overwrite releases the entry references it held. + this.#entries.fill(undefined); + this.#head = 0; + this.#count = 0; + } +} + +const createEventLog = (options) => new EventLog(options); + +// The pump. Drives one subscription's generator, writes a `data` packet per +// yield, and answers with `end` exactly once — whether the generator +// finished, threw, or was cut short by an unsubscribe or a disconnect. +// +// Backpressure is real: `client.send` reports the transport's high-water +// mark, and the pump waits for 'drain' before pulling the next value. A +// generator that ignores that would turn a slow consumer into unbounded +// server-side memory. +const runSubscription = async ( + client, + { id, procedure, context, args, lastEventId, signal, stats = null, hooks, compiled = null }, +) => { + const iterator = procedure.subscribe(context, args, { lastEventId, signal }, hooks, compiled); + let terminal = { type: 'end', id }; + try { + while (true) { + const { value, done } = await iterator.next(); + if (done || signal.aborted) break; + const packet = { type: 'data', id }; + if (isTracked(value)) { + packet.eventId = value.id; + packet.data = value.data; + } else { + packet.data = value; + } + const flushed = client.send(packet); + if (stats) stats.values++; + if (flushed === false) { + client.otel.recordBackpressure(client.transportKind); + await client.drain(); + } + if (signal.aborted) break; + } + } catch (error) { + if (!signal.aborted) { + const code = typeof error.code === 'number' ? error.code : 500; + terminal = { type: 'end', id, error: { message: publicErrorMessage(code, error), code } }; + } else { + // Aborted: the terminal packet stays clean because the peer asked to + // stop, so this error reaches nobody unless it is logged here. + client.log.debug({ err: error, event: 'subscription.aborted', id }); + } + } + // Close the generator so its `finally` runs — that is where a handler + // releases the listener or connection it opened. NOT awaited: a handler + // that ignores its abort signal would otherwise hold the terminal packet + // hostage, and the peer has already been told to stop. + void Promise.resolve() + .then(() => iterator.return?.()) + .catch((error) => + client.warn(`SUBSCRIPTION\t${id}\t${error?.stack ?? error}`, { event: 'subscription.return', id, err: error }), + ); + return terminal; +}; + +module.exports = { + TRACKED, + tracked, + isTracked, + EventLog, + createEventLog, + EventStream, + createEventStream, + runSubscription, +}; diff --git a/src/runtime/browser.js b/src/runtime/browser.js index 6edfbc5..c75004b 100644 --- a/src/runtime/browser.js +++ b/src/runtime/browser.js @@ -1,5 +1,17 @@ 'use strict'; -const generateUUID = () => globalThis.crypto.randomUUID(); +// `crypto.randomUUID` only exists in secure contexts (https, localhost). +// Ids here are correlation identifiers — packet ids, context uuids — not +// security tokens (the session token has its own generator, injected +// server-side), so a page served over plain http gets a Math.random-shaped +// fallback instead of a TypeError on its first call. +const pseudoUuidV4 = () => + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => { + const random = (Math.random() * 16) | 0; + const value = char === 'x' ? random : (random & 0x3) | 0x8; + return value.toString(16); + }); + +const generateUUID = () => (globalThis.crypto?.randomUUID ? globalThis.crypto.randomUUID() : pseudoUuidV4()); module.exports = { generateUUID }; diff --git a/src/scaling/index.js b/src/scaling/index.js new file mode 100644 index 0000000..5c2115b --- /dev/null +++ b/src/scaling/index.js @@ -0,0 +1,33 @@ +'use strict'; + +const { MemoryBackplane, createMemoryBackplane, DEFAULT_PREFIX } = require('./memory.js'); +const { createRedisAdapter } = require('./redis.js'); + +// The structural backplane contract — anything with this shape plugs into +// `new Server({ backplane })` / `new RpcServer({ backplane })`: +// +// publish(channel, message) -> void | Promise +// subscribe(channel, handler) -> unsubscribe | Promise +// close() -> void | Promise +// +// `message` is always a string (the rooms layer serializes its envelope), so +// an adapter needs no knowledge of the payload shape. +// +// Guarantees: at-most-once. A message published while an instance is between +// subscriptions, or dropped by the broker, is gone — rooms are a fan-out +// mechanism, not a queue. Replay of missed events lands with subscriptions +// and lastEventId. +const isBackplane = (value) => + typeof value === 'object' && + value !== null && + typeof value.publish === 'function' && + typeof value.subscribe === 'function' && + typeof value.close === 'function'; + +module.exports = { + MemoryBackplane, + createMemoryBackplane, + createRedisAdapter, + isBackplane, + DEFAULT_PREFIX, +}; diff --git a/src/scaling/memory.js b/src/scaling/memory.js new file mode 100644 index 0000000..0684596 --- /dev/null +++ b/src/scaling/memory.js @@ -0,0 +1,86 @@ +'use strict'; + +const { createLoggerWriter } = require('../logging.js'); + +const DEFAULT_PREFIX = 'wrpc'; + +// In-process backplane: the reference implementation of the contract, and +// what makes multi-instance fan-out testable (and useful) without a broker — +// two RpcServer instances sharing one MemoryBackplane behave exactly like two +// processes sharing a Redis. +// +// Delivery is deferred to a microtask on purpose: a real broker never calls +// a handler synchronously inside publish(), and code that accidentally +// depends on synchronous delivery would break the moment Redis is swapped in. +class MemoryBackplane { + #channels = new Map(); // channel -> Set + #prefix; + #log; + #closed = false; + + constructor({ prefix = DEFAULT_PREFIX, logger = globalThis.console } = {}) { + this.#prefix = prefix; + this.#log = createLoggerWriter(logger); + } + + get name() { + return 'memory'; + } + + get size() { + return this.#channels.size; + } + + #key(channel) { + return this.#prefix ? `${this.#prefix}:${channel}` : channel; + } + + publish(channel, message) { + if (this.#closed) return; + const handlers = this.#channels.get(this.#key(channel)); + if (!handlers || handlers.size === 0) return; + // No snapshot: the loop body only schedules, so nothing here can mutate + // `handlers` mid-iteration. (Contrast the copy in scaling/redis.js, whose + // loop calls handlers synchronously and therefore needs one.) The real + // safety is the membership re-check inside the microtask below. + for (const handler of handlers) { + queueMicrotask(() => { + // Unsubscribed between publish and delivery: a broker would not + // deliver either. + if (this.#closed || !handlers.has(handler)) return; + try { + handler(message); + } catch (error) { + this.#log.error({ err: error, event: 'backplane.handler', channel }); + } + }); + } + } + + subscribe(channel, handler) { + if (typeof handler !== 'function') { + throw new TypeError('MemoryBackplane.subscribe: handler must be a function'); + } + const key = this.#key(channel); + let handlers = this.#channels.get(key); + if (!handlers) { + handlers = new Set(); + this.#channels.set(key, handlers); + } + handlers.add(handler); + return () => { + const current = this.#channels.get(key); + if (!current || !current.delete(handler)) return; + if (current.size === 0) this.#channels.delete(key); + }; + } + + close() { + this.#closed = true; + this.#channels.clear(); + } +} + +const createMemoryBackplane = (options) => new MemoryBackplane(options); + +module.exports = { MemoryBackplane, createMemoryBackplane, DEFAULT_PREFIX }; diff --git a/src/scaling/redis.js b/src/scaling/redis.js new file mode 100644 index 0000000..cc87b22 --- /dev/null +++ b/src/scaling/redis.js @@ -0,0 +1,174 @@ +'use strict'; + +const { DEFAULT_PREFIX } = require('./memory.js'); +const { createLoggerWriter } = require('../logging.js'); + +// Redis backplane, modelled on the ioredis API — `publish(channel, message)`, +// `subscribe(channel)` plus an `'message'(channel, message)` event, and +// `duplicate()` for the second connection a subscriber needs. +// +// Per the zero-dependency rule nothing is required here: the caller injects +// its own clients and they are validated structurally, so any client with +// that shape (ioredis, node-redis wrapped, a fake in tests) plugs in. +// +// const { createRedisAdapter } = require('@alexify/wrpc/scaling'); +// const Redis = require('ioredis'); +// const pub = new Redis(url); +// const backplane = createRedisAdapter({ pub }); // sub = pub.duplicate() +// +// A Redis connection in subscriber mode cannot issue other commands, which +// is why publishing and subscribing need two clients. + +const isFunction = (value) => typeof value === 'function'; + +const checkPub = (pub) => { + if (!pub || !isFunction(pub.publish)) { + throw new TypeError('createRedisAdapter: options.pub must be a client with publish(channel, message)'); + } + return pub; +}; + +const resolveSub = (pub, sub) => { + const client = sub ?? (isFunction(pub.duplicate) ? pub.duplicate() : null); + if (!client || !isFunction(client.subscribe) || !isFunction(client.on)) { + throw new TypeError( + 'createRedisAdapter: options.sub must be a client with subscribe(channel) and on(event, listener), ' + + 'or options.pub must support duplicate()', + ); + } + return client; +}; + +const detach = (client, event, listener) => { + if (isFunction(client.off)) return void client.off(event, listener); + if (isFunction(client.removeListener)) client.removeListener(event, listener); +}; + +const createRedisAdapter = (options = {}) => { + const { pub, sub, prefix = DEFAULT_PREFIX, logger = globalThis.console } = options; + checkPub(pub); + // A subscriber this adapter duplicated for itself is a connection nobody + // else holds a reference to, so close() has to release it. An INJECTED one + // belongs to the caller and is only unsubscribed. + const owned = sub === undefined || sub === null; + const subscriber = resolveSub(pub, sub); + + const handlers = new Map(); // full channel name -> Set + let closed = false; + + const log = createLoggerWriter(logger); + const report = (error) => void log.error({ err: error, component: 'redis' }); + const settle = (result) => { + if (result && isFunction(result.catch)) result.catch(report); + }; + const key = (channel) => (prefix ? `${prefix}:${channel}` : channel); + + // ioredis delivers every channel of a connection through one 'message' + // event, so a single listener demultiplexes for all subscriptions. + const onMessage = (channel, message) => { + const set = handlers.get(channel); + if (!set) return; + // Snapshot is load-bearing, unlike the one dropped in scaling/memory.js: + // these handlers run SYNCHRONOUSLY, so one that subscribes or unsubscribes + // during dispatch would otherwise perturb a live Set iterator. + for (const handler of Array.from(set)) { + try { + handler(message); + } catch (error) { + report(error); + } + } + }; + subscriber.on('message', onMessage); + + // The subscriber this adapter duplicated has no other listeners: without + // this, its first network error is an 'error' event nobody handles — an + // uncaught exception that takes the process down for a Redis blip. + const onError = (error) => report(error); + subscriber.on('error', onError); + + // ioredis re-emits 'ready' after every reconnect. Its own auto-resubscribe + // covers the common case; re-issuing the SUBSCRIBEs here also covers + // clients (or configurations) that do not, and a duplicate SUBSCRIBE is + // idempotent on the server side. + const onReady = () => { + for (const name of handlers.keys()) { + try { + settle(subscriber.subscribe(name)); + } catch (error) { + report(error); + } + } + }; + subscriber.on('ready', onReady); + + const unsubscribe = (name) => { + if (!isFunction(subscriber.unsubscribe)) return; + try { + settle(subscriber.unsubscribe(name)); + } catch (error) { + report(error); + } + }; + + return { + name: 'redis', + + publish(channel, message) { + if (closed) return; + try { + settle(pub.publish(key(channel), message)); + } catch (error) { + report(error); + } + }, + + subscribe(channel, handler) { + if (!isFunction(handler)) { + throw new TypeError('createRedisAdapter: subscribe handler must be a function'); + } + if (closed) return () => {}; + const name = key(channel); + let set = handlers.get(name); + if (!set) { + set = new Set(); + handlers.set(name, set); + try { + settle(subscriber.subscribe(name)); + } catch (error) { + report(error); + } + } + set.add(handler); + return () => { + const current = handlers.get(name); + if (!current || !current.delete(handler)) return; + if (current.size > 0) return; + handlers.delete(name); + unsubscribe(name); + }; + }, + + // Releases what this adapter owns — its subscriptions, its listener, and + // the subscriber connection it opened itself. An INJECTED client is never + // quit: its lifetime belongs to the caller. + close() { + if (closed) return; + closed = true; + for (const name of Array.from(handlers.keys())) unsubscribe(name); + handlers.clear(); + detach(subscriber, 'message', onMessage); + detach(subscriber, 'error', onError); + detach(subscriber, 'ready', onReady); + if (!owned) return; + try { + if (isFunction(subscriber.quit)) settle(subscriber.quit()); + else if (isFunction(subscriber.disconnect)) subscriber.disconnect(); + } catch (error) { + report(error); + } + }, + }; +}; + +module.exports = { createRedisAdapter }; diff --git a/src/server.js b/src/server.js index cdce508..52e7762 100644 --- a/src/server.js +++ b/src/server.js @@ -3,310 +3,110 @@ const http = require('node:http'); const https = require('node:https'); -const { Emitter, jsonParse } = require('./utils.js'); -const { generateUUID } = require('./runtime/node.js'); - -const { WebsocketServer } = require('./websocket/ws.js'); -const { ServerTransport, buildHeaders } = require('./transport.js'); -const ServerHttpTransport = ServerTransport.transport.http; -const ServerWsTransport = ServerTransport.transport.ws; -const ServerEventTransport = ServerTransport.transport.event; -const { WrpcReadable, WrpcWritable } = require('./streams.js'); -const { chunkDecode } = require('./chunks.js'); +const { Emitter } = require('./utils.js'); +const { RpcServer, rpcOptions } = require('./rpc/core.js'); +const { createNodeEngine, isEngine } = require('./engine/index.js'); +const { receiveBody, nodeStream, createUpgradeGate, respondBodyError, MAX_BODY_SIZE } = require('./adapters/common.js'); +const { createLoggerWriter } = require('./logging.js'); const DEFAULT_LISTEN_RETRY = 3; -const MAX_BODY_SIZE = 10 * 1024 * 1024; - -const isError = (err) => err?.constructor?.name?.includes('Error') || false; - -const split = (s, separator) => { - const i = s.indexOf(separator); - if (i < 0) return [s, '']; - return [s.slice(0, i), s.slice(i + separator.length)]; -}; - -const parseParams = (params) => Object.fromEntries(new URLSearchParams(params)); - -const receiveBody = async (stream, limit = MAX_BODY_SIZE) => { - if (!Number.isSafeInteger(limit) || limit < 0) { - throw new TypeError('Body size limit must be a non-negative safe integer'); - } - const chunks = []; - let size = 0; - for await (const chunk of stream) { - size += chunk.length; - if (size > limit) throw new Error('Body size limit exceeded'); - chunks.push(chunk); - } - if (chunks.length === 1) return chunks[0]; - return Buffer.concat(chunks, size); -}; - -const getPathname = (url) => (url ? url.split('?')[0] : '/'); - -const isWrpcWebSocketPath = (url) => { - const pathname = getPathname(url); - return pathname === '/' || pathname === '/api' || pathname.startsWith('/api/'); -}; - -const createProxy = (data, save) => - new Proxy(data, { - get: (target, key) => { - const value = Reflect.get(target, key); - return value; - }, - set: (target, key, value) => { - const success = Reflect.set(target, key, value); - if (save) save(target); - return success; - }, - }); - -class Session { - constructor(token, data, context) { - this.token = token; - const { console, auth } = context; - this.state = createProxy(data, (data) => { - auth.saveSession(token, data).catch((error) => { - console.error(error); - }); - }); - } -} - -const sessions = new Map(); // token: Session - -class Context { - constructor(client) { - this.client = client; - this.uuid = generateUUID(); - this.state = {}; - this.session = client?.session ?? null; - } -} - -class Client extends Emitter { - #transport = null; - #context = null; +const DEFAULT_BIND_TIMEOUT = 2000; + +// Batteries-included shell over the engine-agnostic RpcServer core: +// creates the node http(s) server, attaches a WebSocket engine (the +// built-in one by default), and feeds HTTP requests into the core. +// +// With a standalone engine (uWebSockets.js) there is no node http server at +// all: `httpServer` stays null and the engine owns listening plus the HTTP +// request path. Read the bound address through `server.address()`, which +// covers both shapes. +class Server extends Emitter { + httpServer = null; + wsServer = null; + rpc = null; + #engine; + #options; + #log; + #address = null; - constructor(transport, context) { + constructor(options = {}) { super(); - this.#transport = transport; - this.#context = context; - this.source = transport.source; - this.session = null; - this.streams = new Map(); - } - - error(code, { id = '', error = null } = {}) { - const httpCode = code <= 599 ? code : 500; - const status = http.STATUS_CODES[httpCode]; - const info = error ? error.stack : status || 'Unknown error'; - this.#transport.error(code, { id, error }); - this.#context.console.error(`${this.source}\t${code}\t${info}`); - } - - send(obj, options = {}) { - const { code, method } = options; - this.#transport.send(obj, code); - const isSuccessCallback = obj.type === 'callback' && !obj.error; - if (!isSuccessCallback) return; - this.#context.console.log(`${this.source}\tCALL\t${method}\tOK`); - } - - createContext() { - return new Context(this); - } - - emit(name, data) { - if (name === 'close') return void super.emit(name, data); - this.sendEvent(name, data); - } - - sendEvent(name, data) { - const packet = { type: 'event', name, data }; - if (!this.#transport.connection) { - throw new Error(`Can't send wrpc event to http transport`); + const { cors = null, logger = globalThis.console, engine = createNodeEngine(), ws = {} } = options; + if (!isEngine(engine)) { + throw new TypeError('Server: options.engine does not implement the Engine contract'); } - this.send(packet); - } - - getStream(id) { - if (!this.#transport.connection) { - throw new Error(`Can't receive stream from http transport`); - } - const stream = this.streams.get(id); - if (stream) return stream; - throw new Error(`Stream ${id} is not initialized`); + this.#options = options; + this.#log = createLoggerWriter(logger); + this.#engine = engine; + this.rpc = new RpcServer(rpcOptions({ ...options, cors, logger })); + if (engine.standalone) this.#initStandalone(ws, cors); + else this.#init(ws, cors); } - createStream(name, size) { - if (!this.#transport.connection) { - throw new Error(`Can't send wrpc streams to http transport`); - } - if (!name) throw new Error('Stream name is not provided'); - if (!size) throw new Error('Stream size is not provided'); - const id = generateUUID(); - const stream = new WrpcWritable(id, name, size, this.#transport); - this.streams.set(id, stream); - return stream; + // The bound address, whichever side owns the listener. + address() { + if (this.httpServer) return this.httpServer.address(); + return this.#address; } - initializeSession(token, data = {}) { - this.finalizeSession(); - this.session = new Session(token, data, this.#context); - sessions.set(token, this.session); - return true; - } + // Rooms, forwarded to the core so `server.to('chat').emit(...)` reads the + // same whether wrpc owns the listener or an adapter does. - finalizeSession() { - if (!this.session) return false; - sessions.delete(this.session.token); - this.session = null; - return true; + get rooms() { + return this.rpc.rooms; } - startSession(token, data = {}) { - this.initializeSession(token, data); - if (!this.#transport.connection) { - this.#transport.sendSessionCookie(token); - } - return true; + get clients() { + return this.rpc.clients; } - restoreSession(token) { - const session = sessions.get(token); - if (!session) return false; - this.session = session; - return true; + to(...rooms) { + return this.rpc.to(...rooms); } - close() { - this.#transport.close(); + except(...clients) { + return this.rpc.except(...clients); } - destroy() { - const { console } = this.#context; - this.emit('close'); - for (const stream of this.streams.values()) { - if (typeof stream.terminate !== 'function') continue; - Promise.resolve(stream.terminate()).catch((error) => { - console.error(error); - }); - } - this.streams.clear(); - if (!this.session) return; - sessions.delete(this.session.token); + broadcast(name, data) { + return this.rpc.broadcast(name, data); } -} -const protectedCall = async (client, proc, id, args) => { - const context = client.createContext(); - try { - await proc.enter(); - } catch (error) { - client.error(503, { id, error }); - return { entered: false, result: null }; - } - let result = null; - try { - result = await proc.invoke(context, args); - } finally { - proc.leave(); + /** Cluster-wide presence, introspection and node-to-node messaging. */ + get cluster() { + return this.rpc.cluster; } - return { entered: true, result }; -}; -const handleStream = async (client, packet) => { - const { id, name, size, status } = packet; - const tag = `${id}/${name}`; - try { - const stream = client.streams.get(id); - if (status) { - if (!stream) throw new Error(`Stream ${tag} is not initialized`); - if (status === 'end') await stream.close(); - if (status === 'terminate') await stream.terminate(); - return void client.streams.delete(id); - } - const valid = typeof name === 'string' && Number.isSafeInteger(size); - if (!valid) throw new Error('Stream packet structure error'); - if (stream) throw new Error(`Stream ${tag} is already initialized`); - { - const stream = new WrpcReadable(id, name, size); - client.streams.set(id, stream); - } - } catch (error) { - client.error(400, { id, error }); + /** The local client with this id; undefined when not on this instance. */ + getClient(id) { + return this.rpc.getClient(id); } -}; -const handleBinary = async (client, data) => { - const { id, payload } = chunkDecode(data); - try { - const upstream = client.streams.get(id); - if (upstream) { - await upstream.push(payload); - return; - } - const error = new Error(`Stream ${id} is not initialized`); - client.error(400, { id, error }); - } catch (error) { - client.error(400, { id, error }); - } -}; - -const handleRpc = async (client, packet, context) => { - const { id, method, args } = packet; - const [unitName, methodName] = method.split('/'); - const [unit, ver = '*'] = unitName.split('.'); - const proc = context.getMethod(unit, ver, methodName); - if (!proc) return void client.error(404, { id }); - if (!client.session && proc.access !== 'public') { - return void client.error(403, { id }); - } - try { - const procResult = await protectedCall(client, proc, id, args); - if (!procResult.entered) return; - const { result } = procResult; - if (isError(result)) { - const { code } = result; - return void client.error(code, { id, error: result }); - } - client.send({ type: 'callback', id, result }, { method }); - } catch (error) { - let code = error.code === 'ETIMEOUT' ? 408 : 500; - if (typeof error.code === 'number') code = error.code; - return void client.error(code, { id, error }); - } -}; - -class Server extends Emitter { - #headers = null; - #clients = new Set(); - #context = null; - #options = null; - httpServer = null; - wsServer = null; - - constructor(context, options) { - super(); - this.#context = context; - this.#options = options; - this.#headers = buildHeaders(options.cors); - this.#init(); + #onConnection(socket, req) { + this.rpc.attachSocket(socket, { + headers: req.headers, + url: req.url ?? '', + remoteAddress: req.socket?.remoteAddress ?? socket.remoteAddress, + }); } - #addClient(transport) { - const client = new Client(transport, this.#context); - this.#clients.add(client); - transport.once('close', () => { - client.destroy(); - this.#clients.delete(client); + // Standalone engines own node:http too, so the shell creates no server + // and hands the core's HTTP entry point to the engine instead. + #initStandalone(wsOptions, cors) { + this.wsServer = this.#engine.attach({ + ...wsOptions, + verifyClient: createUpgradeGate({ rpc: this.rpc, cors, ws: wsOptions }), + onHttpCall: (call) => this.rpc.handleHttpCall(call), + }); + this.wsServer.on('connection', (socket, req) => { + this.#onConnection(socket, req); + }); + this.on('port', (port) => { + this.rpc.attachPort(port); }); - return client; } - #init() { + #init(wsOptions, cors) { const { protocol, nagle = true, key, cert, SNICallback } = this.#options; const proto = protocol === 'http' ? http : https; const opt = { key, cert, noDelay: !nagle, SNICallback }; @@ -316,150 +116,126 @@ class Server extends Emitter { this.#handleHttpRequest(req, res); }); - const options = this.#createWsServerOptions(); - this.wsServer = new WebsocketServer(options); - - this.wsServer.on('connection', (connection, req) => { - this.#handleWsConnection(req, connection); + const verifyClient = createUpgradeGate({ rpc: this.rpc, cors, ws: wsOptions }); + this.wsServer = this.#engine.attach({ server: this.httpServer, ...wsOptions, verifyClient }); + this.wsServer.on('connection', (socket, req) => { + this.#onConnection(socket, req); }); this.on('port', (port) => { - this.#handleEventConnection(port); + this.rpc.attachPort(port); }); } - #createWsServerOptions() { - const { websocketPath } = this.#options; - const options = { server: this.httpServer }; - if (websocketPath !== undefined) { - options.path = websocketPath; - } else { - options.verifyClient = ({ req }) => isWrpcWebSocketPath(req.url); - } - return options; - } - async #handleHttpRequest(req, res) { - if (!req.url.startsWith('/api')) return; - const options = { headers: this.#headers }; - const transport = new ServerHttpTransport(req, res, options); - if (res.writableEnded) return; - - const client = this.#addClient(transport); - let data = null; + const respond = ({ status, headers, body }) => { + if (res.writableEnded) return; + res.writeHead(status, headers); + res.end(body); + }; + let body = null; try { - data = await receiveBody(req); + body = await receiveBody(req, this.#options.maxBodySize ?? MAX_BODY_SIZE); } catch (error) { - transport.error(400, { error }); - return; + return void respondBodyError(respond, error); } - - if (req.url === '/api') { - if (req.method !== 'POST') transport.error(403); - else this.#message(client, data); - return; - } - this.#request(client, transport, data); - } - - #handleWsConnection(req, connection) { - const options = { headers: this.#headers }; - const transport = new ServerWsTransport(req, connection, options); - const client = this.#addClient(transport); - - connection.on('message', (data, isBinary) => { - if (isBinary) handleBinary(client, new Uint8Array(data)); - else this.#message(client, data); + await this.rpc.handleHttpCall({ + method: req.method, + url: req.url, + headers: req.headers, + body, + remoteAddress: req.socket.remoteAddress, + respond, + // Keeps the response open and writes into it — how SSE is served. + stream: nodeStream(res), + // Lets the core evict clients for requests that never get a response + onAbort: (listener) => void res.on('close', listener), }); - connection.on('error', () => transport.emit('close')); } - #handleEventConnection(port) { - const transport = new ServerEventTransport(port); - const client = this.#addClient(transport); - - port.on('message', (data) => { - if (typeof data === 'string' || Buffer.isBuffer(data)) { - this.#message(client, data); - } else if (data instanceof Uint8Array) { - handleBinary(client, data); - } + // One bind attempt; rejects with the bind error (EADDRINUSE included) so + // the retry loop above can decide, and detaches both listeners either way + // so a retried listen() does not stack them. + #bindOnce(host, port) { + if (!this.httpServer) return this.#engine.listen({ host, port }); + return new Promise((resolve, reject) => { + const onListening = () => { + this.httpServer.off('error', onError); + resolve(this.httpServer.address()); + }; + const onError = (error) => { + this.httpServer.off('listening', onListening); + reject(error); + }; + this.httpServer.once('listening', onListening); + this.httpServer.once('error', onError); + this.httpServer.listen(port, host); }); } listen() { - const { host, port, timeouts, retry } = this.#options; - + const { host, port, timeouts = {}, retry } = this.#options; let count = retry || DEFAULT_LISTEN_RETRY; - let listen = null; return new Promise((resolve, reject) => { - const onListening = () => { - this.#context.console.info(`Listen port ${port}`); - resolve(this); - }; - - const onError = (error) => { - if (error.code !== 'EADDRINUSE') return void reject(error); - count--; - if (count === 0) return void reject(error); - this.#context.console.warn(`Address in use: ${host}:${port}, retry...`); - setTimeout(listen, timeouts.bind); - }; - - listen = () => { - this.httpServer.once('listening', onListening); - this.httpServer.once('error', onError); - this.httpServer.listen(port, host); + const attempt = () => { + this.#bindOnce(host, port).then( + (address) => { + this.#address = address; + this.#log.info({ event: 'listen', port: address?.port ?? port }, `Listen port ${address?.port ?? port}`); + resolve(this); + }, + (error) => { + if (error.code !== 'EADDRINUSE') return void reject(error); + count--; + if (count === 0) return void reject(error); + this.#log.warn({ event: 'listen.retry', host, port }, `Address in use: ${host}:${port}, retry...`); + setTimeout(attempt, timeouts.bind ?? DEFAULT_BIND_TIMEOUT); + }, + ); }; - - listen(); + attempt(); }); } - #message(client, data) { - const packet = jsonParse(data) || {}; - const { id, type, method } = packet; - if (type === 'call' && id && method) { - return void handleRpc(client, packet, this.#context); - } else if (type === 'stream' && id) { - return void handleStream(client, packet); + /** + * Shuts the server down. With `drain` (ms) the shutdown is graceful: + * intake stops first (new connections are refused, new calls answer 503), + * in-flight calls get up to `drain` ms to settle, then every peer gets a + * 1001 "going away" close frame, and only what remains is torn down hard. + * Without it the same sequence runs with a zero-length drain window. + */ + async close(options = {}) { + const { drain = 0 } = options; + if (!this.httpServer) { + // Intake first, same ordering as the node boot below: a standalone + // engine that can close its listen socket separately refuses new + // connections while the in-flight work drains, instead of accepting + // calls it will immediately 503. + if (typeof this.#engine.stopListening === 'function') this.#engine.stopListening(); + await this.rpc.drain(drain); + // Standalone engines own the whole stack: their close() both stops + // the listener and says goodbye to the peers. + this.#engine.close(); + return void (await this.rpc.close()); } - const error = new Error('Packet structure error'); - client.error(500, { error }); - } - - #request(client, transport, data) { - const { url } = transport.req; - const pathname = url.slice('/api/'.length); - const [path, params] = split(pathname, '?'); - const parameters = parseParams(params); - const [unit, name] = split(path, '/'); - const body = jsonParse(data) || {}; - const args = { ...parameters, ...body }; - const id = generateUUID(); - const method = `${unit}/${name}`; - const packet = { id, method, args }; - handleRpc(client, packet, this.#context); - } - - async close() { + // Stop intake first, so a load balancer's next health check fails while + // the in-flight work is still being finished. const closed = new Promise((resolve) => { this.httpServer.close((error) => { - if (error) this.#context.console.error(error); + if (error) this.#log.error({ err: error, event: 'close' }); resolve(); }); }); - for (const client of this.#clients) client.close(); + await this.rpc.drain(drain); + // The engine's close sends 1001 to every open socket BEFORE the core + // evicts the clients — the reverse order used to terminate everyone and + // then say goodbye to nobody. + this.#engine.close(); + await this.rpc.close(); this.httpServer.closeAllConnections(); await closed; } } -module.exports = { - Server, - Client, - Context, - Session, - createProxy, -}; +module.exports = { Server }; diff --git a/src/sse/client.js b/src/sse/client.js new file mode 100644 index 0000000..83a69ef --- /dev/null +++ b/src/sse/client.js @@ -0,0 +1,275 @@ +'use strict'; + +const { WrpcClient, ClientTransport, metaHeaders } = require('../client.js'); +const { CHANNEL_HEADER } = require('./constants.js'); + +// The client half of the SSE transport. Browser-safe: `fetch`, streams and +// TextDecoder only — no node builtins, and deliberately not `EventSource`, +// which cannot set headers, cannot be aborted cleanly, and reconnects on its +// own schedule instead of the client's. +// +// require('@alexify/wrpc/sse'); // registers it +// await WrpcClient.connect(url, { transport: 'sse' }); +// +// Calls go out as POSTs; every answer comes back down the one event stream. +// +// The channel id is SERVER-minted: the first GET carries none, the server +// answers with a `ready` frame naming the channel, and only then can this +// transport POST. A reconnect presents the id again (by header — URLs end +// up in logs) together with the same cookies; a 409 means the channel is +// gone (retention expired, another instance), and the transport starts +// over with a fresh one. + +// Incremental SSE parser (WHATWG "event stream" rules): fields are +// `field: value` lines, a blank line dispatches, `data:` lines accumulate +// with newlines between them, and a leading colon is a comment. +class SseParser { + #buffer = ''; + #data = []; + #event = ''; + #id = null; + + /** Feeds a chunk and returns the events it completed. */ + push(chunk) { + this.#buffer += chunk; + const events = []; + while (true) { + const end = this.#lineEnd(); + if (end < 0) break; + const [line, next] = end; + const raw = this.#buffer.slice(0, line); + this.#buffer = this.#buffer.slice(next); + const event = this.#line(raw); + if (event) events.push(event); + } + return events; + } + + // Returns [lineLength, consumed] for the first complete line, honouring + // \n, \r and \r\n — and refusing to split a \r that might still be the + // first half of a \r\n in the next chunk. + #lineEnd() { + for (let i = 0; i < this.#buffer.length; i++) { + const char = this.#buffer[i]; + if (char === '\n') return [i, i + 1]; + if (char !== '\r') continue; + if (i + 1 === this.#buffer.length) return -1; // might be \r\n, wait + return [i, this.#buffer[i + 1] === '\n' ? i + 2 : i + 1]; + } + return -1; + } + + #line(line) { + if (line === '') return this.#dispatch(); + if (line.startsWith(':')) return null; // comment (the proxy heartbeat) + const colon = line.indexOf(':'); + const field = colon < 0 ? line : line.slice(0, colon); + let value = colon < 0 ? '' : line.slice(colon + 1); + if (value.startsWith(' ')) value = value.slice(1); + if (field === 'data') this.#data.push(value); + else if (field === 'event') this.#event = value; + // Per the spec an id containing U+0000 is ignored. Written as an + // escape on purpose: a literal NUL in a source file turns it into a + // binary blob for grep and most editors. + else if (field === 'id' && !value.includes('\u0000')) this.#id = value; + // `retry` is honoured by the client's own backoff, not by this parser. + return null; + } + + #dispatch() { + if (this.#data.length === 0) { + this.#event = ''; + return null; + } + const event = { id: this.#id, event: this.#event || 'message', data: this.#data.join('\n') }; + this.#data = []; + this.#event = ''; + return event; + } +} + +const joinUrl = (base, path) => (base.endsWith('/') ? base.slice(0, -1) : base) + path; + +class ClientSseTransport extends ClientTransport { + // The stream can die without a close frame just like a socket can. + heartbeat = true; + + #controller = null; + #channel = null; + #lastEventId = null; + #parser = null; + #reading = null; + #onReady = null; + // Connection-phase headers and metadata, resolved per open — real request + // headers on both the stream GET and every packet POST. + #headers = null; + #meta = null; + + get eventsUrl() { + return joinUrl(this.url, '/events'); + } + + async open(options = {}) { + this.#headers = options.headers ?? null; + // A header BLOCK, not one encoded value: the client's metaFormat picks + // the spelling, both legs below just spread the result. + this.#meta = options.meta ? metaHeaders(options.meta, options.metaPrefixed) : null; + if (this.active) return; + if (this.#reading) return this.#reading; + const opening = this.#open(true); + this.#reading = opening; + try { + await opening; + } finally { + this.#reading = null; + } + } + + async #open(retryOnGone) { + const controller = new AbortController(); + this.#controller = controller; + this.#parser = new SseParser(); + // Declared first, wire headers after: the protocol's own always win. + const headers = { ...this.#headers, ...this.#meta, accept: 'text/event-stream' }; + // A reconnect presents the channel and where it stopped; the server + // replays what this channel did not acknowledge. + if (this.#channel !== null) headers[CHANNEL_HEADER] = this.#channel; + if (this.#lastEventId !== null) headers['last-event-id'] = this.#lastEventId; + const response = await fetch(this.eventsUrl, { headers, signal: controller.signal, cache: 'no-store' }); + // 409: the channel this transport remembers no longer exists. Its + // replay state is worthless now — drop it and start a fresh channel; + // the WrpcClient above re-loads and re-subscribes on 'open'/'reconnect'. + if (response.status === 409 && retryOnGone && this.#channel !== null) { + await response.body?.cancel?.(); + this.#channel = null; + this.#lastEventId = null; + return this.#open(false); + } + if (!response.ok || !response.body) { + throw new Error(`SSE stream refused with ${response.status}`); + } + // Not connected until the server names the channel: a POST before the + // `ready` frame would have no channel to belong to. + const consuming = this.#consume(response.body); + void consuming.catch((error) => { + if (!controller.signal.aborted) this.emit('error', error); + }); + await new Promise((resolve, reject) => { + let settled = false; + const fail = (error) => { + if (settled) return; + settled = true; + reject(error); + }; + this.#onReady = () => { + if (settled) return; + settled = true; + resolve(); + }; + controller.signal.addEventListener('abort', () => fail(new Error('SSE stream aborted')), { once: true }); + // A stream that ends (or dies) before `ready` never connected. After + // `ready` both callbacks are no-ops: the consume loop owns the stream. + consuming.then(() => fail(new Error('SSE stream ended before ready')), fail); + }); + this.active = true; + this.emit('open'); + } + + async #consume(body) { + const reader = body.getReader(); + const decoder = new TextDecoder(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + for (const event of this.#parser.push(decoder.decode(value, { stream: true }))) { + this.#receive(event); + } + } + } finally { + this.#down(); + } + } + + #receive(event) { + if (event.id !== null) this.#lastEventId = event.id; + if (event.event === 'gap') { + // The server no longer holds what this channel missed: resuming would + // silently skip frames. Drop the channel state and start over — the + // client above re-loads and re-subscribes, and each subscription + // resumes (or honestly refuses to) from its own lastEventId. Said out + // loud: this is event loss, not routine reconnection. + this.log?.warn({ event: 'sse.gap', channel: this.#channel }); + this.#channel = null; + this.#lastEventId = null; + this.close(); + return; + } + if (event.event === 'ready') { + const ready = JSON.parse(event.data); + // The server mints the id; this frame is the only place it is learned. + if (ready.channel) this.#channel = ready.channel; + if (this.#onReady) { + this.#onReady(); + this.#onReady = null; + } + return; + } + this.emit('message', event.data); + } + + #down() { + if (!this.active) return; + this.active = false; + this.emit('close'); + } + + close() { + if (!this.active && !this.#controller) return; + const controller = this.#controller; + this.#controller = null; + this.#down(); + controller?.abort(); + } + + terminate() { + this.close(); + } + + write(data) { + if (!this.active || this.#channel === null) throw new Error('Not connected'); + const headers = { + ...this.#headers, + ...this.#meta, + 'Content-Type': this.codec?.contentType ?? 'application/json', + [CHANNEL_HEADER]: this.#channel, + }; + const post = async () => { + const response = await fetch(this.url, { method: 'POST', headers, body: data }); + // 202 is the expected answer: everything a call produces comes back + // on the event stream, not in this response. + if (response.status === 202) return void (await response.body?.cancel?.()); + const text = await response.text(); + if (response.ok) return void (text && this.emit('message', text)); + // 409: the channel died server-side (retention, restart, another + // instance). This stream is now an orphan — close it so the client + // above reconnects and starts a fresh channel (the 'close' settles + // everything in flight). + if (response.status === 409) return void this.close(); + // A refused POST answers nothing on the event stream, so the exact + // calls this request carried settle now instead of waiting out + // callTimeout — same contract as the plain HTTP transport. + this.emit('error', new Error(`SSE post failed with ${response.status}: ${text}`)); + this.failPackets(data, response.status); + }; + post().catch((error) => { + this.emit('error', error); + this.failPackets(data, 503); + }); + return true; + } +} + +WrpcClient.transport.sse = ClientSseTransport; + +module.exports = { ClientSseTransport, SseParser, CHANNEL_HEADER }; diff --git a/src/sse/constants.js b/src/sse/constants.js new file mode 100644 index 0000000..b442597 --- /dev/null +++ b/src/sse/constants.js @@ -0,0 +1,9 @@ +'use strict'; + +// Shared by the server half and the (browser-bundled) client half; nothing +// here may import anything. + +// The header a POST (or a re-attaching GET) names its channel with. +const CHANNEL_HEADER = 'x-wrpc-channel'; + +module.exports = { CHANNEL_HEADER }; diff --git a/src/sse/index.js b/src/sse/index.js new file mode 100644 index 0000000..f93cad7 --- /dev/null +++ b/src/sse/index.js @@ -0,0 +1,24 @@ +'use strict'; + +// `@alexify/wrpc/sse` — Server-Sent Events as a wrpc transport. +// +// Requiring this registers the client-side transport, after which +// `WrpcClient.connect(url, { transport: 'sse' })` works. The server side is +// built into RpcServer (the `{basePath}/events` route); what is exported +// here is the machinery behind it, for hosts that drive it themselves. +// +// In a browser bundle this resolves to src/sse/client.js alone — the server +// half never ships to a browser. + +const { ServerSseTransport, SseChannel, SseChannels, SSE_HEADERS, CHANNEL_HEADER } = require('./server.js'); +const { ClientSseTransport, SseParser } = require('./client.js'); + +module.exports = { + ClientSseTransport, + SseParser, + ServerSseTransport, + SseChannel, + SseChannels, + SSE_HEADERS, + CHANNEL_HEADER, +}; diff --git a/src/sse/server.js b/src/sse/server.js new file mode 100644 index 0000000..2962db6 --- /dev/null +++ b/src/sse/server.js @@ -0,0 +1,453 @@ +'use strict'; + +const { ServerTransport } = require('../transport.js'); +const { generateUUID } = require('../runtime/node.js'); +const { createLoggerWriter } = require('../logging.js'); +const { UNKNOWN_TARGET } = require('../rpc/dispatcher.js'); + +// Server-Sent Events as a wrpc transport. +// +// SSE is one-way, so a channel is two halves that find each other by id: +// GET {basePath}/events?channel= the server -> client stream +// POST {basePath} + x-wrpc-channel: the client -> server direction +// Both halves belong to ONE server-side Client, which is what lets a +// subscription started by a POST deliver its values down the GET stream. +// The POST answers 202 with no body; every reply — callbacks included — +// comes back on the stream, exactly like the Service Worker port transport. +// +// Serverless-friendly by construction: no upgrade, no long-lived socket +// beyond the response body, and nothing but HTTP in either direction. +// +// What it cannot do is binary: SSE frames are text, so wrpc's binary streams +// are refused on this transport rather than silently corrupted. + +const { CHANNEL_HEADER } = require('./constants.js'); + +const DEFAULT_RETENTION = 30 * 1000; +const DEFAULT_REPLAY = 100; +// The replay buffer is per-channel memory held for `retention` even with no +// stream attached — a byte budget caps it where a frame-count cap cannot +// (100 frames of 1 MB each is not "bounded"). +const DEFAULT_REPLAY_BYTES = 1024 * 1024; +const DEFAULT_HEARTBEAT = 15 * 1000; +const DEFAULT_RETRY = 2000; +// One GET is one channel held for `retention` even after the response dies, +// so channel creation is memory a peer can buy with plain HTTP requests — +// both caps are generous but present, like maxSubscriptions. +const DEFAULT_MAX_CHANNELS = 10000; +const DEFAULT_MAX_CHANNELS_PER_ADDRESS = 100; + +const SSE_HEADERS = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + // Nginx buffers proxied responses by default, which turns a live stream + // into one delivered at the end of time. + 'X-Accel-Buffering': 'no', +}; + +// A JSON packet never contains a raw newline (they are escaped inside +// strings), so one `data:` line always suffices. +const frame = (id, payload) => `id: ${id}\ndata: ${payload}\n\n`; + +class ServerSseTransport extends ServerTransport { + kind = 'sse'; + + // Persistent — it carries events and subscriptions — but text-only. + binary = false; + + #writer = null; + #closed = false; + + constructor(channelId, remoteAddress = '') { + super(remoteAddress); + this.channelId = channelId; + this.connection = this; // what Client.persistent checks + } + + get attached() { + return this.#writer !== null; + } + + attach(writer) { + this.#writer = writer; + // A producer parked on backpressure while this channel was detached + // writes nothing, so the new response's own 'drain' can never fire — + // a fresh sink IS the relief, and has to say so. + this.emit('drain'); + } + + /** True while `writer` is the one currently attached. */ + isCurrent(writer) { + return this.#writer === writer; + } + + detach() { + this.#writer = null; + } + + /** + * Writes one SSE event. Returns the sink's backpressure signal so the + * subscription pump can wait, exactly as it does on a socket. + */ + writeFrame(text) { + if (!this.#writer || this.#closed) return false; + try { + return this.#writer.write(text) !== false; + } catch { + // The response died under us; the close listener will clean up. + return false; + } + } + + write(data) { + if (typeof data !== 'string') { + throw new Error('SSE transport carries text only: binary streams need a WebSocket'); + } + return this.emitPacket(data); + } + + // Set by the channel, which owns the id sequence and the replay buffer. + emitPacket(payload) { + return this.onPacket ? this.onPacket(payload) : false; + } + + close() { + if (this.#closed) return; + this.#closed = true; + this.emit('close'); + } +} + +// One channel: a Client, its transport, the outbound id sequence and the +// replay buffer that makes Last-Event-ID mean something. +// +// `key` is the identity the channel was created under (the session token +// read from the opening GET's cookie, or '' for an anonymous peer). Every +// re-attach and every POST must present the same key: without the check the +// channel id alone is a bearer token for a session-carrying Client, and ids +// travel in URLs, which proxies and logs keep. +class SseChannel { + constructor({ id, key = '', client, transport, replay, replayBytes, retention }) { + this.id = id; + this.key = key; + this.client = client; + this.transport = transport; + this.retention = retention; + this.writer = null; + // A preallocated ring (slots + head + count), not push/shift: shift on + // every SSE event cost ~320 ns at the default replay=100 against ~7 ns + // for the ring — bench/replay-buffer.js; same shape as EventLog and + // SegmentQueue. Capacity is `replay` (the frame-count budget); the byte + // budget below can hold the COUNT lower, never higher. + this.buffer = new Array(replay); + this.head = 0; + this.count = 0; + this.replay = replay; + this.replayBytes = replayBytes; + this.bytes = 0; + this.cursor = 0; + this.timer = null; + transport.onPacket = (payload) => this.push(payload); + } + + // Drops the oldest retained frame and its bytes from the accounting. + #evict() { + const dropped = this.buffer[this.head]; + this.buffer[this.head] = undefined; + this.head = (this.head + 1) % this.replay; + this.count--; + this.bytes -= dropped.text.length; + } + + push(payload) { + const id = this.cursor++; + const text = frame(id, payload); + // Two budgets, both honest: frame count AND bytes. The count budget is + // the ring's own capacity — a full ring overwrites its oldest slot. + if (this.count === this.replay) this.#evict(); + this.buffer[(this.head + this.count) % this.replay] = { id, text }; + this.count++; + this.bytes += text.length; + while (this.count > 0 && this.replayBytes > 0 && this.bytes > this.replayBytes) this.#evict(); + return this.transport.writeFrame(text); + } + + /** + * Replays everything the peer says it did not see — or says so when it + * CANNOT. Frames the ring buffer already dropped are gone: silently + * resuming past them used to look like "nothing was missed". The `gap` + * frame is the client's signal to drop its channel state and start over. + */ + resume(lastEventId) { + const cursor = Number(lastEventId); + if (!Number.isFinite(cursor)) return null; + const oldest = this.count > 0 ? this.buffer[this.head].id : this.cursor; + if (cursor < oldest - 1) { + this.transport.writeFrame(`event: gap\ndata: ${JSON.stringify({ oldest })}\n\n`); + return 'gap'; + } + for (let i = 0; i < this.count; i++) { + const entry = this.buffer[(this.head + i) % this.replay]; + if (entry.id <= cursor) continue; + this.transport.writeFrame(entry.text); + // The sink died mid-replay: the rest would go nowhere. + if (!this.transport.attached) break; + } + return 'replay'; + } +} + +class SseChannels { + #channels = new Map(); + #byAddress = new Map(); // remoteAddress -> live channel count + #options; + #addClient; + #channelKey; + #clientAddress; + #log; + #otel; + + constructor({ addClient, channelKey = null, log = globalThis.console, otel = null, ...options } = {}) { + this.#addClient = addClient; + // Extracts the identity a request presents (the session token from its + // cookie); injected by the core, which owns the SessionManager. Without + // one every request presents the same identity and only the unguessable + // id protects the channel. + this.#channelKey = channelKey ?? (() => ''); + this.#log = createLoggerWriter(log); + this.#otel = otel; + // What maxChannelsPerAddress counts by. The default is the TCP peer — + // which behind a reverse proxy or L7 balancer is ONE address for every + // real client, so the 101st user through the proxy would be refused 429 + // at 1% of the node's budget. Deployments behind a proxy inject their + // own reader (X-Forwarded-For's client hop, a CDN header), duck-typed + // like the codec/logger seams, or set maxChannelsPerAddress: 0. + this.#clientAddress = + typeof options.clientAddress === 'function' ? options.clientAddress : (call) => call.remoteAddress ?? ''; + this.#options = { + retention: options.retention ?? DEFAULT_RETENTION, + replay: options.replay ?? DEFAULT_REPLAY, + heartbeat: options.heartbeat ?? DEFAULT_HEARTBEAT, + retry: options.retry ?? DEFAULT_RETRY, + replayBytes: options.replayBytes ?? DEFAULT_REPLAY_BYTES, + maxChannels: options.maxChannels ?? DEFAULT_MAX_CHANNELS, + maxChannelsPerAddress: options.maxChannelsPerAddress ?? DEFAULT_MAX_CHANNELS_PER_ADDRESS, + }; + } + + get size() { + return this.#channels.size; + } + + get(channelId) { + return this.#channels.get(channelId) ?? null; + } + + /** True when `headers` present the identity the channel was created under. */ + authorized(channel, requestHeaders) { + return channel.key === this.#channelKey(requestHeaders ?? {}); + } + + #refuse(call, headers, code, message) { + // A refusal that happens before any Client exists produced no log line + // and no metric anywhere — a 429ing capacity cap was invisible until a + // user complained. Counted on the same series as answered traffic + // (UNKNOWN_TARGET keeps cardinality flat). + this.#log.warn({ event: 'sse.refused', code, reason: message }); + this.#otel?.recordCall(UNKNOWN_TARGET, 'error', code); + const body = Buffer.from(JSON.stringify({ type: 'callback', id: '', error: { message, code } })); + call.respond({ status: code, headers: { ...headers, 'Content-Type': 'application/json' }, body }); + } + + /** + * Opens (or re-attaches) the server -> client half. + * `call.stream` is the seam every adapter implements; a host that cannot + * stream gets an honest 501 instead of a response that never arrives. + * + * Channel ids are SERVER-minted: a request without one gets a fresh + * channel and learns the id from the `ready` frame. A request naming an + * id re-attaches to that channel — 409 when it is unknown (expired, + * another instance, or a guess), 403 when the request's cookie does not + * present the identity the channel was created under. + * + * `headers` are RESPONSE headers (CORS and the rest); the REQUEST headers a + * new channel's client is built from come off `call` itself. + */ + open(call, { channelId = null, lastEventId = null, headers = {} } = {}) { + if (typeof call.stream !== 'function') { + return void call.respond({ + status: 501, + headers: { 'Content-Type': 'application/json' }, + body: Buffer.from( + JSON.stringify({ type: 'callback', id: '', error: { message: 'SSE unsupported', code: 501 } }), + ), + }); + } + const existing = channelId ? this.#channels.get(channelId) : null; + if (channelId && !existing) { + // Distinguishable from every other refusal: the client reacts to 409 + // by dropping its stale channel state and starting a fresh one. + return void this.#refuse(call, headers, 409, 'Unknown channel'); + } + if (existing && !this.authorized(existing, call.headers)) { + return void this.#refuse(call, headers, 403, 'Channel belongs to another session'); + } + if (!existing) { + const refusal = this.#capacity(this.#clientAddress(call)); + if (refusal) return void this.#refuse(call, headers, refusal.code, refusal.message); + } + const channel = existing ?? this.#create(call); + if (existing && channel.timer) { + clearTimeout(channel.timer); + channel.timer = null; + } + const writer = call.stream({ status: 200, headers: { ...headers, ...SSE_HEADERS } }); + if (!writer) { + // The host could not open the stream. A channel created for it would + // sit there with no writer, no retention timer and no close listener — + // unreachable and never collected. + if (!existing) channel.transport.close(); + return; + } + // Replacing a live writer: the superseded response is nobody's now, so + // end it rather than leaking it open until a proxy times it out. + const previous = channel.writer; + channel.writer = writer; + if (previous && previous !== writer) { + try { + previous.end(); + } catch (error) { + this.#log.error({ err: error, event: 'sse.supersede', channel: channel.id }); + } + } + channel.transport.attach(writer); + // The response's own high-water mark is the channel's backpressure, so + // the subscription pump waits on it exactly as it does on a socket. + writer.onDrain?.(() => channel.transport.emit('drain')); + // The client needs its channel id before it can POST anything, and this + // frame is the ONLY place the server hands the id out. + writer.write(`retry: ${this.#options.retry}\n\n`); + writer.write(`event: ready\ndata: ${JSON.stringify({ channel: channel.id })}\n\n`); + this.#otel?.recordSseEvent(existing ? 'reattach' : 'open'); + if (existing && lastEventId !== null) { + const outcome = channel.resume(lastEventId); + // A gap is REAL event loss: the peer is being told to start over, + // and until this line nothing on either side said so. + if (outcome === 'gap') { + this.#log.warn({ event: 'sse.gap', channel: channel.id, requested: lastEventId }); + } + if (outcome) this.#otel?.recordSseEvent(outcome); + } + this.#beat(channel, writer); + writer.onClose?.(() => this.#detach(channel, writer)); + return channel; + } + + #capacity(address) { + const { maxChannels, maxChannelsPerAddress } = this.#options; + if (maxChannels > 0 && this.#channels.size >= maxChannels) { + return { code: 503, message: 'Too many channels' }; + } + const held = this.#byAddress.get(address) ?? 0; + if (maxChannelsPerAddress > 0 && held >= maxChannelsPerAddress) { + return { code: 429, message: 'Too many channels from this address' }; + } + return null; + } + + // The GET that opens a channel is the channel's only handshake, so its + // headers are handed on: they carry the cookie the client's session is + // restored from, exactly as an upgrade's headers do for a socket. The id + // is minted here — never taken from the request — so holding one proves + // the server said it, and the cookie's token is captured as the key every + // later request must present again. + #create(call) { + const channelId = generateUUID(); + // The capacity key comes from the seam; the transport keeps the raw + // TCP peer, which is what client.meta reports. + const address = this.#clientAddress(call); + const transport = new ServerSseTransport(channelId, call.remoteAddress ?? ''); + // The whole call, not just its headers: the injected addClient builds + // the client's meta (headers, url, remoteAddress) from it too. + const client = this.#addClient(transport, call); + const key = this.#channelKey(call.headers ?? {}); + const channel = new SseChannel({ id: channelId, key, client, transport, ...this.#options }); + this.#channels.set(channelId, channel); + this.#byAddress.set(address, (this.#byAddress.get(address) ?? 0) + 1); + // Every teardown path — retention timeout, close(), a dead response — + // ends at transport.close(), which is idempotent, so this is the one + // place the gauge can come back down exactly once. + this.#otel?.recordSseChannel(1); + transport.once('close', () => { + this.#channels.delete(channelId); + const held = this.#byAddress.get(address) ?? 0; + if (held <= 1) this.#byAddress.delete(address); + else this.#byAddress.set(address, held - 1); + this.#otel?.recordSseChannel(-1); + }); + return channel; + } + + // Comment frames keep proxies (and some mobile networks) from deciding an + // idle response is a dead one. + #beat(channel, writer) { + if (!(this.#options.heartbeat > 0)) return; + const timer = setInterval(() => { + // Scoped to this writer: a superseded stream's heartbeat must not keep + // writing into the one that replaced it. + if (channel.transport.isCurrent(writer)) return void channel.transport.writeFrame(': ping\n\n'); + clearInterval(timer); + }, this.#options.heartbeat); + timer.unref?.(); + writer.onClose?.(() => clearInterval(timer)); + } + + // A dropped stream is usually a network blip, not a goodbye: the channel + // (and everything it is subscribed to) is held for `retention` so a + // reconnect can re-attach and replay instead of starting over. + #detach(channel, writer) { + // Scoped to the writer that actually closed. A response replaced by a + // reconnect closes LATER than the one that replaced it, and detaching on + // that would tear down a perfectly live stream. + if (!channel.transport.isCurrent(writer)) return; + channel.transport.detach(); + channel.writer = null; + if (channel.timer) clearTimeout(channel.timer); + channel.timer = setTimeout(() => { + this.#channels.delete(channel.id); + this.#log.debug({ event: 'sse.expired', channel: channel.id }); + this.#otel?.recordSseEvent('expired'); + channel.transport.close(); + }, channel.retention); + channel.timer.unref?.(); + } + + /** Ends every channel; used when the server itself closes. */ + close() { + for (const channel of Array.from(this.#channels.values())) { + if (channel.timer) clearTimeout(channel.timer); + this.#channels.delete(channel.id); + try { + // End the response too: a stream left open holds the socket, and a + // host waiting on `server.close()` would wait forever. + channel.writer?.end(); + channel.writer = null; + channel.transport.detach(); + channel.transport.close(); + } catch (error) { + this.#log.error({ err: error, event: 'sse.close', channel: channel.id }); + } + } + } +} + +module.exports = { + ServerSseTransport, + SseChannel, + SseChannels, + SSE_HEADERS, + CHANNEL_HEADER, + DEFAULT_RETENTION, + DEFAULT_REPLAY, +}; diff --git a/src/streams.js b/src/streams.js index 39d495a..e0abca8 100644 --- a/src/streams.js +++ b/src/streams.js @@ -10,6 +10,7 @@ const MAX_LISTENERS = 10; const MAX_HIGH_WATER_MARK = 1000; class WrpcReadable extends Emitter { + #consuming = false; queue = []; streaming = true; status = 'active'; @@ -25,8 +26,12 @@ class WrpcReadable extends Emitter { if (highWaterMark) this.highWaterMark = highWaterMark; } + // The high-water mark applies only once a consumer attached: before that + // nobody emits PULL_EVENT, so blocking here would deadlock producers whose + // consumer starts from a later packet on the same paused socket (the + // upload-then-call wire pattern). async push(data) { - while (this.queue.length > this.highWaterMark) { + while (this.#consuming && this.queue.length > this.highWaterMark) { this.checkStreamLimits(); await this.waitEvent(PULL_EVENT); } @@ -39,8 +44,14 @@ class WrpcReadable extends Emitter { const onError = () => this.terminate(); writable.once('error', onError); for await (const chunk of this) { + // A closed sink accepts nothing and will never emit 'drain' + if (writable.closed) { + await this.terminate(); + writable.removeListener('error', onError); + return; + } const needDrain = !writable.write(chunk); - if (needDrain) await writable.waitEvent('drain'); + if (needDrain && !writable.closed) await writable.waitEvent('drain'); } this.emit('end'); writable.end(); @@ -85,6 +96,7 @@ class WrpcReadable extends Emitter { } async read() { + this.#consuming = true; if (this.queue.length > 0) return this.pull(); const finisher = await this.waitEvent(PUSH_EVENT); if (finisher === null) return null; @@ -92,6 +104,7 @@ class WrpcReadable extends Emitter { } pull() { + this.#consuming = true; const data = this.queue.shift(); /* c8 ignore next -- read()/waitEvent(PUSH_EVENT) callers already guard against an empty queue */ if (!data) return data; @@ -125,33 +138,77 @@ class WrpcReadable extends Emitter { } class WrpcWritable extends Emitter { + #waitingDrain = false; + #closed = false; + #ended = false; + constructor(id, name, size, transport) { super(); this.id = id; this.name = name; this.size = size; this.transport = transport; + // Armed HERE, not lazily inside the backpressure branch: a stream that + // never happened to stall used to learn nothing when the transport + // closed — `closed` stayed false and a producer loop kept writing into + // the void. Now every writable notices the disconnect, backpressured + // or not. + if (typeof transport.once === 'function') { + transport.once('close', () => { + if (this.#closed) return; + this.#closed = true; + this.#releaseDrain(); + this.emit('close'); + }); + } this.init(); } + get closed() { + return this.#closed; + } + + #releaseDrain() { + if (!this.#waitingDrain) return; + this.#waitingDrain = false; + this.emit('drain'); + } + init() { const { id, name, size } = this; const packet = { type: 'stream', id, name, size }; this.transport.send(packet); } + // Reports the transport's real acceptance: false means the socket buffer + // is above its high-water mark — wait for this stream's 'drain' event. + // A false return can also mean the transport closed: check `closed` (or + // listen for 'close') — after it, no 'drain' will ever follow. + // Transports without flow-control reporting (browser WebSocket, HTTP) + // always count as accepted. write(data) { + if (this.#closed || this.#ended) return false; const chunk = chunkEncode(this.id, data); - this.transport.write(chunk); - return true; + const accepted = this.transport.write(chunk) !== false; + if (!accepted && !this.#waitingDrain && typeof this.transport.once === 'function') { + this.#waitingDrain = true; + this.transport.once('drain', () => this.#releaseDrain()); + } + return accepted; } + // Idempotent, and a no-op on a dead transport: the peer that would have + // read the status packet is gone. end() { + if (this.#closed || this.#ended) return; + this.#ended = true; const packet = { type: 'stream', id: this.id, status: 'end' }; this.transport.send(packet); } terminate() { + if (this.#closed || this.#ended) return; + this.#ended = true; const packet = { type: 'stream', id: this.id, status: 'terminate' }; this.transport.send(packet); } diff --git a/src/telemetry/client.js b/src/telemetry/client.js new file mode 100644 index 0000000..466b96d --- /dev/null +++ b/src/telemetry/client.js @@ -0,0 +1,167 @@ +'use strict'; + +// The client half. Smaller than the server's by design: a client owns one +// connection, so there is no fan-out, no rooms and no sessions to count. +// Browser-reachable, which is why it does not require the server half. + +const { + SPAN_KIND_CLIENT, + SETTER, + hasMethod, + resolvePropagation, + resolveTracerAndMeter, + DISABLED, + targetAttributes, + startSpanWith, + recordSpanError, + endSpanHandle, +} = require('./shared.js'); + +/** + * The client half. Smaller than the server's by design: a client owns one + * connection, so there is no fan-out, no rooms and no sessions to count. + * + * Its distinguishing job is `inject` — writing the active trace context into + * an outgoing packet, which is what makes a client span in one process the + * parent of a server span in another. + */ +const createClientTelemetry = (telemetry) => { + if (!telemetry || typeof telemetry !== 'object') return DISABLED; + + let tracer = null; + let meter = null; + try { + ({ tracer, meter } = resolveTracerAndMeter(telemetry)); + } catch { + return DISABLED; + } + if (!tracer && !meter) return DISABLED; + + const propagator = resolvePropagation(telemetry); + + let duration = null; + let reconnects = null; + let connections = null; + if (hasMethod(meter, 'createHistogram') && hasMethod(meter, 'createCounter')) { + try { + duration = meter.createHistogram('rpc.client.duration', { + unit: 'ms', + description: 'Duration of RPC calls made by this client', + }); + reconnects = meter.createCounter('wrpc.client.reconnects', { + unit: '{attempt}', + description: 'Reconnect attempts, by outcome', + }); + } catch { + duration = null; + reconnects = null; + } + } + let refreshes = null; + if (hasMethod(meter, 'createCounter')) { + try { + refreshes = meter.createCounter('wrpc.client.refreshes', { + unit: '{run}', + description: 'Credential refresh runs, by outcome', + }); + } catch { + refreshes = null; + } + } + if (hasMethod(meter, 'createUpDownCounter')) { + try { + connections = meter.createUpDownCounter('wrpc.client.connections', { + unit: '{connection}', + description: 'Live client connections', + }); + } catch { + connections = null; + } + } + + return { + enabled: true, + + withSpan({ packet, target, suffix = '' }, fn) { + const handle = { span: null, error: false }; + if (!tracer) return fn(handle); + const attributes = { + 'rpc.system': 'wrpc', + ...targetAttributes(target), + 'wrpc.packet.type': packet?.type, + }; + if (packet?.id) attributes['wrpc.packet.id'] = packet.id; + return startSpanWith(tracer, `${target}${suffix}`, { kind: SPAN_KIND_CLIENT, attributes }, null, handle, fn); + }, + + /** + * Writes the active trace context into `packet` as `tp`/`ts`. A no-op + * without a propagator — you cannot serialize W3C context without one, + * and wrpc refuses to hand-roll that. + */ + inject(packet) { + if (!propagator) return; + try { + const active = propagator.context?.active?.(); + propagator.propagation.inject(active, packet, SETTER); + } catch {} + }, + + /** + * Writes the active trace context into a plain header bag under the + * REAL W3C names (traceparent/tracestate) — the REST leg's carrier, + * where there is no packet to hold tp/ts. The propagator's default + * setter does exactly that, so no adapter is needed. + */ + injectHeaders(carrier) { + if (!propagator) return; + try { + const active = propagator.context?.active?.(); + propagator.propagation.inject(active, carrier); + } catch {} + }, + + recordError(handle, error) { + recordSpanError(handle, error); + }, + + endSpan(handle, attributes) { + endSpanHandle(handle, attributes); + }, + + recordCall(target, status, elapsed) { + try { + if (elapsed !== undefined) { + duration?.record(elapsed, { 'rpc.system': 'wrpc', ...targetAttributes(target), 'wrpc.status': status }); + } + } catch {} + }, + + recordReconnect(outcome, attempt) { + try { + // The attempt COUNT stays off the attributes: an unbounded integer + // as a label is a new time series per value. Every SCHEDULED attempt + // is recorded once (outcome 'attempted'), so the rate of that series + // is the reconnect pressure an operator alerts on; 'recovered' and + // 'exhausted' are the terminal markers saying how episodes end — a + // storm that keeps recovering is no longer invisible. + void attempt; + reconnects?.add(1, { 'wrpc.reconnect.outcome': outcome }); + } catch {} + }, + + recordRefresh(outcome) { + try { + refreshes?.add(1, { 'wrpc.refresh.outcome': outcome }); + } catch {} + }, + + recordConnection(delta) { + try { + connections?.add(delta); + } catch {} + }, + }; +}; + +module.exports = { createClientTelemetry }; diff --git a/src/telemetry/index.js b/src/telemetry/index.js new file mode 100644 index 0000000..c0a8785 --- /dev/null +++ b/src/telemetry/index.js @@ -0,0 +1,29 @@ +'use strict'; + +// The node-side barrel. `src/client.js` deliberately requires +// ./telemetry/client.js directly instead of this file: requiring the barrel +// would pull the server half into every browser bundle. + +const { createServerTelemetry } = require('./server.js'); +const { createClientTelemetry } = require('./client.js'); +const { + SCOPE_NAME, + SPAN_STATUS_ERROR, + SPAN_KIND_SERVER, + SPAN_KIND_CLIENT, + SPAN_KIND_CONSUMER, + TRACEPARENT, + TRACESTATE, +} = require('./shared.js'); + +module.exports = { + createServerTelemetry, + createClientTelemetry, + SCOPE_NAME, + SPAN_STATUS_ERROR, + SPAN_KIND_SERVER, + SPAN_KIND_CLIENT, + SPAN_KIND_CONSUMER, + TRACEPARENT, + TRACESTATE, +}; diff --git a/src/telemetry/server.js b/src/telemetry/server.js new file mode 100644 index 0000000..aac2f22 --- /dev/null +++ b/src/telemetry/server.js @@ -0,0 +1,310 @@ +'use strict'; + +// The server half: spans for calls, subscriptions and inbound events, plus +// the twelve instruments a server has anything to say about. Node-only — +// nothing browser-reachable requires this file. + +const { + SPAN_KIND_SERVER, + TRACEPARENT, + GETTER, + SETTER, + hasMethod, + resolvePropagation, + resolveTracerAndMeter, + DISABLED, + targetAttributes, + startSpanWith, + recordSpanError, + endSpanHandle, +} = require('./shared.js'); + +// A target is the wire `method` string: 'unit/name' or 'unit.ver/name'. The +// span name is that string verbatim, following the OTel `rpc.*` convention +// ($service/$method) rather than inventing a wrpc-specific scheme — APM +// tools group RPC spans by exactly this. +const buildCallAttributes = (client, packet, target, includeIdentity) => { + const attributes = { + 'rpc.system': 'wrpc', + ...targetAttributes(target), + 'wrpc.packet.type': packet.type, + 'wrpc.transport': client.transportKind, + 'wrpc.persistent': client.persistent, + }; + if (packet.id) attributes['wrpc.packet.id'] = packet.id; + // `client.source` is a remote address, so it rides behind the PII gate. + // A session TOKEN never appears at any setting: that is a credential, not + // an identity, and the two must not share one switch. + if (includeIdentity && client.source) attributes['network.peer.address'] = client.source; + return attributes; +}; + +const createServerTelemetry = (telemetry) => { + if (!telemetry || typeof telemetry !== 'object') return DISABLED; + + let tracer = null; + let meter = null; + try { + ({ tracer, meter } = resolveTracerAndMeter(telemetry)); + } catch { + return DISABLED; + } + if (!tracer && !meter) return DISABLED; + + const includeIdentity = telemetry.includeIdentity !== false; + // Accepting an inbound traceparent means trusting a peer not to forge one. + // gRPC and every HTTP instrumentation make the same call, and the + // mitigation belongs at ingress; defaulting to false would mean end-to-end + // tracing did nothing out of the box. + const trustRemote = telemetry.trustRemoteContext !== false; + const propagator = resolvePropagation(telemetry); + + let duration = null; + let calls = null; + let connections = null; + let subscriptions = null; + let subscriptionValues = null; + let broadcasts = null; + let recipients = null; + let streamBytes = null; + let backpressure = null; + let sessions = null; + let sseChannels = null; + let clusterMessages = null; + let clusterRequests = null; + let clusterInstances = null; + let sseEvents = null; + + // Checked separately from the others: a meter with counters and histograms + // but no up/down counter would otherwise disable every instrument here. + const canCount = hasMethod(meter, 'createCounter') && hasMethod(meter, 'createHistogram'); + const canGauge = hasMethod(meter, 'createUpDownCounter'); + if (canCount) { + try { + duration = meter.createHistogram('rpc.server.duration', { + unit: 'ms', + description: 'Duration of RPC calls answered by this server', + }); + calls = meter.createCounter('wrpc.server.calls', { + unit: '{call}', + description: 'RPC calls answered, by outcome and status code', + }); + subscriptionValues = meter.createCounter('wrpc.server.subscription.values', { + unit: '{value}', + description: 'Values yielded to subscribers', + }); + broadcasts = meter.createCounter('wrpc.server.broadcasts', { + unit: '{event}', + description: 'Room broadcasts, by whether they also went to the backplane', + }); + recipients = meter.createHistogram('wrpc.server.broadcast.recipients', { + unit: '{client}', + description: 'Local fan-out size of a broadcast', + }); + streamBytes = meter.createCounter('wrpc.server.stream.bytes', { + unit: 'By', + description: 'Binary stream bytes, by direction', + }); + backpressure = meter.createCounter('wrpc.server.backpressure', { + unit: '{event}', + description: 'Times a producer parked waiting for the transport to drain', + }); + sessions = meter.createCounter('wrpc.server.sessions', { + unit: '{operation}', + description: 'Session operations, by kind and result', + }); + clusterMessages = meter.createCounter('wrpc.cluster.messages', { + unit: '{message}', + description: 'Backplane envelopes received, by type', + }); + clusterRequests = meter.createCounter('wrpc.cluster.requests', { + unit: '{request}', + description: 'Cluster requests settled, by op and completeness', + }); + sseEvents = meter.createCounter('wrpc.server.sse.events', { + unit: '{event}', + description: 'SSE channel lifecycle events (open/reattach/replay/gap/expired)', + }); + } catch { + duration = null; + calls = null; + subscriptionValues = null; + broadcasts = null; + recipients = null; + streamBytes = null; + backpressure = null; + sessions = null; + clusterMessages = null; + clusterRequests = null; + sseEvents = null; + } + } + if (canGauge) { + try { + connections = meter.createUpDownCounter('wrpc.server.connections', { + unit: '{connection}', + description: 'Live connections, by transport', + }); + subscriptions = meter.createUpDownCounter('wrpc.server.subscriptions', { + unit: '{subscription}', + description: 'Live subscriptions', + }); + sseChannels = meter.createUpDownCounter('wrpc.server.sse.channels', { + unit: '{channel}', + description: 'Live SSE channels', + }); + clusterInstances = meter.createUpDownCounter('wrpc.cluster.instances', { + unit: '{instance}', + description: 'Peer instances this node currently sees on the backplane', + }); + } catch { + connections = null; + subscriptions = null; + sseChannels = null; + clusterInstances = null; + } + } + + // The parent context carried by the packet, or null. Extraction is what + // links a client span in one process to the server span in another. + const extract = (packet) => { + if (!propagator || !trustRemote) return null; + if (typeof packet?.[TRACEPARENT] !== 'string') return null; + try { + const root = propagator.context?.active?.() ?? undefined; + return propagator.propagation.extract(root, packet, GETTER) ?? null; + } catch { + return null; + } + }; + + return { + enabled: true, + + /** + * Opens a span around one packet's whole lifetime and invokes `fn(handle)` + * exactly once. The handle is mutable — `{ span, error }` — rather than a + * raw span, because the span is NOT ended here: `endSpan` does that from + * the caller's `finally`, which is the only place that knows the call + * actually finished. + */ + withSpan({ client, packet, target, kind = SPAN_KIND_SERVER, suffix = '' }, fn) { + const handle = { span: null, error: false }; + if (!tracer) return fn(handle); + const attributes = buildCallAttributes(client, packet, target, includeIdentity); + return startSpanWith(tracer, `${target}${suffix}`, { kind, attributes }, extract(packet), handle, fn); + }, + + recordError(handle, error, code) { + recordSpanError(handle, error, code); + }, + + endSpan(handle, attributes) { + endSpanHandle(handle, attributes); + }, + + // Fires with or without a tracer: a meter-only configuration is a + // supported way to run wrpc. + recordCall(target, status, code, elapsed) { + try { + const attributes = { 'rpc.system': 'wrpc', ...targetAttributes(target), 'wrpc.status': status }; + if (code !== undefined) attributes['rpc.wrpc.status_code'] = code; + calls?.add(1, attributes); + if (elapsed !== undefined) duration?.record(elapsed, attributes); + } catch {} + }, + + recordConnection(delta, transport) { + try { + connections?.add(delta, { 'wrpc.transport': transport }); + } catch {} + }, + + recordSubscription(delta, target) { + try { + subscriptions?.add(delta, targetAttributes(target)); + } catch {} + }, + + recordSubscriptionValues(count, target) { + try { + if (count > 0) subscriptionValues?.add(count, targetAttributes(target)); + } catch {} + }, + + recordBroadcast(name, local, published) { + try { + broadcasts?.add(1, { 'wrpc.event.name': name, 'wrpc.published': Boolean(published) }); + recipients?.record(local, { 'wrpc.event.name': name }); + } catch {} + }, + + recordStreamBytes(direction, bytes) { + try { + streamBytes?.add(bytes, { 'wrpc.stream.direction': direction }); + } catch {} + }, + + recordBackpressure(transport) { + try { + backpressure?.add(1, { 'wrpc.transport': transport }); + } catch {} + }, + + recordSession(operation, result) { + try { + sessions?.add(1, { 'wrpc.session.op': operation, 'wrpc.session.result': result }); + } catch {} + }, + + recordSseChannel(delta) { + try { + sseChannels?.add(delta); + } catch {} + }, + + recordSseEvent(kind) { + try { + // A closed kind set: open | reattach | replay | gap | expired. The + // gap and expired series are REAL event loss — what replay sizing + // is tuned from. + sseEvents?.add(1, { 'wrpc.sse.event': kind }); + } catch {} + }, + + recordClusterMessage(type) { + try { + // The envelope type set is closed (hello/state/delta/bye/e/cmd/q/a) + // — a bounded label, unlike anything peer-named. + clusterMessages?.add(1, { 'wrpc.cluster.type': type }); + } catch {} + }, + + recordClusterRequest(op, complete) { + try { + clusterRequests?.add(1, { 'wrpc.cluster.op': op, 'wrpc.cluster.complete': complete === true }); + } catch {} + }, + + recordClusterInstances(delta) { + try { + clusterInstances?.add(delta); + } catch {} + }, + + /** + * Writes the active trace context into a node-to-node envelope as + * tp/ts — the cluster's cross-node hop is where a trace is most + * valuable and used to be exactly where context was dropped. + */ + inject(carrier) { + if (!propagator) return; + try { + const active = propagator.context?.active?.(); + propagator.propagation.inject(active, carrier, SETTER); + } catch {} + }, + }; +}; + +module.exports = { createServerTelemetry }; diff --git a/src/telemetry/shared.js b/src/telemetry/shared.js new file mode 100644 index 0000000..056e441 --- /dev/null +++ b/src/telemetry/shared.js @@ -0,0 +1,223 @@ +'use strict'; + +// OpenTelemetry with no dependency on OpenTelemetry. +// +// Nothing here imports anything: the caller injects either the +// `@opentelemetry/api` module or pre-built tracer/meter instances, and every +// one of them is duck-typed. +// +// The file is split three ways — shared, server, client — for one measured +// reason: `src/client.js` is browser-reachable, and pulling the server's +// twelve instruments into a browser bundle put it over its size budget. The +// browser gets shared + client and nothing else. + +// 0 = UNSET, 1 = OK, 2 = ERROR. Frozen by the OTel specification, so +// hardcoding it is what avoids importing @opentelemetry/api for a constant. +const SPAN_STATUS_ERROR = 2; + +// Instrumentation scope. The version argument of getTracer/getMeter is +// deliberately omitted: reading it would mean requiring package.json, which +// would drag this file's whole dependency graph into browser bundles. +const SCOPE_NAME = '@alexify/wrpc'; + +// SpanKind, also frozen by the spec: 0 INTERNAL, 1 SERVER, 2 CLIENT, +// 3 PRODUCER, 4 CONSUMER. +const SPAN_KIND_SERVER = 1; +const SPAN_KIND_CLIENT = 2; +const SPAN_KIND_CONSUMER = 4; + +// The two packet fields that carry W3C trace context. Short on purpose: +// they ride on every call packet, and 'traceparent' would cost 11 more bytes +// per call than 'tp' for no gain. +const TRACEPARENT = 'tp'; +const TRACESTATE = 'ts'; + +// wrpc does NOT parse or serialize W3C trace context — that is spec surface +// that drifts. The propagator the user configured does the work; these two +// adapters only rename its header keys to the packet's field names. +const SETTER = { + set(packet, key, value) { + if (key === 'traceparent') packet[TRACEPARENT] = value; + else if (key === 'tracestate') packet[TRACESTATE] = value; + }, +}; + +// At most two keys can ever be present, so the four possible answers are +// prebuilt: Object.keys().filter() walked the whole packet and allocated two +// arrays per inbound traced packet to rediscover one of these every time. +const NO_KEYS = Object.freeze([]); +const TRACEPARENT_ONLY = Object.freeze([TRACEPARENT]); +const TRACESTATE_ONLY = Object.freeze([TRACESTATE]); +const BOTH_KEYS = Object.freeze([TRACEPARENT, TRACESTATE]); + +const GETTER = { + keys: (packet) => { + const hasParent = packet[TRACEPARENT] !== undefined; + const hasState = packet[TRACESTATE] !== undefined; + if (hasParent) return hasState ? BOTH_KEYS : TRACEPARENT_ONLY; + return hasState ? TRACESTATE_ONLY : NO_KEYS; + }, + get(packet, key) { + if (key === 'traceparent') return packet[TRACEPARENT]; + if (key === 'tracestate') return packet[TRACESTATE]; + return undefined; + }, +}; + +// Injecting is impossible without a propagator, so `{ tracer, meter }` alone +// leaves propagation off unless `propagation` is supplied too. +const resolvePropagation = (telemetry) => { + const propagation = telemetry.api?.propagation ?? telemetry.propagation ?? null; + const context = telemetry.api?.context ?? telemetry.context ?? null; + if (!hasMethod(propagation, 'inject') || !hasMethod(propagation, 'extract')) return null; + return { propagation, context }; +}; + +const hasMethod = (value, name) => typeof value?.[name] === 'function'; + +/** + * Two injection modes: + * - `{ api }` — the @opentelemetry/api module, from which wrpc derives its + * own tracer and meter so spans carry the right instrumentation scope. + * - `{ tracer, meter }` — pre-built instances; either may be absent, and a + * tracer-only or meter-only configuration is fully supported. + */ +const resolveTracerAndMeter = (telemetry) => { + if (telemetry.api) { + const { api } = telemetry; + return { + tracer: hasMethod(api.trace, 'getTracer') ? api.trace.getTracer(SCOPE_NAME) : null, + meter: hasMethod(api.metrics, 'getMeter') ? api.metrics.getMeter(SCOPE_NAME) : null, + }; + } + return { tracer: telemetry.tracer ?? null, meter: telemetry.meter ?? null }; +}; + +const noop = () => {}; + +// The OTel RPC semantic conventions split a target into service ($unit) and +// method: one helper, used by spans AND metrics on both halves, so the two +// never disagree about what 'rpc.method' means. +const targetAttributes = (target) => { + const slash = target.indexOf('/'); + return { + 'rpc.service': slash > 0 ? target.slice(0, slash) : target, + 'rpc.method': slash > 0 ? target.slice(slash + 1) : '', + }; +}; + +// Opens a span (active when the tracer can, detached otherwise) and invokes +// `fn(handle)` exactly once. Shared verbatim by both halves — the arity +// check on startActiveSpan and the invoked-guard are hazards nobody should +// maintain twice. +const startSpanWith = (tracer, name, options, parent, handle, fn) => { + if (hasMethod(tracer, 'startActiveSpan')) { + let invoked = false; + try { + const run = (span) => { + handle.span = span ?? null; + invoked = true; + return fn(handle); + }; + // The 4-argument overload is not universal: handing four arguments + // to a 3-argument implementation means the callback is never called + // at all, so the arity is checked rather than assumed. + const withParent = parent && tracer.startActiveSpan.length >= 4; + return withParent + ? tracer.startActiveSpan(name, options, parent, run) + : tracer.startActiveSpan(name, options, run); + } catch (error) { + // An error thrown by `fn` itself must propagate untouched; only a + // tracer that broke BEFORE running the callback is swallowed. + if (invoked) throw error; + return fn(handle); + } + } + try { + if (!hasMethod(tracer, 'startSpan')) handle.span = null; + else handle.span = parent ? tracer.startSpan(name, options, parent) : tracer.startSpan(name, options); + } catch { + handle.span = null; + } + return fn(handle); +}; + +const recordSpanError = (handle, error, code) => { + try { + handle.error = true; + const span = handle?.span; + if (!span) return; + span.recordException?.(error); + span.setStatus?.({ code: SPAN_STATUS_ERROR, message: error?.message }); + if (code !== undefined) span.setAttribute?.('rpc.wrpc.status_code', code); + if (error?.name) span.setAttribute?.('error.type', error.name); + } catch { + // Telemetry must never break the request path. + } +}; + +const endSpanHandle = (handle, attributes) => { + try { + const span = handle?.span; + if (!span) return; + if (attributes) { + // for...in over a literal built in this package — no entries array, and + // no [key, value] pair object, per span end i.e. per traced call. + for (const key in attributes) { + const value = attributes[key]; + if (value !== undefined && value !== null) span.setAttribute?.(key, value); + } + } + span.end(); + } catch {} +}; + +const DISABLED = Object.freeze({ + enabled: false, + // The lifecycle wrapper still runs its callback — that is what lets the + // dispatcher bracket a call the same way whether telemetry is on or off. + withSpan(_options, fn) { + return fn(null); + }, + recordError: noop, + endSpan: noop, + recordCall: noop, + recordConnection: noop, + recordSubscription: noop, + recordSubscriptionValues: noop, + recordBroadcast: noop, + recordStreamBytes: noop, + recordBackpressure: noop, + recordSession: noop, + recordSseChannel: noop, + recordSseEvent: noop, + recordClusterMessage: noop, + recordClusterRequest: noop, + recordClusterInstances: noop, + // Client-side members: one disabled writer serves both halves, so it has + // to answer to everything either of them exposes. + recordReconnect: noop, + recordRefresh: noop, + inject: noop, + injectHeaders: noop, +}); + +module.exports = { + targetAttributes, + startSpanWith, + recordSpanError, + endSpanHandle, + SPAN_STATUS_ERROR, + SCOPE_NAME, + SPAN_KIND_SERVER, + SPAN_KIND_CLIENT, + SPAN_KIND_CONSUMER, + TRACEPARENT, + TRACESTATE, + SETTER, + GETTER, + hasMethod, + resolvePropagation, + resolveTracerAndMeter, + DISABLED, +}; diff --git a/src/transport.js b/src/transport.js index 9aed92d..2e60e0b 100644 --- a/src/transport.js +++ b/src/transport.js @@ -2,136 +2,371 @@ const http = require('node:http'); -const { Emitter } = require('./utils.js'); - -const parseHost = (host) => { - if (!host) return 'no-host-name-in-http-headers'; - const portOffset = host.indexOf(':'); - if (portOffset > -1) return host.substring(0, portOffset); - return host; -}; +const { Emitter, toKebab } = require('./utils.js'); +const { META_HEADER, META_PREFIX, CHANNEL_HEADER } = require('./wire.js'); +// RFC 6265 permits '=' inside cookie values (base64, JWT) — split each +// pair on the FIRST '=' only, or the value gets silently truncated. const parseCookies = (cookie) => { const values = []; const items = cookie.split(';'); for (const item of items) { - const parts = item.split('='); - const key = parts[0].trim(); - const val = (parts[1] ?? '').trim(); + const eq = item.indexOf('='); + const key = (eq < 0 ? item : item.slice(0, eq)).trim(); + const val = eq < 0 ? '' : item.slice(eq + 1).trim(); values.push([key, val]); } return Object.fromEntries(values); }; -const HEADERS = { - 'X-XSS-Protection': '1; mode=block', +const SECURITY_HEADERS = { 'X-Content-Type-Options': 'nosniff', 'Strict-Transport-Security': 'max-age=31536000; includeSubdomains; preload', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', 'Content-Type': 'application/json', + // The HTTP side's version marker, echoed on every response — the ws + // subprotocol ladder's counterpart (protocol.md#versioning). A request + // MAY send `wrpc-version`; revision 1 accepts and ignores it, which is + // exactly what reserves the negotiation seam inside the 1.0 freeze. + 'wrpc-version': '1', +}; + +const DEFAULT_CORS_METHODS = 'POST, GET, OPTIONS'; +// `x-wrpc-channel` (which event stream a POST belongs to), `last-event-id` +// (the SSE resume header) and `x-wrpc-meta` (per-request client metadata) +// are not CORS-safelisted, so a cross-origin peer's preflight fails before +// the request is ever sent unless they are named here. An application whose +// clients declare CUSTOM connection headers over http/sse must widen the +// list via `cors.headers` the same way. +const DEFAULT_CORS_HEADERS = `Content-Type, ${CHANNEL_HEADER}, last-event-id, ${META_HEADER}`; + +const CORS_META_PREFIX = META_PREFIX; +// `headers` accepts an array purely so composing a list stays readable; the +// string form is what the header value has always been and is untouched. +const corsHeaderList = (value) => (Array.isArray(value) ? value.join(', ') : value); +// The per-key meta spelling (metaFormat: 'prefixed') sends one real request +// header per key, and CORS has no wildcard for header names — so every key a +// cross-origin client will send has to be named. Run through toKebab so +// `metaHeaders: ['userId']` grants `x-wrpc-meta-user-id`: the name the client +// actually sends, not the one the config happened to spell. +// +// Memoized on the cors OBJECT: the string derives only from the immutable +// option (assigned once in RpcServer's constructor), yet buildHeaders runs +// on every HTTP call — preflight, packet POST, REST, SSE — and the toKebab +// regex passes made it cost ~1.9 µs/request with six camelCase metaHeaders, +// against ~20 ns for the whole buildHeaders once memoized +// (bench/cors-headers.js). Only the STRING is cached; +// the headers object stays per-request (it carries per-origin fields and is +// mutated by callers). +const ALLOW_CACHE = new WeakMap(); +const allowedHeaders = (cors) => { + if (!cors) return DEFAULT_CORS_HEADERS; + const cached = ALLOW_CACHE.get(cors); + if (cached !== undefined) return cached; + const declared = corsHeaderList(cors.headers) ?? DEFAULT_CORS_HEADERS; + const meta = cors.metaHeaders; + let allow = declared; + if (meta && meta.length > 0) { + for (let i = 0; i < meta.length; i++) allow += `, ${CORS_META_PREFIX}${toKebab(meta[i])}`; + } + ALLOW_CACHE.set(cors, allow); + return allow; }; -const buildHeaders = (cors) => { - if (!cors || !cors.origin) return HEADERS; - return { ...HEADERS, 'Access-Control-Allow-Origin': cors.origin }; +// CORS v2: `cors` is { origins: string[] | (origin) => boolean, credentials?, +// headers?, metaHeaders?, methods? }. Without a `cors` option every origin is allowed +// (wildcard, credentials-less) — the pre-F2 behavior. With `origins`, the +// request origin is echoed back only when allowed, plus `Vary: Origin`. +const buildHeaders = (cors, origin) => { + const headers = { + ...SECURITY_HEADERS, + 'Access-Control-Allow-Methods': cors?.methods ?? DEFAULT_CORS_METHODS, + 'Access-Control-Allow-Headers': allowedHeaders(cors), + }; + if (!cors || !cors.origins) { + headers['Access-Control-Allow-Origin'] = '*'; + return headers; + } + headers['Vary'] = 'Origin'; + const allowed = + typeof cors.origins === 'function' ? Boolean(origin && cors.origins(origin)) : cors.origins.includes(origin); + if (allowed) { + headers['Access-Control-Allow-Origin'] = origin; + if (cors.credentials) headers['Access-Control-Allow-Credentials'] = 'true'; + } + return headers; }; -const TOKEN = 'token'; -const EPOCH = 'Thu, 01 Jan 1970 00:00:00 GMT'; -const FUTURE = 'Fri, 01 Jan 2100 00:00:00 GMT'; -const LOCATION = 'Path=/; Domain'; -const COOKIE_DELETE = `${TOKEN}=deleted; Expires=${EPOCH}; ${LOCATION}=`; -const COOKIE_HOST = `Expires=${FUTURE}; ${LOCATION}`; +const isOriginAllowed = (cors, origin) => { + if (!cors || !cors.origins) return true; + if (!origin) return true; // non-browser peers send no Origin header + if (typeof cors.origins === 'function') return Boolean(cors.origins(origin)); + return cors.origins.includes(origin); +}; + +// What the peer is told. 4xx messages are written for the caller +// (validation, quotas, refusals) and travel as-is; a 5xx message is a server +// internal — an uncaught exception's text can carry paths, queries or stack +// fragments — so the peer gets the status line and the details stay in the +// server log, unless the error opts in with `expose = true` (which the +// router's own coded errors do: their messages are part of the protocol). +// The packet id is the correlation: the same id is on the server log line. +const publicErrorMessage = (code, error) => { + const status = http.STATUS_CODES[code] || 'Unknown error'; + if (!error) return status; + if (code < 500 || error.expose === true) return error.message; + return status; +}; + +// `details` follows the exact same rule as the message: structured issue +// lists (validation paths, quota numbers) are part of the 4xx conversation, +// while a 5xx's internals stay in the log unless the error opts in. +const publicErrorDetails = (code, error) => { + if (!error || error.details === undefined) return undefined; + if (code < 500 || error.expose === true) return error.details; + return undefined; +}; + +// The one builder for the wire error object, so every packet that carries +// an error ({type:'callback'} and {type:'end'} alike) redacts identically. +// The `details` key is omitted entirely when there is nothing to say — +// an optional field, absent rather than null, per the protocol's +// additive-fields rule. +const wireError = (code, error) => { + const wire = { message: publicErrorMessage(code, error), code }; + const details = publicErrorDetails(code, error); + if (details !== undefined) wire.details = details; + return wire; +}; class ServerTransport extends Emitter { + // Which wire this is, for log entries and metric attributes. Subclasses + // override it; the base value covers a transport nobody labelled. + kind = 'unknown'; + constructor(source) { - super(); + // No listener cap: transports are fan-out points — every backpressured + // outbound stream on the connection parks a once('drain'|'close') + // listener here, and the default cap of 10 would throw on the 11th + // concurrently stalled stream. + super({ maxListeners: Number.MAX_SAFE_INTEGER }); this.source = source; } error(code = 500, { id = '', error = null } = {}) { - const status = http.STATUS_CODES[code] || 'Unknown error'; - const info = error ? error.message : status; - const packet = { type: 'callback', id, error: { message: info, code } }; - this.send(packet, code); + const packet = { type: 'callback', id, error: wireError(code, error) }; + return this.send(packet, code); } - send(obj, code = 200) { - this.write(JSON.stringify(obj), code); + // Returns the transport's backpressure signal (false = above the + // high-water mark) so a producer — a subscription pump, a stream — can + // wait for 'drain' instead of buffering without limit. + // + // `text` is the already-serialized form of `obj` when the dispatcher's + // compiled-serializer fast path built one (see handleRpc); passing both + // keeps the object available to the overrides that need it (batch + // collection, REST unwrapping) while the plain path skips a stringify. + send(obj, code = 200, text = null) { + // An injected codec (RpcServer options.codec, assigned per transport) + // re-frames every packet; it wins over precompiled `text` by + // construction — the server refuses codec + serializers up front. + if (this.codec) return this.write(this.codec.encode(obj), code); + return this.write(text ?? JSON.stringify(obj), code); } } +// Net-free HTTP transport over an abstract call description: +// { method, url, headers, body?, remoteAddress?, respond({ status, headers, body }) }. +// The node Server shell and the framework adapters both speak this shape. class ServerHttpTransport extends ServerTransport { - constructor(req, res, options = {}) { - super(req.socket.remoteAddress); - this.req = req; - this.res = res; - this.headers = options.headers ?? HEADERS; - if (req.method === 'OPTIONS') this.options(); - req.on('close', () => void this.emit('close')); + kind = 'http'; + + #respond; + #responded = false; + #setCookies = []; + #batch = null; // requested ids, in order — null outside batch mode + #collected = []; + // Declarative-route mode: the response body is the PLAIN result (or the + // wire error object), not a callback envelope — external REST semantics. + // `{ status }` carries the route's success status; null everywhere else. + #rest = null; + + constructor(call, options = {}) { + super(call.remoteAddress ?? ''); + this.call = call; + this.headers = options.headers ?? { ...SECURITY_HEADERS }; + this.#respond = call.respond; + if (Array.isArray(options.batch)) this.#batch = options.batch; + if (options.rest) this.#rest = options.rest; } - write(data, httpCode = 200) { - const { res } = this; - if (res.writableEnded) return; - const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); - const headers = { ...this.headers, 'Content-Length': buf.length }; - res.writeHead(httpCode, headers); - res.end(buf); + get responded() { + return this.#responded; } - options() { - const { res } = this; - if (res.headersSent) return; - res.writeHead(200, this.headers); - res.end(); + get batched() { + return this.#batch !== null; + } + + // A batch frame gets ONE response carrying every answer. They are held + // until the last one arrives and then emitted in the order the packets + // were sent, so a caller can zip requests to responses positionally + // without depending on how fast each handler happened to be. + // Both special modes ignore `text` deliberately: batch mode collects the + // OBJECTS to re-order and re-serialize as one frame, and REST mode writes + // the bare result rather than the callback envelope the text carries. + send(obj, code = 200, text = null) { + if (this.#rest && obj.type === 'callback') { + // The REST body codec (codec.rest) encodes VALUES — the plain result + // and the wire error object — never envelopes. Absent, JSON as ever. + const codec = this.#rest.codec ?? null; + if (obj.error) { + return this.write(codec ? codec.encode(obj.error) : JSON.stringify(obj.error), obj.error.code ?? code); + } + const status = this.#rest.status ?? 200; + // 204 promises "no content": the result (if any) is discarded on the + // wire by contract, not by accident. + if (status === 204) return this.write('', 204); + // An undefined result travels as an encoded `null` — one documented + // behaviour with and without a codec. + if (obj.result === undefined) return this.write(codec ? codec.encode(null) : 'null', status); + return this.write(codec ? codec.encode(obj.result) : JSON.stringify(obj.result), status); + } + if (!this.#batch) return super.send(obj, code, text); + if (this.#responded) return true; + this.#collected.push(obj); + if (this.#collected.length < this.#batch.length) return true; + const ordered = this.#ordered(); + return this.write(this.codec ? this.codec.encode(ordered) : JSON.stringify(ordered), 200); + } + + // Building an id index makes this O(n), but the Map costs more than the + // quadratic it removes until the batch gets big: the linear scan wins 1.31x + // at 4 answers and 1.08x at 8, and loses 1.20x at 16 and 3.92x at 128. + // Re-run bench/batch-ordering.js before moving this. + static #INDEX_THRESHOLD = 12; + + #ordered() { + const collected = this.#collected; + if (collected.length >= ServerHttpTransport.#INDEX_THRESHOLD) return this.#orderedIndexed(); + const pending = collected.slice(); + const answers = []; + for (const id of this.#batch) { + const index = pending.findIndex((packet) => packet.id === id); + if (index < 0) continue; + answers.push(pending[index]); + pending.splice(index, 1); + } + // Anything with no matching id (a structure error carries an empty one) + // still has to be reported, so it goes at the end rather than nowhere. + for (let i = 0; i < pending.length; i++) answers.push(pending[i]); + return answers; + } + + // Same output as the scan above, including the two behaviours that are easy + // to lose: ids repeated within one batch consume one collected answer each, + // and unmatched answers trail in COLLECTION order, not index order. + #orderedIndexed() { + const collected = this.#collected; + const byId = new Map(); + for (let i = 0; i < collected.length; i++) { + const { id } = collected[i]; + const bucket = byId.get(id); + if (bucket === undefined) byId.set(id, [i]); + else bucket.push(i); + } + const answers = []; + const taken = new Uint8Array(collected.length); + for (const id of this.#batch) { + const bucket = byId.get(id); + if (bucket === undefined || bucket.length === 0) continue; + const index = bucket.shift(); + taken[index] = 1; + answers.push(collected[index]); + } + for (let i = 0; i < collected.length; i++) if (taken[i] === 0) answers.push(collected[i]); + return answers; + } + + write(data, httpCode = 200) { + if (this.#responded) return true; + this.#responded = true; + const body = Buffer.isBuffer(data) ? data : Buffer.from(data); + const headers = { ...this.headers, 'Content-Length': body.length }; + if (this.#setCookies.length > 0) headers['Set-Cookie'] = this.#setCookies; + this.#respond({ status: httpCode, headers, body }); + this.emit('close'); + return true; } getCookies() { - const { cookie } = this.req.headers; + const { cookie } = this.call.headers; if (!cookie) return {}; return parseCookies(cookie); } - sendSessionCookie(token) { - const host = parseHost(this.req.headers.host); - const cookie = `${TOKEN}=${token}; ${COOKIE_HOST}=${host}`; - this.res.setHeader('Set-Cookie', cookie); + sendSessionCookie(cookieHeader) { + this.#setCookies.push(cookieHeader); } - removeSessionCookie() { - const host = parseHost(this.req.headers.host); - this.res.setHeader('Set-Cookie', COOKIE_DELETE + host); + // Cookies a handler queued (startSession) that no write() will flush — + // a host-delegated route copies these onto its own reply. + get pendingCookies() { + return this.#setCookies; } + // Closed before every answer arrived — a shutdown, an evicted client. In + // batch mode `error()` would only collect ONE more answer and then keep + // waiting for the rest, which are never coming, so the request hangs. Fill + // every still-unanswered slot with its own error packet instead: an + // id-less packet is one the caller cannot route back to a pending call. close() { - this.error(503); - this.req.socket.destroy(); + if (this.#responded) return; + if (!this.#batch) return void this.error(503); + const message = http.STATUS_CODES[503]; + const answered = new Set(); + for (let i = 0; i < this.#collected.length; i++) answered.add(this.#collected[i].id); + for (const id of this.#batch) { + if (answered.has(id)) continue; + answered.add(id); + this.#collected.push({ type: 'callback', id: typeof id === 'string' ? id : '', error: { message, code: 503 } }); + } + const ordered = this.#ordered(); + this.write(this.codec ? this.codec.encode(ordered) : JSON.stringify(ordered), 503); } } class ServerWsTransport extends ServerTransport { - constructor(req, connection) { - super(req.socket.remoteAddress); + kind = 'ws'; + + constructor(connection, meta = {}) { + super(meta.remoteAddress ?? connection.remoteAddress ?? ''); this.connection = connection; connection.on('close', () => void this.emit('close')); + connection.on('drain', () => void this.emit('drain')); } write(data) { if (typeof data !== 'string' && !Buffer.isBuffer(data)) { data = Buffer.from(data.buffer, data.byteOffset, data.byteLength); } - this.connection.send(data); + return this.connection.send(data); } + // A graceful goodbye: the peer gets a close frame (1001 "going away") and + // the chance to finish the handshake; the engine's closeTimeout destroys + // one that never answers. Hard teardown stays spelled terminate(). close() { - this.connection.terminate(); + if (typeof this.connection.close === 'function') { + this.connection.close(1001, 'Server is closing'); + } else { + this.connection.terminate(); + } } } class ServerEventTransport extends ServerTransport { + kind = 'event'; + constructor(port) { super('event transport'); this.port = port; @@ -140,6 +375,7 @@ class ServerEventTransport extends ServerTransport { write(data) { this.port.postMessage(data); + return true; // a MessagePort has no backpressure to report } close() { @@ -156,4 +392,10 @@ ServerTransport.transport = { module.exports = { ServerTransport, buildHeaders, + isOriginAllowed, + parseCookies, + publicErrorMessage, + publicErrorDetails, + wireError, + SECURITY_HEADERS, }; diff --git a/src/utils.js b/src/utils.js index e2b4d77..b70a284 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,5 +1,14 @@ 'use strict'; +// Every emit() answers with a promise, and a listener's SYNCHRONOUS throw has +// to become a rejection: `void emitter.emit('close')` is the house style, and a +// throw escaping it would land in a socket handler. The async-arrow wrapper on +// the slow path buys exactly that, at one closure and one promise per listener +// per emit. The overwhelmingly common shape is a single sync listener — a +// transport's 'message', a stream's chunk push — so it is spelled out by hand: +// 4.0 -> 14.3 M emit/s awaited, 4.8 -> 63.9 M fire-and-forget; bench/emitter.js. +const RESOLVED = Promise.resolve(); + class Emitter { #events = new Map(); #maxListeners = 10; @@ -11,10 +20,26 @@ class Emitter { emit(eventName, value) { const event = this.#events.get(eventName); if (!event) { - if (eventName !== 'error') return Promise.resolve(); + if (eventName !== 'error') return RESOLVED; throw new Error('Unhandled error'); } - const listeners = event.on.slice(); + const on = event.on; + // Gated on once.size because the sweep below may delete the whole record, + // and off() splices without deleting, so an emptied `on` is reachable too. + if (event.once.size === 0 && on.length < 2) { + if (on.length === 0) return RESOLVED; + let result; + try { + result = on[0](value); + } catch (error) { + return Promise.reject(error); + } + if (result === null || result === undefined) return RESOLVED; + return typeof result.then === 'function' ? Promise.resolve(result).then(() => undefined) : RESOLVED; + } + // More than one listener, or a once to sweep: snapshot first, because a + // listener may call off() while the eager invocation below is still going. + const listeners = on.slice(); const promises = listeners.map(async (fn) => fn(value)); if (event.once.size > 0) { const len = event.on.length; @@ -47,9 +72,13 @@ class Emitter { event.on.push(listener); if (once) event.once.add(listener); } + // A warning, not a throw: exceeding the cap usually signals a leak, + // but killing legitimate fan-out (many streams awaiting one 'drain') + // is worse than a noisy console (cross-platform, so no process API). if (event.on.length > this.#maxListeners) { - throw new Error( - `MaxListenersExceededWarning: Possible memory leak. ` + `Current maxListeners is ${this.#maxListeners}.`, + globalThis.console.warn( + `MaxListenersExceededWarning: Possible ${String(eventName)} memory leak. ` + + `${event.on.length} listeners added, current maxListeners is ${this.#maxListeners}.`, ); } } @@ -102,4 +131,255 @@ const jsonParse = (data = null) => { } }; -module.exports = { Emitter, jsonParse }; +const DEFAULT_HIGH_WATER_MARK = 1024; + +// Push -> pull adapter: the missing primitive between "something calls me +// with a value" (an emitter, a backplane message, a websocket frame) and +// "someone is `for await`-ing values". Used on the server to feed a +// subscription handler and on the client to back `subscription.iterate()`. +// +// The queue is bounded: a producer that outruns the consumer drops the +// OLDEST pending value rather than growing without limit, and says so +// through `dropped`. Silent unbounded buffering is how a slow consumer +// takes a process down. +class EventStream { + #queue = []; + #waiting = null; + #done = false; + #error = null; + #highWaterMark; + #onAbort = null; + #signal = null; + + constructor({ signal = null, highWaterMark = DEFAULT_HIGH_WATER_MARK } = {}) { + this.#highWaterMark = highWaterMark; + this.dropped = 0; + if (!signal) return; + this.#signal = signal; + if (signal.aborted) { + this.#done = true; + return; + } + this.#onAbort = () => this.end(); + signal.addEventListener('abort', this.#onAbort, { once: true }); + } + + get length() { + return this.#queue.length; + } + + get closed() { + return this.#done; + } + + push(value) { + if (this.#done) return false; + if (this.#waiting) { + const { resolve } = this.#waiting; + this.#waiting = null; + resolve({ value, done: false }); + return true; + } + this.#queue.push(value); + if (this.#queue.length > this.#highWaterMark) { + this.#queue.shift(); + this.dropped++; + } + return true; + } + + /** Ends the stream; a pending next() resolves as done. */ + end() { + if (this.#done) return; + this.#done = true; + this.#detach(); + if (!this.#waiting) return; + const { resolve } = this.#waiting; + this.#waiting = null; + resolve({ value: undefined, done: true }); + } + + /** Ends the stream by throwing into the consumer. */ + fail(error) { + if (this.#done) return; + this.#error = error; + this.#done = true; + this.#detach(); + if (!this.#waiting) return; + const { reject } = this.#waiting; + this.#waiting = null; + reject(error); + } + + #detach() { + if (!this.#onAbort || !this.#signal) return; + this.#signal.removeEventListener('abort', this.#onAbort); + this.#onAbort = null; + } + + next() { + if (this.#queue.length > 0) { + return Promise.resolve({ value: this.#queue.shift(), done: false }); + } + if (this.#error) { + const error = this.#error; + this.#error = null; + return Promise.reject(error); + } + if (this.#done) return Promise.resolve({ value: undefined, done: true }); + if (this.#waiting) { + return Promise.reject(new Error('EventStream: concurrent next() is not supported')); + } + return new Promise((resolve, reject) => { + this.#waiting = { resolve, reject }; + }); + } + + // Consumers that break out of `for await` land here: the stream has to + // release its abort listener, or a long-lived signal pins it forever. + return() { + this.end(); + return Promise.resolve({ value: undefined, done: true }); + } + + [Symbol.asyncIterator]() { + return this; + } +} + +const createEventStream = (options) => new EventStream(options); + +// Reconnect pacing: truncated exponential backoff with AWS "full jitter" +// (https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/). +// +// window = min(maxDelay, minDelay * factor ** attempt) +// delay = jitter ? random_between(0, window) : window +// +// Jittering the WHOLE window rather than adding a small offset is what +// actually breaks up the thundering herd: after a server restart, a thousand +// clients that all disconnected in the same millisecond would otherwise all +// come back in the same millisecond. `attempt` is 0-based, so the first +// retry waits inside the minDelay window. +const backoffDelay = ({ attempt = 0, minDelay, maxDelay, factor = 2, jitter = true, random = Math.random }) => { + const growth = factor > 0 ? factor ** attempt : 1; + // Infinity * 0 is NaN, and an overflowing exponential must still cap. + const window = Math.min(maxDelay, minDelay * growth); + const capped = Number.isFinite(window) ? window : maxDelay; + if (!jitter) return Math.round(capped); + return Math.round(random() * capped); +}; + +// Counting semaphore with a bounded wait queue (ported from metautil): +// enter() resolves when a slot frees up, rejects on queue overflow or +// after `timeout` ms in the queue. +class Semaphore { + #concurrency; + #counter; + #size; + #timeout; + #queue = []; + + constructor({ concurrency, size = 0, timeout = 0 } = {}) { + this.#concurrency = concurrency; + this.#counter = concurrency; + this.#size = size; + this.#timeout = timeout; + } + + get empty() { + return this.#counter === this.#concurrency; + } + + // `signal` (optional) makes a queued waiter abortable: a caller that gave + // up — cancelled, disconnected, timed out — leaves the queue immediately + // instead of taking a slot later and doing work nobody will read. + enter(signal = null) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + return void reject(new Error('Semaphore entry aborted')); + } + if (this.#counter > 0) { + this.#counter--; + return void resolve(); + } + if (this.#queue.length >= this.#size) { + return void reject(new Error('Semaphore queue is full')); + } + const waiter = { resolve, reject, timer: null, release: null }; + const evict = (error) => { + const index = this.#queue.indexOf(waiter); + if (index > -1) this.#queue.splice(index, 1); + if (waiter.timer) clearTimeout(waiter.timer); + reject(error); + }; + if (this.#timeout > 0) { + waiter.timer = setTimeout(() => evict(new Error('Semaphore timeout')), this.#timeout); + } + if (signal) { + const onAbort = () => evict(new Error('Semaphore entry aborted')); + signal.addEventListener('abort', onAbort, { once: true }); + waiter.release = () => signal.removeEventListener('abort', onAbort); + } + this.#queue.push(waiter); + }); + } + + leave() { + const waiter = this.#queue.shift(); + if (!waiter) { + if (this.#counter < this.#concurrency) this.#counter++; + return; + } + if (waiter.timer) clearTimeout(waiter.timer); + waiter.release?.(); + waiter.resolve(); + } +} + +// Structural check for an injected wire codec. The packet half — +// { encode(packet) -> string, decode(text) -> packet, contentType? } — is +// text-only by design: binary output would collide with the chunk framing +// on ws and the line protocol on SSE. The optional `rest` section is a BODY +// codec for REST mode — { encode(value), decode(body), contentType? } — +// where binary is fine (whole HTTP bodies, no framing to collide with). +// A codec may carry either half or both; a declared rest section must be +// complete. Shared here because both the server core and the browser +// client run it. +const isRestSection = (value) => + value !== null && + typeof value === 'object' && + typeof value.encode === 'function' && + typeof value.decode === 'function' && + (value.contentType === undefined || typeof value.contentType === 'string'); + +const isCodec = (value) => { + if (value === null || typeof value !== 'object') return false; + const packet = typeof value.encode === 'function' && typeof value.decode === 'function'; + if (value.rest !== undefined && !isRestSection(value.rest)) return false; + return packet || value.rest !== undefined; +}; + +// camelCase -> kebab-case for declared header and meta keys, applied on BOTH +// ends so `schema.headers` and the meta bag see one casing convention whatever +// transport carried them. Two passes: the acronym pass keeps XMLHttpRequest +// from becoming x-m-l-http-request, the boundary pass does the ordinary case +// change. The output is all-lowercase and both patterns need an uppercase +// char, so toKebab(toKebab(x)) === toKebab(x) by construction. +// +// Two deliberate non-goals. Underscores are left alone: user_id -> user-id +// would silently merge two keys an application may have meant to keep apart, +// widening the rule from "one casing convention" to "one spelling convention". +// And collisions are not detected — userId and userID both yield user-id, +// last write wins — because a per-key guard costs an Object.hasOwn on a +// byte-budgeted path and would be the one place this pipeline warns about a +// single key rather than refusing the bag. +// +// It also cannot repair what it never sees: every HTTP stack lowercases header +// names before wrpc is reached, so an external caller's `x-wrpc-meta-userId` +// arrives as `userid` with the word boundary already gone. The guarantee holds +// for keys wrpc's own client produced; everyone else writes kebab themselves. +const KEBAB_ACRONYM = /([A-Z]+)([A-Z][a-z])/g; +const KEBAB_BOUNDARY = /([a-z0-9])([A-Z])/g; +const toKebab = (key) => key.replace(KEBAB_ACRONYM, '$1-$2').replace(KEBAB_BOUNDARY, '$1-$2').toLowerCase(); + +module.exports = { Emitter, jsonParse, isCodec, toKebab, Semaphore, backoffDelay, EventStream, createEventStream }; diff --git a/src/websocket/connection.js b/src/websocket/connection.js index 6654250..19a3a0d 100644 --- a/src/websocket/connection.js +++ b/src/websocket/connection.js @@ -2,39 +2,91 @@ const { EventEmitter } = require('node:events'); -const { OPCODES, CLOSE_CODES } = require('./constants.js'); +const { OPCODES, CLOSE_CODES, RSV1 } = require('./constants.js'); const { Frame, EMPTY_PING, EMPTY_PONG } = require('./frame.js'); const { FrameParser, isValidUTF8 } = require('./frameParser.js'); +const { SegmentQueue } = require('./segments.js'); +const permessageDeflate = require('./permessageDeflate.js'); const MAX_BUFFER = 1024 * 1024 * 100; +// The inflated-size cap for permessage-deflate, separate from MAX_BUFFER on +// purpose: MAX_BUFFER bounds bytes that already crossed the wire, while a +// compression bomb turns a few KB on the wire into whatever this allows — +// so its default has to be small enough to survive, not merely "large". +const MAX_PAYLOAD = 1024 * 1024 * 16; const CLOSE_TIMEOUT = 1000; +// How long the answering side waits for the peer's FIN before destroying. +const CLOSE_GRACE = 200; +const MAX_HEADER_SIZE = 14; // 2 base + 8 extended length + 4 mask key class Connection extends EventEmitter { #socket; #isClient; - #recvBuffer; + #queue = new SegmentQueue(); + #pendingHeader = null; #maxBuffer; + #maxPayload; + #maxBackpressure; + #fragmentThreshold; + #deflate; + #allowedRsv = 0; #closeTimeout; #closing = false; #closeSent = false; #closeReceived = false; #fragments = null; #closeTimer = null; + #needsDrain = false; + #paused = false; + #closeCode = CLOSE_CODES.CONNECTION_CLOSED_ABNORMALLY; + #closeReason = ''; constructor(socket, head, options = {}) { super(); this.#socket = socket; - this.#recvBuffer = null; - const { isClient = false, maxBuffer = MAX_BUFFER, closeTimeout = CLOSE_TIMEOUT } = options; + const { + isClient = false, + maxBuffer = MAX_BUFFER, + maxPayload = MAX_PAYLOAD, + closeTimeout = CLOSE_TIMEOUT, + // Finite by default: an unbounded outbound buffer lets a peer that + // stops reading (or floods pings, each answered with a pong) grow the + // socket's write queue without limit. `maxBuffer` is the ceiling — + // the connection can hold one biggest-allowed message in flight — + // and 0 opts back into unbounded. + maxBackpressure = maxBuffer, + fragmentThreshold = 0, + protocol = '', + deflate = null, + } = options; this.#isClient = isClient; this.#maxBuffer = maxBuffer; + this.#maxPayload = maxPayload; this.#closeTimeout = closeTimeout; + this.#maxBackpressure = maxBackpressure; + this.#fragmentThreshold = fragmentThreshold; + this.#deflate = deflate; + if (deflate) this.#allowedRsv = RSV1; + this.protocol = protocol; this.#init(head); } + get bufferedAmount() { + return this.#socket.writableLength ?? 0; + } + + get remoteAddress() { + return this.#socket.remoteAddress; + } + #init(head) { this.#socket.on('data', (data) => this.#receive(data)); + this.#socket.on('drain', () => { + if (!this.#needsDrain) return; + this.#needsDrain = false; + this.emit('drain'); + }); this.#socket.on('error', (error) => { if (this.#socket.destroyed) return; this.emit('error', error); @@ -42,19 +94,39 @@ class Connection extends EventEmitter { }); this.#socket.on('close', () => { if (this.#closeTimer) clearTimeout(this.#closeTimer); - this.emit('close'); + this.emit('close', this.#closeCode, this.#closeReason); }); // received data before upgrade if (head && head.length > 0) this.#receive(head); } + // Receive-side flow control: stops/restarts socket reads so a slow + // consumer propagates pressure down to the peer through TCP. While + // paused, inbound pongs are not read either — liveness checks must not + // treat a paused connection as dead (see the heartbeat's isPaused skip). + get isPaused() { + return this.#paused; + } + + pause() { + if (this.#socket.destroyed) return; + this.#paused = true; + this.#socket.pause(); + } + + resume() { + if (this.#socket.destroyed) return; + this.#paused = false; + this.#socket.resume(); + } + #receive(data) { if (this.#closeReceived && this.#closing) return; - this.#recvBuffer = this.#recvBuffer ? Buffer.concat([this.#recvBuffer, data]) : data; + this.#queue.push(data); - if (this.#recvBuffer.length > this.#maxBuffer) { + if (this.#queue.length > this.#maxBuffer) { const error = new Error('Buffer overflow, closing connection'); this.emit('error', error); if (this.#isClient) { @@ -64,28 +136,46 @@ class Connection extends EventEmitter { return void this.#close(frame); } - this.#processFrame(); + this.#processFrames(); } - #processFrame() { + #processFrames() { while (true) { - const result = FrameParser.parse(this.#recvBuffer); - const { value, error } = result; - if (error) return void this.#processFrameParserError(error); - if (!value) break; - - const { frame, bytesUsed } = result.value; - if (!this.#isClient && !frame.masked) { - const closeFrame = Frame.protocolErrorClose('UNMASKED'); - return void this.#close(closeFrame); - } - if (this.#isClient && frame.masked) { - const closeFrame = Frame.protocolErrorClose('MASKED', this.#isClient); - return void this.#close(closeFrame); + // RFC 6455 5.5.2 exempts an endpoint from answering a Ping received + // after a Close, and #answerClose has already half-closed the socket — + // so anything pipelined behind the peer's Close in the same segment is + // not just pointless to handle, it would write past end(). + if (this.#closeReceived) break; + if (!this.#pendingHeader) { + const headerBytes = this.#queue.peek(MAX_HEADER_SIZE); + const result = FrameParser.parseHeader(headerBytes, { allowedRsv: this.#allowedRsv }); + const { value, error } = result; + if (error) return void this.#processFrameParserError(error); + if (!value) break; + + if (!this.#isClient && !value.masked) { + const closeFrame = Frame.protocolErrorClose('UNMASKED'); + return void this.#close(closeFrame); + } + if (this.#isClient && value.masked) { + const closeFrame = Frame.protocolErrorClose('MASKED', this.#isClient); + return void this.#close(closeFrame); + } + // A frame that can never fit is rejected on its header, before the + // payload is buffered. + if (!this.#trackMessageSize(value.length)) return; + + this.#queue.consume(value.headerSize); + this.#pendingHeader = value; } + const header = this.#pendingHeader; + if (this.#queue.length < header.length) break; + this.#pendingHeader = null; + + const payload = this.#queue.consume(header.length); + const frame = new Frame(header.fin, header.opcode, header.masked, payload, header.mask, header.rsv); if (frame.masked) frame.unmaskPayload(); - this.#recvBuffer = this.#recvBuffer.subarray(bytesUsed); if (this.#closing && !frame.isControlFrame) continue; if (frame.isControlFrame) { this.#processControlFrame(frame); @@ -100,13 +190,18 @@ class Connection extends EventEmitter { if (error) return void this.#processFrameParserError(error); const { opcode } = frame; - if (opcode === OPCODES.PING) return void this.sendPong(frame.payload); + if (opcode === OPCODES.PING) { + this.emit('ping', frame.payload); + return void this.sendPong(frame.payload); + } if (opcode === OPCODES.PONG) return void this.emit('pong', frame.payload); if (opcode === OPCODES.CLOSE) { this.#closeReceived = true; const { code, reason } = frame.getCloseDetails().value; + this.#closeCode = code ?? CLOSE_CODES.NO_CODE_RECEIVED; + this.#closeReason = reason; if (!this.#closeSent) { - return void this.sendClose(code, reason); + return void this.#answerClose(code, reason); } return void this.terminate(); } @@ -146,6 +241,7 @@ class Connection extends EventEmitter { #handleDataFrame(frame) { const { opcode, payload } = frame; + const compressed = (frame.rsv & RSV1) !== 0; if (!this.#fragments) { // Continuation frame without a started fragmented message if (opcode === OPCODES.CONTINUATION) { @@ -156,12 +252,14 @@ class Connection extends EventEmitter { } if (frame.fin) { // single frame + if (compressed) return void this.#emitInflated(opcode, payload); const isBinary = opcode === OPCODES.BINARY; this.emit('message', frame.payload, isBinary); } else { if (!this.#trackMessageSize(payload.length)) return; this.#fragments = { opcode, + compressed, payloads: [payload], totalSize: payload.length, }; @@ -173,16 +271,18 @@ class Connection extends EventEmitter { this.#fragments.totalSize = totalSize; this.#fragments.payloads.push(frame.payload); if (!frame.fin) return; - const isBinary = this.#fragments.opcode === OPCODES.BINARY; - const isText = this.#fragments.opcode === OPCODES.TEXT; + const { opcode: firstOpcode, compressed: wasCompressed } = this.#fragments; const fullPayload = Buffer.concat(this.#fragments.payloads); + this.#fragments = null; + if (wasCompressed) return void this.#emitInflated(firstOpcode, fullPayload); + const isBinary = firstOpcode === OPCODES.BINARY; + const isText = firstOpcode === OPCODES.TEXT; if (isText && !isValidUTF8(fullPayload)) { const error = new Error('Invalid UTF-8 in text frame'); this.emit('error', error); const frame = Frame.errorClose('INVALID_PAYLOAD', this.#isClient); return void this.#close(frame); } - this.#fragments = null; this.emit('message', fullPayload, isBinary); } else { const error = new Error('Protocol error: Unexpected data frame during fragments'); @@ -192,53 +292,128 @@ class Connection extends EventEmitter { } } + #emitInflated(opcode, payload) { + // The dedicated inflated-size cap. zlib's maxOutputLength stops the + // inflation the moment output would exceed it, so a compression bomb + // costs at most `maxPayload` of memory and CPU before the close. + const limit = Math.min(this.#maxPayload, this.#maxBuffer); + let inflated = null; + try { + inflated = permessageDeflate.decompress(payload, limit); + } catch (error) { + this.emit('error', error); + const type = error.code === 'ERR_BUFFER_TOO_LARGE' ? 'MESSAGE_TOO_BIG' : 'INVALID_PAYLOAD'; + return void this.#close(Frame.errorClose(type, this.#isClient)); + } + const isText = opcode === OPCODES.TEXT; + if (isText && !isValidUTF8(inflated)) { + const error = new Error('Invalid UTF-8 in text frame'); + this.emit('error', error); + return void this.#close(Frame.errorClose('INVALID_PAYLOAD', this.#isClient)); + } + this.emit('message', inflated, opcode === OPCODES.BINARY); + } + send(data) { if (typeof data === 'string') return this.sendText(data); if (Buffer.isBuffer(data)) return this.sendBinary(data); throw new TypeError('send() accepts only string or Buffer'); } + #write(buffer) { + const ok = this.#socket.write(buffer); + if (!ok) this.#needsDrain = true; + return ok; + } + #writeFrame(frame) { if (this.#isClient) frame.maskPayload(); this.#socket.cork(); - this.#socket.write(frame.header); - this.#socket.write(frame.payload); + this.#write(frame.header); + const ok = this.#write(frame.payload); + this.#socket.uncork(); + return ok; + } + + #exceedsBackpressure() { + if (!this.#maxBackpressure) return false; + if (this.bufferedAmount <= this.#maxBackpressure) return false; + const error = new Error('Backpressure limit exceeded, terminating connection'); + this.emit('error', error); + this.terminate(); + return true; + } + + #sendData(opcode, payload) { + let rsv = 0; + if (this.#deflate && payload.length >= this.#deflate.threshold) { + payload = permessageDeflate.compress(payload, this.#deflate.windowBits); + rsv = RSV1; + } + const threshold = this.#fragmentThreshold; + if (!threshold || payload.length <= threshold) { + return this.#writeFrame(new Frame(true, opcode, false, payload, null, rsv)); + } + let ok = true; + // One cork for the WHOLE fragmented message: per-frame cork/uncork + // (inside #writeFrame) flushed a TCP write per fragment; nested corks + // are ref-counted, so this outer pair batches them into one flush. + this.#socket.cork(); + for (let offset = 0; offset < payload.length; offset += threshold) { + const end = Math.min(offset + threshold, payload.length); + const fin = end === payload.length; + const op = offset === 0 ? opcode : OPCODES.CONTINUATION; + const frameRsv = offset === 0 ? rsv : 0; + ok = this.#writeFrame(new Frame(fin, op, false, payload.subarray(offset, end), null, frameRsv)); + } this.#socket.uncork(); + return ok; } + // Data send methods return false when the socket did not take the bytes + // without exceeding its high-water mark — wait for 'drain' before more. sendText(message) { if (this.#closing) return false; - this.#writeFrame(Frame.text(message)); - return true; + if (this.#exceedsBackpressure()) return false; + return this.#sendData(OPCODES.TEXT, Buffer.from(message, 'utf8')); } sendBinary(buffer) { if (this.#closing) return false; - this.#writeFrame(Frame.binary(buffer)); - return true; + if (this.#exceedsBackpressure()) return false; + if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer); + return this.#sendData(OPCODES.BINARY, buffer); } sendPing(payload) { if (this.#closing) return false; - if (!payload) return this.#fastPing(); - this.#writeFrame(Frame.ping(payload)); - return true; + if (this.#exceedsBackpressure()) return false; + if (payload) return this.#writeFrame(Frame.ping(payload)); + return this.#fastPing(); } + // No #closing guard: RFC 6455 5.5.3 requires a pong unless a Close frame + // was received, and #processFrames stops draining input once + // #closeReceived is set — including frames already queued behind the Close. + // + // The backpressure guard DOES apply: a peer that floods pings without + // reading the pongs grows the write queue without limit, and RFC + // compliance is not a suicide pact — past the cap the connection is + // terminated like any other non-reading peer. sendPong(payload) { - if (!payload) return this.#fastPong(); - this.#writeFrame(Frame.pong(payload)); - return true; + if (this.#exceedsBackpressure()) return false; + if (payload) return this.#writeFrame(Frame.pong(payload)); + return this.#fastPong(); } #fastPing() { const buf = this.#isClient ? Frame.emptyClientPingBuffer() : EMPTY_PING; - return this.#socket.write(buf); + return this.#write(buf); } #fastPong() { const buf = this.#isClient ? Frame.emptyClientPongBuffer() : EMPTY_PONG; - return this.#socket.write(buf); + return this.#write(buf); } #close(frameBuffer) { @@ -260,12 +435,42 @@ class Connection extends EventEmitter { }, this.#closeTimeout); } + // Answers a peer-initiated Close and hangs up. + // + // RFC 6455 5.5.1: the side ANSWERING a Close closes the TCP connection + // immediately — it does not wait for the initiator to do it. Arming the + // full close timeout here instead left both peers waiting for the other + // to hang up, so every graceful disconnect cost `closeTimeout` (a second + // of latency on top of each reconnect, and on every test teardown). + // + // end() rather than destroy(): the echo has to flush before the FIN, and + // the short grace timer only covers a peer that never answers the FIN. + #answerClose(code = 1000, reason = '') { + if (this.#closing) return; + this.#closing = true; + this.#closeSent = true; + this.#fragments = null; + const frame = Frame.close(code, reason); + if (this.#isClient) frame.maskPayload(); + this.#socket.write(frame.toBuffer()); + this.#socket.end(); + if (this.#closeTimer) clearTimeout(this.#closeTimer); + this.#closeTimer = setTimeout(() => { + this.#socket.destroy(); + }, CLOSE_GRACE); + } + sendClose(code = 1000, reason = '') { const frame = Frame.close(code, reason); if (this.#isClient) frame.maskPayload(); this.#close(frame.toBuffer()); } + // WrpcSocket engine-contract alias for sendClose + close(code, reason) { + this.sendClose(code, reason); + } + terminate() { if (this.#closeTimer) { clearTimeout(this.#closeTimer); @@ -275,4 +480,4 @@ class Connection extends EventEmitter { } } -module.exports = { Connection, CLOSE_TIMEOUT }; +module.exports = { Connection, CLOSE_TIMEOUT, MAX_PAYLOAD }; diff --git a/src/websocket/constants.js b/src/websocket/constants.js index 090774b..a2a73d5 100644 --- a/src/websocket/constants.js +++ b/src/websocket/constants.js @@ -25,8 +25,10 @@ const CLOSE_CODES = { TLS_HANDSHAKE: 1015, }; +const RSV1 = 0x40; + const DATA_OPCODES = new Set([OPCODES.CONTINUATION, OPCODES.TEXT, OPCODES.BINARY]); const CONTROL_OPCODES = new Set([OPCODES.CLOSE, OPCODES.PING, OPCODES.PONG]); -module.exports = { OPCODES, CLOSE_CODES, DATA_OPCODES, CONTROL_OPCODES }; +module.exports = { OPCODES, CLOSE_CODES, RSV1, DATA_OPCODES, CONTROL_OPCODES }; diff --git a/src/websocket/frame.js b/src/websocket/frame.js index 2c721e7..b1c03ca 100644 --- a/src/websocket/frame.js +++ b/src/websocket/frame.js @@ -43,6 +43,37 @@ const truncateCloseReason = (reason) => { return bytes.subarray(0, end).toString(ENCODING); }; +// Uint32Array element order follows the host CPU; the word-XOR fast path +// composes its mask for little-endian layout, so big-endian hosts (Node +// supports linux-s390x) must take the byte-wise path or corrupt payloads. +const LITTLE_ENDIAN = new Uint8Array(Uint32Array.of(1).buffer)[0] === 1; + +// XOR-masks `payload` in place: byte-wise head up to 4-byte alignment, then +// 32 bits per iteration through a Uint32Array view (~8x the naive loop — +// see bench/unmask.js; readUInt32LE/writeUInt32LE lose to both), byte-wise +// tail. The alignment head matters: payloads are often subarray views at +// arbitrary byte offsets. +const applyMask = (payload, mask) => { + const length = payload.length; + let i = 0; + if (LITTLE_ENDIAN) { + const misalign = payload.byteOffset & 3; + if (misalign !== 0) { + const prefix = Math.min(4 - misalign, length); + for (; i < prefix; i++) payload[i] ^= mask[i & 3]; + } + const words = (length - i) >> 2; + if (words > 0) { + const view = new Uint32Array(payload.buffer, payload.byteOffset + i, words); + const mask32 = + (mask[i & 3] | (mask[(i + 1) & 3] << 8) | (mask[(i + 2) & 3] << 16) | (mask[(i + 3) & 3] << 24)) >>> 0; + for (let w = 0; w < words; w++) view[w] ^= mask32; + i += words << 2; + } + } + for (; i < length; i++) payload[i] ^= mask[i & 3]; +}; + class Frame { constructor(fin, opcode, masked, payload, mask, rsv = RSV) { this.fin = fin; @@ -119,18 +150,14 @@ class Frame { unmaskPayload() { if (!this.masked) return; - for (let i = 0; i < this.payload.length; i++) { - this.payload[i] ^= this.mask[i & 0x03]; - } + applyMask(this.payload, this.mask); this.masked = false; } maskPayload() { if (this.masked) return; this.mask = crypto.randomBytes(4); - for (let i = 0; i < this.payload.length; i++) { - this.payload[i] ^= this.mask[i & 0x03]; - } + applyMask(this.payload, this.mask); this.masked = true; } @@ -193,4 +220,4 @@ class Frame { } } -module.exports = { Frame, EMPTY_PING, EMPTY_PONG }; +module.exports = { Frame, EMPTY_PING, EMPTY_PONG, applyMask }; diff --git a/src/websocket/frameParser.js b/src/websocket/frameParser.js index 2d7c79a..ed5f1d6 100644 --- a/src/websocket/frameParser.js +++ b/src/websocket/frameParser.js @@ -1,6 +1,6 @@ 'use strict'; -const { CONTROL_OPCODES, DATA_OPCODES, OPCODES, CLOSE_CODES } = require('./constants.js'); +const { CONTROL_OPCODES, DATA_OPCODES, OPCODES, CLOSE_CODES, RSV1 } = require('./constants.js'); const { Frame } = require('./frame.js'); const { Result } = require('./result.js'); @@ -26,7 +26,19 @@ const VALID_CLOSE_CODES = new Set([ CLOSE_CODES.INTERNAL_SERVER_ERROR, ]); +const { isUtf8: nativeIsUtf8 } = require('node:buffer'); + +// The scalar JS loop below stays for SHORT buffers (a native call has fixed +// overhead that dominates under ~24 bytes — the same threshold ws uses) and +// as the fallback where node:buffer.isUtf8 is missing. Everything else goes +// to the native SIMD path: 20-70x faster on kilobyte-and-up frames, and the +// single biggest receive-path gap benchmarks showed against ws. const isValidUTF8 = (buf) => { + if (nativeIsUtf8 && buf.length >= 24) return nativeIsUtf8(buf); + return isValidUTF8Scalar(buf); +}; + +const isValidUTF8Scalar = (buf) => { let i = 0; const len = buf.length; @@ -111,7 +123,12 @@ const PARSE_ERR_CODES = { }; class FrameParser { - static parse(buffer) { + // Parses only the frame header (through the mask key, when present). + // Returns { fin, rsv, opcode, masked, mask, length, headerSize } or an + // empty Result when more bytes are needed. `allowedRsv` is a bitmask of + // RSV bits negotiated via extensions (RFC 6455 5.2) — any other set RSV + // bit is a protocol error. + static parseHeader(buffer, options = {}) { if (buffer.length < 2) return Result.empty(); const fin = (buffer[0] & FIN_MASK) !== 0; @@ -121,7 +138,8 @@ class FrameParser { let length = buffer[1] & PAYLOAD_LEN_MASK; let offset = 2; - if (rsv !== 0) { + const allowedRsv = options.allowedRsv ?? 0; + if ((rsv & ~allowedRsv) !== 0) { return Result.from(new ParseError(PARSE_ERR_CODES.PROTOCOL_ERROR_RSV, 'RSV bits must be 0')); } @@ -141,22 +159,34 @@ class FrameParser { length = high * TWO_32 + low; } - let mask; + let mask = null; if (masked) { if (buffer.length < offset + 4) return Result.empty(); + // A view, not a copy: the segment bytes are immutable and the mask is + // consumed immediately by unmaskPayload — one less allocation on the + // hottest path in the parser. mask = buffer.subarray(offset, offset + 4); offset += 4; } - if (buffer.length < offset + length) return Result.empty(); - const payload = buffer.subarray(offset, offset + length); + return Result.from({ fin, rsv, opcode, masked, mask, length, headerSize: offset }); + } + + static parse(buffer, options = {}) { + const headerResult = FrameParser.parseHeader(buffer, options); + const { value, error } = headerResult; + if (error || !value) return headerResult; + + const { fin, rsv, opcode, masked, mask, length, headerSize } = value; + if (buffer.length < headerSize + length) return Result.empty(); + const payload = buffer.subarray(headerSize, headerSize + length); const frame = new Frame(fin, opcode, masked, payload, mask, rsv); - return Result.from({ frame, bytesUsed: offset + length }); + return Result.from({ frame, bytesUsed: headerSize + length }); } static checkControlFrame(frame) { - const { fin, opcode, payload } = frame; - if (!CONTROL_OPCODES.has(opcode) || !fin) { + const { fin, rsv, opcode, payload } = frame; + if (!CONTROL_OPCODES.has(opcode) || !fin || rsv !== 0) { return Result.from(new ParseError(PARSE_ERR_CODES.PROTOCOL_ERROR_COMMON, 'Protocol error')); } if (payload.length > 125) { @@ -180,12 +210,18 @@ class FrameParser { } static checkDataFrame(frame) { - const { fin, opcode, payload } = frame; + const { fin, rsv, opcode, payload } = frame; if (!DATA_OPCODES.has(opcode)) { return Result.from(new ParseError(PARSE_ERR_CODES.PROTOCOL_ERROR_COMMON, 'Protocol error')); } + // RSV1 (compression) may only start a message, never continue one + if (opcode === OPCODES.CONTINUATION && rsv !== 0) { + return Result.from(new ParseError(PARSE_ERR_CODES.PROTOCOL_ERROR_COMMON, 'Protocol error')); + } + // Compressed text is validated after inflation, not on the wire bytes + const isCompressed = (rsv & RSV1) !== 0; const isText = opcode === OPCODES.TEXT; - if (isText && fin && !isValidUTF8(payload)) { + if (isText && fin && !isCompressed && !isValidUTF8(payload)) { return Result.from(new ParseError(PARSE_ERR_CODES.INVALID_PAYLOAD, 'Invalid UTF-8 in text frame')); } return Result.from(true); diff --git a/src/websocket/permessageDeflate.js b/src/websocket/permessageDeflate.js new file mode 100644 index 0000000..0b0ddc9 --- /dev/null +++ b/src/websocket/permessageDeflate.js @@ -0,0 +1,132 @@ +'use strict'; + +const zlib = require('node:zlib'); + +const EXTENSION_NAME = 'permessage-deflate'; +const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]); +const DEFAULT_THRESHOLD = 1024; +// zlib cannot produce a true 8-bit raw-deflate window, so an offer +// demanding server_max_window_bits=8 cannot be honored and is skipped. +const MIN_WINDOW_BITS = 9; +const MAX_WINDOW_BITS = 15; + +const KNOWN_PARAMS = new Set([ + 'server_no_context_takeover', + 'client_no_context_takeover', + 'server_max_window_bits', + 'client_max_window_bits', +]); + +const parseParam = (token, offer) => { + const eq = token.indexOf('='); + const key = (eq < 0 ? token : token.slice(0, eq)).trim(); + if (!key) return false; + let value = true; + if (eq >= 0) { + value = token.slice(eq + 1).trim(); + if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1); + if (!value) return false; + } + // A duplicate param is grammar-valid but makes the offer unacceptable — + // RFC 7692 7 requires declining it, not failing the handshake + if (offer.params.has(key)) offer.valid = false; + else offer.params.set(key, value); + return true; +}; + +// Parses a Sec-WebSocket-Extensions header value into +// [{ name, params: Map, valid }]. Returns null only on +// true grammar violations (RFC 6455 9.1) — the caller must then fail the +// handshake rather than silently ignore the header. +const parseExtensions = (header) => { + const offers = []; + for (const part of header.split(',')) { + const tokens = part.split(';'); + const name = tokens[0].trim(); + if (!name) return null; + const offer = { name, params: new Map(), valid: true }; + for (let i = 1; i < tokens.length; i++) { + if (!parseParam(tokens[i], offer)) return null; + } + offers.push(offer); + } + return offers; +}; + +const parseWindowBits = (value) => { + if (!/^\d{1,2}$/.test(value)) return null; + const bits = Number(value); + if (bits < 8 || bits > MAX_WINDOW_BITS) return null; + return bits; +}; + +const acceptOffer = (offer, options) => { + if (!offer.valid) return null; + let windowBits = MAX_WINDOW_BITS; + for (const [key, value] of offer.params) { + if (!KNOWN_PARAMS.has(key)) return null; + if (key === 'server_max_window_bits') { + if (value === true) return null; // value is required for this param + const bits = parseWindowBits(value); + if (bits === null || bits < MIN_WINDOW_BITS) return null; + windowBits = bits; + } else if (key === 'client_max_window_bits' && value !== true) { + if (parseWindowBits(value) === null) return null; + } + } + const response = [EXTENSION_NAME, 'server_no_context_takeover', 'client_no_context_takeover']; + if (offer.params.has('server_max_window_bits')) { + response.push(`server_max_window_bits=${windowBits}`); + } + return { + response: response.join('; '), + threshold: options.threshold ?? DEFAULT_THRESHOLD, + windowBits, + }; +}; + +// Server-side negotiation: accepts the first honorable permessage-deflate +// offer. Both directions are pinned to no context takeover, so every +// message is a self-contained deflate stream — compression state never +// spans messages and one-shot zlib calls suffice. +// Returns the accepted params, null to decline, or { malformed: true } +// when the header violates the grammar — the handshake must then fail +// with 400 (RFC 6455 4.2.1), not proceed without the extension. +const negotiate = (header, options = {}) => { + if (!header) return null; + const offers = parseExtensions(header); + if (!offers) return { malformed: true }; + for (const offer of offers) { + if (offer.name !== EXTENSION_NAME) continue; + const accepted = acceptOffer(offer, options); + if (accepted) return accepted; + } + return null; +}; + +const compress = (payload, windowBits = MAX_WINDOW_BITS) => { + const compressed = zlib.deflateRawSync(payload, { + windowBits, + finishFlush: zlib.constants.Z_SYNC_FLUSH, + }); + // RFC 7692 7.2.1: strip the trailing empty block (00 00 ff ff) + return compressed.subarray(0, compressed.length - TRAILER.length); +}; + +// finishFlush Z_SYNC_FLUSH: the stream has no final block — it ends at the +// flush point left by the peer's compressor (RFC 7692 7.2.2). +const decompress = (payload, maxLength) => + zlib.inflateRawSync(Buffer.concat([payload, TRAILER]), { + windowBits: MAX_WINDOW_BITS, + maxOutputLength: maxLength, + finishFlush: zlib.constants.Z_SYNC_FLUSH, + }); + +module.exports = { + EXTENSION_NAME, + DEFAULT_THRESHOLD, + parseExtensions, + negotiate, + compress, + decompress, +}; diff --git a/src/websocket/result.js b/src/websocket/result.js index 4fa8de4..adabf69 100644 --- a/src/websocket/result.js +++ b/src/websocket/result.js @@ -14,8 +14,12 @@ class Result { return new this(res); } + // One shared instance: empty() is answered for every not-enough-bytes + // parse attempt, which on a fragmented stream is most of them. + static #EMPTY = null; + static empty() { - return new this({ value: null, error: null }); + return (this.#EMPTY ??= new this({ value: null, error: null })); } get value() { diff --git a/src/websocket/segments.js b/src/websocket/segments.js new file mode 100644 index 0000000..0d38ff2 --- /dev/null +++ b/src/websocket/segments.js @@ -0,0 +1,82 @@ +'use strict'; + +const EMPTY_BUFFER = Buffer.alloc(0); + +// FIFO byte queue over a list of socket segments. Bytes are copied at most +// once on consume (and only when a range spans segments), replacing the +// per-segment Buffer.concat accumulation that made large-message receive +// O(n^2) in the number of TCP segments. +class SegmentQueue { + #segments = []; + #offset = 0; // read cursor within #segments[0] + #length = 0; + + get length() { + return this.#length; + } + + push(segment) { + if (segment.length === 0) return; + this.#segments.push(segment); + this.#length += segment.length; + } + + // Returns up to `size` leading bytes without consuming them. + // Zero-copy when the range lies within the first segment. + peek(size) { + if (size > this.#length) size = this.#length; + if (size === 0) return EMPTY_BUFFER; + const first = this.#segments[0]; + const available = first.length - this.#offset; + if (available >= size) return first.subarray(this.#offset, this.#offset + size); + const out = Buffer.allocUnsafe(size); + let copied = first.copy(out, 0, this.#offset); + for (let i = 1; copied < size; i++) { + const segment = this.#segments[i]; + copied += segment.copy(out, copied, 0, Math.min(segment.length, size - copied)); + } + return out; + } + + // Consumes and returns exactly `size` leading bytes. The result is a + // zero-copy view into the source segment when the range does not span + // segments — see the payload ownership contract in the changelog: callers + // retaining the bytes long-term should copy them. + consume(size) { + if (size === 0) return EMPTY_BUFFER; + if (size > this.#length) throw new RangeError('Cannot consume more bytes than buffered'); + const first = this.#segments[0]; + const available = first.length - this.#offset; + let out; + if (available > size) { + out = first.subarray(this.#offset, this.#offset + size); + this.#offset += size; + } else if (available === size) { + out = first.subarray(this.#offset); + this.#segments.shift(); + this.#offset = 0; + } else { + out = Buffer.allocUnsafe(size); + let copied = first.copy(out, 0, this.#offset); + this.#segments.shift(); + this.#offset = 0; + while (copied < size) { + const segment = this.#segments[0]; + const take = Math.min(segment.length, size - copied); + copied += segment.copy(out, copied, 0, take); + if (take === segment.length) this.#segments.shift(); + else this.#offset = take; + } + } + this.#length -= size; + return out; + } + + clear() { + this.#segments.length = 0; + this.#offset = 0; + this.#length = 0; + } +} + +module.exports = { SegmentQueue }; diff --git a/src/websocket/server.js b/src/websocket/server.js index c4a5420..4e32855 100644 --- a/src/websocket/server.js +++ b/src/websocket/server.js @@ -4,6 +4,7 @@ const crypto = require('node:crypto'); const { EventEmitter } = require('node:events'); const { Connection } = require('./connection.js'); +const permessageDeflate = require('./permessageDeflate.js'); const MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; const PING_INTERVAL = 10000; @@ -18,6 +19,13 @@ const UPGRADE = [ const hasToken = (value, token) => !!value && value.toLowerCase().includes(token); +// The protocol revision marker (see docs/reference/protocol.md#versioning). +// A wrpc client OFFERS it; with no app-configured protocols the server +// echoes it back, which is what stamps the wire with a version both sides +// can rely on. A peer that offers nothing gets no subprotocol and speaks +// 1.0 — additive, nothing breaks. +const WRPC_PROTOCOL = 'wrpc.v1'; + const writeResponse = (socket, headerLines) => { socket.cork(); socket.write(headerLines.join(EOL)); @@ -25,10 +33,11 @@ const writeResponse = (socket, headerLines) => { socket.uncork(); }; -const sendUpgrade = (socket, accept) => { +const sendUpgrade = (socket, accept, extraHeaders = []) => { socket.cork(); socket.write(UPGRADE); socket.write(accept); + for (const line of extraHeaders) socket.write(EOL + line); socket.write(EOL2); socket.uncork(); }; @@ -49,26 +58,55 @@ class WebsocketServer extends EventEmitter { #connections = new Set(); #heartbeats = new Map(); // { awaiting: boolean } #pingTimer; + #closed = false; + // `server` is optional: without it nothing is bound and upgrades are + // driven manually through handleUpgrade(req, socket, head) — that is how + // middleware adapters (express) hook their own 'upgrade' listener. constructor({ server, ...opts } = {}) { super(); - if (!server || typeof server.on !== 'function') { - throw new TypeError('WebsocketServer: options.server (instance of http.Server) is required'); + if (server !== undefined && (!server || typeof server.on !== 'function')) { + throw new TypeError('WebsocketServer: options.server must be an http.Server'); } this.#options = { pingInterval: PING_INTERVAL, ...opts, }; - this.#init(server); + this.#startHeartbeat(); + if (server) this.#bind(server); + } + + // Snapshot of the live connections (mutations do not affect the server) + get connections() { + return new Set(this.#connections); + } + + // Drives one upgrade by hand. Same guarantees as the bound path: the raw + // socket gets an error handler before parsing, and a throwing handshake + // answers 500 instead of leaving the socket dangling. + handleUpgrade(req, socket, head) { + socket.on('error', () => { + socket.destroy(); + }); + try { + this.#handleUpgrade(req, socket, head); + } catch (error) { + this.emit('error', error); + abort(socket, 500, 'Internal Server Error'); + } } - #init(server) { + #startHeartbeat() { const { pingInterval } = this.#options; this.#pingTimer = setInterval(() => { for (const ws of this.#connections) { + // A paused connection cannot read pongs — skip it instead of + // terminating a healthy peer that is merely applying backpressure. + if (ws.isPaused) continue; const heartbeat = this.#heartbeats.get(ws); - if (heartbeat.awaiting) { + if (!heartbeat || heartbeat.awaiting) { ws.terminate(); + this.#connections.delete(ws); this.#heartbeats.delete(ws); continue; } @@ -76,16 +114,12 @@ class WebsocketServer extends EventEmitter { ws.sendPing(); } }, pingInterval); + this.#pingTimer.unref(); + } + + #bind(server) { server.on('upgrade', (req, socket, head) => { - socket.on('error', () => { - socket.destroy(); - }); - try { - this.#handleUpgrade(req, socket, head); - } catch (error) { - this.emit('error', error); - abort(socket, 500, 'Internal Server Error'); - } + this.handleUpgrade(req, socket, head); }); server.on('error', (error) => { // Forward error to WebsocketServer if: @@ -98,18 +132,54 @@ class WebsocketServer extends EventEmitter { } }); server.on('close', () => { - clearInterval(this.#pingTimer); - for (const ws of this.#connections) { - ws.sendClose(1001, 'Server is closing'); - } - this.#connections.clear(); - this.#heartbeats.clear(); - this.emit('close'); + this.close(); }); } + close({ code = 1001, reason = 'Server is closing' } = {}) { + if (this.#closed) return; + this.#closed = true; + clearInterval(this.#pingTimer); + for (const ws of this.#connections) { + ws.sendClose(code, reason); + } + this.#connections.clear(); + this.#heartbeats.clear(); + this.emit('close'); + } + + #negotiateProtocol(req, socket) { + const header = req.headers['sec-websocket-protocol']; + if (!header) return { protocol: '' }; + const offered = header + .split(',') + .map((token) => token.trim()) + .filter(Boolean); + const { protocols, handleProtocols } = this.#options; + if (handleProtocols) { + const selected = handleProtocols(offered, req); + if (selected === false) { + abort(socket, 400, 'Subprotocol negotiation failed'); + return null; + } + return { protocol: selected || '' }; + } + if (protocols) { + const selected = offered.find((name) => protocols.includes(name)); + return { protocol: selected ?? '' }; + } + // No app configuration: answer the wrpc revision when it was offered. + // Required, not a nicety — a browser fails the whole connection when it + // offered subprotocols and the server selected none. + if (offered.includes(WRPC_PROTOCOL)) return { protocol: WRPC_PROTOCOL }; + return { protocol: '' }; + } + #handleUpgrade(req, socket, head) { - const { path, verifyClient } = this.#options; + if (this.#closed) { + return void abort(socket, 503, 'Service Unavailable'); + } + const { path, verifyClient, perMessageDeflate } = this.#options; const pathname = getPathname(req.url); if (path !== undefined && pathname !== path) { return void abort(socket, 404, 'Not Found'); @@ -146,12 +216,32 @@ class WebsocketServer extends EventEmitter { if (!isValidSecWebSocketKey(key)) { return void abort(socket, 400, 'Invalid Sec-WebSocket-Key'); } + + const negotiated = this.#negotiateProtocol(req, socket); + if (!negotiated) return; + const { protocol } = negotiated; + + let deflate = null; + if (perMessageDeflate) { + const deflateOptions = perMessageDeflate === true ? {} : perMessageDeflate; + deflate = permessageDeflate.negotiate(req.headers['sec-websocket-extensions'], deflateOptions); + if (deflate && deflate.malformed) { + return void abort(socket, 400, 'Invalid Sec-WebSocket-Extensions header'); + } + } + + const extraHeaders = []; + if (protocol) extraHeaders.push(`Sec-WebSocket-Protocol: ${protocol}`); + if (deflate) extraHeaders.push(`Sec-WebSocket-Extensions: ${deflate.response}`); + const accept = crypto.createHash('sha1').update(key).update(MAGIC).digest('base64'); - sendUpgrade(socket, accept); + sendUpgrade(socket, accept, extraHeaders); const ws = new Connection(socket, head, { ...this.#options, isClient: false, + protocol, + deflate, }); this.#setupHeartbeat(ws); this.emit('connection', ws, req); @@ -176,4 +266,4 @@ class WebsocketServer extends EventEmitter { } } -module.exports = { WebsocketServer, MAGIC }; +module.exports = { WebsocketServer, MAGIC, WRPC_PROTOCOL }; diff --git a/src/websocket/ws.js b/src/websocket/ws.js index 20eb0a4..9b71fca 100644 --- a/src/websocket/ws.js +++ b/src/websocket/ws.js @@ -1,6 +1,6 @@ 'use strict'; -const { OPCODES, CLOSE_CODES } = require('./constants.js'); +const { OPCODES, CLOSE_CODES, RSV1 } = require('./constants.js'); const { WebsocketServer, MAGIC } = require('./server.js'); const { Connection, CLOSE_TIMEOUT } = require('./connection.js'); const { Frame } = require('./frame.js'); @@ -9,6 +9,7 @@ const { FrameParser, ParseError, PARSE_ERR_CODES } = require('./frameParser.js') module.exports = { OPCODES, CLOSE_CODES, + RSV1, CLOSE_TIMEOUT, MAGIC, WebsocketServer, diff --git a/src/wire.js b/src/wire.js new file mode 100644 index 0000000..6af75e6 --- /dev/null +++ b/src/wire.js @@ -0,0 +1,26 @@ +'use strict'; + +// The wire names of the connection-metadata carriers, in ONE place. Five +// modules speak them — the server parser (rpc/core), the client emitter +// (client/core), the ws connect-URL builder (client/transports), the CORS +// allowlist (transport) and the auth strategies (auth) — and a spelling +// change used to be a five-file grep with nothing to catch a miss. +// Zero-require beyond sse/constants.js (itself import-free) and +// browser-safe by construction: esbuild inlines these strings, so pulling +// this file into a budgeted entry costs nothing. + +const { CHANNEL_HEADER } = require('./sse/constants.js'); + +// The connect-URL query parameters carrying the declared bags on a browser +// WebSocket, whose constructor cannot set real headers: the headers bag and +// the connection-phase meta bag, each one percent-encoded JSON value. +const HEADERS_PARAM = 'wrpc_h'; +const META_PARAM = 'wrpc_meta'; + +// The canonical single meta header (percent-encoded JSON, type-faithful, +// one CORS entry) and the per-key x-amz-meta-style prefix spelling (string +// values, hand-typeable, one CORS entry per key). +const META_HEADER = 'x-wrpc-meta'; +const META_PREFIX = 'x-wrpc-meta-'; + +module.exports = { HEADERS_PARAM, META_PARAM, META_HEADER, META_PREFIX, CHANNEL_HEADER }; diff --git a/sse.browser.d.ts b/sse.browser.d.ts new file mode 100644 index 0000000..548450a --- /dev/null +++ b/sse.browser.d.ts @@ -0,0 +1,44 @@ +// Types for the browser half of `@alexify/wrpc/sse` (`sse.browser.js`): the +// fetch-based client transport and the event-stream parser. The channel +// registry is server machinery and lives in sse.d.ts, which re-exports this +// file — so the server types are a superset, exactly like the runtime. +import { ClientTransport } from './client.js'; + +/** One parsed event-stream event. */ +export interface SseEvent { + /** The last `id:` seen, or null if the stream has not sent one. */ + id: string | null; + /** The `event:` name; 'message' when unnamed. */ + event: string; + /** `data:` lines joined with newlines. */ + data: string; +} + +/** + * Incremental WHATWG event-stream parser. Feed it decoded chunks; it + * returns the events each chunk completed. Handles fields split across + * chunks, CRLF/CR/LF line endings, comments (the proxy heartbeat) and + * multi-line data. + */ +export declare class SseParser { + push(chunk: string): Array; +} + +/** + * The client half: a `fetch`-based transport, registered as + * `WrpcClient.transport.sse` on require. Deliberately not `EventSource`, + * which cannot set headers, cannot be aborted cleanly, and reconnects on its + * own schedule instead of the client's. + * + * The channel id is SERVER-minted: the first GET carries none, the server + * answers with a `ready` frame naming the channel, and only then can this + * transport POST. A 409 means the channel is gone — the transport starts + * over with a fresh one. + */ +export declare class ClientSseTransport extends ClientTransport { + constructor(url: string); + readonly eventsUrl: string; +} + +/** The header a POST (or a re-attaching GET) names its channel with. */ +export declare const CHANNEL_HEADER: string; diff --git a/sse.browser.js b/sse.browser.js new file mode 100644 index 0000000..fbd1c56 --- /dev/null +++ b/sse.browser.js @@ -0,0 +1,5 @@ +'use strict'; + +// The browser half of the './sse' subpath: registering the client transport +// is all a browser needs, and the server half would drag node:http in. +module.exports = require('./src/sse/client.js'); diff --git a/sse.d.ts b/sse.d.ts new file mode 100644 index 0000000..9b6f5cf --- /dev/null +++ b/sse.d.ts @@ -0,0 +1,143 @@ +import type { Client, HttpCall, WrpcLogger } from './index.js'; + +/** + * `@alexify/wrpc/sse` — Server-Sent Events as a wrpc transport. + * + * SSE is one-way, so a channel is two halves that find each other by id: + * + * GET {basePath}/events opens a NEW channel + * GET {basePath}/events x-wrpc-channel: re-attaches to it + * POST {basePath} x-wrpc-channel: client -> server + * + * The id is SERVER-minted and handed out once, in the `ready` frame; the + * channel is bound to the cookie identity of the GET that created it, and + * every re-attach and POST must present the same one (403 otherwise; 409 + * when the id is unknown). Both halves belong to ONE server-side `Client`, + * which is what lets a subscription opened by a POST deliver its values + * down the stream. A POST answers `202` with no body — every reply, + * callbacks included, travels on the stream. + * + * Requiring this module registers the client transport: + * + * ```js + * require('@alexify/wrpc/sse'); + * const client = await WrpcClient.connect('https://host/api', { transport: 'sse' }); + * ``` + * + * The server half is built into `RpcServer` and configured through its `sse` + * option; what is exported here is the machinery behind it. In a browser + * bundle this subpath resolves to the client half alone (sse.browser.d.ts). + * + * **Text only.** SSE frames are text, so wrpc's binary streams are refused + * on this transport rather than silently corrupted — use a WebSocket for + * those. + */ + +// The client half (transport, parser, header) is shared with the browser +// types condition verbatim. +export * from './sse.browser.js'; + +/** Response headers every event stream carries. */ +export declare const SSE_HEADERS: Record; + +export interface SseWriter { + write(chunk: string): boolean; + end(): void; + onClose?(listener: () => void): void; + onDrain?(listener: () => void): void; +} + +export interface SseOptions { + /** + * How long a channel outlives its stream, in ms. A dropped stream is + * usually a blip, not a goodbye: within this window a reconnect + * re-attaches and replays instead of starting over. Default 30000. + */ + retention?: number; + /** Outbound frames kept for Last-Event-ID replay. Default 100. */ + replay?: number; + /** Comment-frame interval in ms; 0 disables. Default 15000. */ + heartbeat?: number; + /** The `retry:` value handed to the peer, in ms. Default 2000. */ + retry?: number; + /** Byte budget for the replay buffer; evicts oldest first. Default 1 MiB. */ + replayBytes?: number; + /** Live channels per server; past it a new GET is 503. Default 10000. */ + maxChannels?: number; + /** + * Live channels per remote address; past it 429. Default 100. Behind a + * proxy the default address is the PROXY — see `clientAddress`. + */ + maxChannelsPerAddress?: number; + /** + * What the per-address cap counts by; defaults to the TCP peer address. + * Behind a load balancer inject a reader for your proxy's client header + * (trust it only when the proxy is yours). + */ + clientAddress?: (call: { headers?: Record; remoteAddress?: string }) => string; +} + +/** The server-side transport behind one event stream. Text-only. */ +export declare class ServerSseTransport { + readonly channelId: string; + readonly binary: false; + readonly attached: boolean; + write(data: string): boolean; + close(): void; +} + +export declare class SseChannel { + readonly id: string; + /** + * The identity the channel was created under: the session token read from + * the opening GET's cookie, or '' for an anonymous peer. Every re-attach + * and POST must present it again — the id alone is never enough. + */ + readonly key: string; + readonly client: Client; + readonly transport: ServerSseTransport; + /** Replays every buffered frame newer than `lastEventId`. */ + resume(lastEventId: string | number): void; +} + +/** + * The channel registry. `RpcServer` owns one and exposes it as `.sse`; + * a host driving SSE itself can use it directly. + */ +export declare class SseChannels { + constructor( + options: SseOptions & { + /** + * Builds the one `Client` both halves of a channel share. `headers` are + * the request headers of the GET that opened the stream — a channel's + * only handshake, and where its session cookie comes from. + */ + addClient(transport: ServerSseTransport, headers: Record): Client; + /** + * Extracts the identity a request presents (the session token from its + * cookie). Injected by RpcServer, which owns the SessionManager. + */ + channelKey?(headers: Record): string; + /** Internal: the channel registry's writer, built by RpcServer. */ + log?: WrpcLogger | boolean; + }, + ); + readonly size: number; + get(channelId: string): SseChannel | null; + /** True when `headers` present the identity the channel was created under. */ + authorized(channel: SseChannel, requestHeaders?: Record): boolean; + /** + * Opens or re-attaches the server -> client half. The call must provide + * `stream`; a host that cannot keep a response open gets a 501. Ids are + * server-minted: an unknown `channelId` answers 409, a known one with the + * wrong cookie identity 403, and creation past the caps 503/429. + * + * `headers` are RESPONSE headers (CORS and the rest); the request headers a + * new channel's client is built from come off `call` itself. + */ + open( + call: HttpCall, + options?: { channelId?: string | null; lastEventId?: string | null; headers?: Record }, + ): SseChannel | undefined; + close(): void; +} diff --git a/sse.js b/sse.js new file mode 100644 index 0000000..464faf8 --- /dev/null +++ b/sse.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./src/sse/index.js'); diff --git a/tests/adapters/boots.js b/tests/adapters/boots.js new file mode 100644 index 0000000..d5322f0 --- /dev/null +++ b/tests/adapters/boots.js @@ -0,0 +1,174 @@ +'use strict'; + +// Boot functions for the adapter swap-test: one behavioral spec +// (tests/adapters/spec.js) runs against every way of standing wrpc up. +// Not a *.test.js — imported by the adapter tests. + +const http = require('node:http'); +const path = require('node:path'); +const Module = require('node:module'); + +const { Server } = require('../../index.js'); +const { createNodeEngine } = require('../../engine.js'); +const { createUwsEngine } = require('../../uws.js'); +const { wrpcFastify } = require('../../fastify.js'); +const { createWrpc } = require('../../express.js'); + +const noop = () => {}; +const quiet = { log: noop, info: noop, warn: noop, error: noop, debug: noop }; + +// Optional peer dependencies: present in devDependencies, but a machine +// without a working uws binary should skip rather than fail. +const optional = (name) => { + try { + return require(name); + } catch { + return null; + } +}; + +// pnpm gives fastify-uws its own uWebSockets.js build (2.0.0 pins 20.57.0, +// this package's devDependencies pin 20.69.0), so a process that touches both +// loads the SAME native addon twice. Two copies cannot coexist: creating and +// closing an App leaves the process to segfault in ~uWS::TemplatedApp during +// node's environment cleanup — after the last assertion has already passed, +// which turns a green run into an exit-code-139 failure. +// +// Seed the module cache so fastify-uws resolves uWebSockets.js to the copy we +// already loaded. One binary, no exit-time crash, and the fastify+uws boot +// exercises the same engine build as every other uws boot. +const pinNestedUws = (uws) => { + let nested = null; + try { + nested = Module.createRequire(require.resolve('fastify-uws')).resolve('uWebSockets.js'); + } catch { + return; // no fastify-uws, or it brings no uws of its own: nothing to pin + } + if (nested === require.resolve('uWebSockets.js')) return; // already deduped + if (require.cache[nested]) return; // already loaded — too late to help + const stub = new Module(nested, null); + stub.filename = nested; + stub.path = path.dirname(nested); + stub.loaded = true; + stub.exports = uws; + require.cache[nested] = stub; +}; + +const requireUws = () => { + const uws = optional('uWebSockets.js'); + if (uws) pinNestedUws(uws); + return uws; +}; + +const rpcOptions = (options) => ({ + router: options.router, + sessions: options.sessions, + cors: options.cors ?? null, + basePath: options.basePath, + logger: false, +}); + +// --------------------------------------------------------------------------- +// 1 + 2. The batteries-included Server, over each engine. + +// `createEngine` is a factory, not an instance: every boot needs a fresh +// engine (a uws engine refuses a second attach, a node engine would drop +// its previous WebsocketServer). +const bootServer = (createEngine) => async (options) => { + const server = new Server({ + ...rpcOptions(options), + protocol: 'http', + host: '127.0.0.1', + port: 0, + timeouts: { bind: 100 }, + engine: createEngine(), + }); + await server.listen(); + const { port } = server.address(); + return { port, rpc: server.rpc, close: () => server.close() }; +}; + +// --------------------------------------------------------------------------- +// 3 + 4. The fastify plugin, over each backend. + +const bootFastify = (serverFactory) => async (options) => { + const fastify = optional('fastify'); + const app = fastify({ ...(serverFactory ? { serverFactory } : {}), logger: false }); + await app.register(wrpcFastify, { ...rpcOptions(options), logger: false }); + await app.listen({ host: '127.0.0.1', port: 0 }); + const address = app.server.address(); + return { port: address.port, rpc: app.wrpc, close: () => app.close() }; +}; + +// --------------------------------------------------------------------------- +// 5. express middleware on a listener it does not own. + +const bootExpress = async (options) => { + const express = optional('express'); + const app = express(); + const wrpc = createWrpc({ ...rpcOptions(options) }); + app.use(wrpc.handler); + const httpServer = http.createServer(app); + httpServer.on('upgrade', wrpc.upgrade); + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)); + const { port } = httpServer.address(); + return { + port, + rpc: wrpc.rpc, + close: async () => { + await wrpc.close(); + httpServer.closeAllConnections(); + await new Promise((resolve) => httpServer.close(resolve)); + }, + }; +}; + +// `pathMiss` records who answers a request outside basePath: +// 'wrpc' — the core's JSON 404 packet (it owns every request) +// 'framework' — the host framework's own 404 (fastify router, express) +// Both are 404s; only the first carries a wrpc error packet, and that +// difference is the point of composing as middleware. +const buildBoots = () => { + const uws = requireUws(); + const fastify = optional('fastify'); + const fastifyUws = optional('fastify-uws'); + const express = optional('express'); + return [ + { + name: 'Server + node engine', + pathMiss: 'wrpc', + streams: true, + boot: bootServer(() => createNodeEngine()), + }, + { + name: 'Server + uws engine', + pathMiss: 'wrpc', + streams: true, + skip: uws ? false : 'uWebSockets.js unavailable', + boot: bootServer(() => createUwsEngine({ uws })), + }, + { + name: 'fastify + node engine', + pathMiss: 'framework', + streams: true, + skip: fastify ? false : 'fastify unavailable', + boot: bootFastify(null), + }, + { + name: 'fastify + uws engine', + pathMiss: 'framework', + streams: true, + skip: fastify && fastifyUws && uws ? false : 'fastify-uws / uWebSockets.js unavailable', + boot: (options) => bootFastify(optional('fastify-uws').serverFactory)(options), + }, + { + name: 'express + node engine', + pathMiss: 'framework', + streams: true, + skip: express ? false : 'express unavailable', + boot: bootExpress, + }, + ]; +}; + +module.exports = { buildBoots, bootServer, bootFastify, bootExpress, requireUws, optional, quiet }; diff --git a/tests/adapters/common.test.js b/tests/adapters/common.test.js new file mode 100644 index 0000000..9610273 --- /dev/null +++ b/tests/adapters/common.test.js @@ -0,0 +1,82 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const { Readable } = require('node:stream'); + +const { receiveBody, normalizeBody, statusLine, eachHeader } = require('../../src/adapters/common.js'); + +const streamOf = (...chunks) => Readable.from(chunks.map((chunk) => Buffer.from(chunk))); + +test('receiveBody', async (t) => { + await t.test('an empty stream reads as no body', async () => { + assert.strictEqual(await receiveBody(streamOf()), null); + }); + + await t.test('a single chunk is passed through without a copy', async () => { + assert.strictEqual((await receiveBody(streamOf('one'))).toString(), 'one'); + }); + + await t.test('multiple chunks are concatenated in order', async () => { + assert.strictEqual((await receiveBody(streamOf('a', 'b', 'c'))).toString(), 'abc'); + }); + + await t.test('the size limit is enforced across chunks, not per chunk', async () => { + await assert.rejects(receiveBody(streamOf('aaa', 'bbb', 'ccc'), 8), /Body size limit exceeded/); + }); + + await t.test('a nonsensical limit is refused instead of silently disabling the guard', async () => { + await assert.rejects(receiveBody(streamOf('x'), -1), TypeError); + await assert.rejects(receiveBody(streamOf('x'), 1.5), TypeError); + await assert.rejects(receiveBody(streamOf('x'), Number.MAX_SAFE_INTEGER + 10), TypeError); + }); +}); + +test('normalizeBody', async (t) => { + await t.test('absent bodies become null', () => { + assert.strictEqual(normalizeBody(undefined), null); + assert.strictEqual(normalizeBody(null), null); + }); + + await t.test('strings and buffers pass through untouched', () => { + const buffer = Buffer.from('{"a":1}'); + assert.strictEqual(normalizeBody('{"a":1}'), '{"a":1}'); + assert.strictEqual(normalizeBody(buffer), buffer); + }); + + await t.test('a typed-array view is wrapped without copying its bytes', () => { + const view = new Uint8Array([123, 125]); // '{}' + assert.strictEqual(normalizeBody(view).toString(), '{}'); + }); + + await t.test('a framework-parsed object is re-serialized for the core to re-parse', () => { + assert.strictEqual(normalizeBody({ type: 'call', id: '1' }), '{"type":"call","id":"1"}'); + }); + + await t.test('a non-serializable body reads as no body instead of throwing', () => { + const circular = { self: null }; + circular.self = circular; + assert.strictEqual(normalizeBody(circular), null); + }); +}); + +test('statusLine renders a uws status string, including unknown codes', () => { + assert.strictEqual(statusLine(200), '200 OK'); + assert.strictEqual(statusLine(404), '404 Not Found'); + assert.strictEqual(statusLine(599), '599 Unknown'); +}); + +test('eachHeader expands arrays into repeated header lines', () => { + const seen = []; + eachHeader( + { 'Content-Type': 'application/json', 'Content-Length': 12, 'Set-Cookie': ['a=1', 'b=2'] }, + (name, value) => seen.push([name, value]), + ); + assert.deepStrictEqual(seen, [ + ['Content-Type', 'application/json'], + // numbers are stringified: frameworks reject non-string header values + ['Content-Length', '12'], + ['Set-Cookie', 'a=1'], + ['Set-Cookie', 'b=2'], + ]); +}); diff --git a/tests/adapters/express.test.js b/tests/adapters/express.test.js new file mode 100644 index 0000000..989b166 --- /dev/null +++ b/tests/adapters/express.test.js @@ -0,0 +1,179 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const http = require('node:http'); +const { EventEmitter } = require('node:events'); + +const { WrpcClient, defineRouter, procedure } = require('../../index.js'); +const { createWrpc } = require('../../express.js'); +const { createUwsEngine } = require('../../uws.js'); +const { optional, requireUws } = require('./boots.js'); + +const express = optional('express'); +const uws = requireUws(); + +const noExpress = express ? false : 'express unavailable'; +const noUws = uws ? false : 'uWebSockets.js unavailable'; + +const createRouter = () => + defineRouter({ + probe: { + echo: procedure({ access: 'public', handler: async (_context, args) => args }), + }, + }); + +const postPacket = (url, method, args) => + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'call', id: '1', method, args }), + }); + +// The composition the adapter is written for: wrpc is middleware, express +// keeps its own routes, and the listener belongs to the app. +const boot = async (t, options = {}) => { + const { parseJson = false, ...wrpcOptions } = options; + const app = express(); + const wrpc = createWrpc({ router: createRouter(), logger: false, ...wrpcOptions }); + const bodies = []; + if (parseJson) app.use(express.json()); + // Records what the wrpc handler is about to see: `undefined` on the raw + // path, an already-parsed object behind express.json(). + app.use((req, _res, next) => { + bodies.push(req.body); + next(); + }); + app.use(wrpc.handler); + app.get('/later', (_req, res) => void res.send('later route')); + + const httpServer = http.createServer(app); + httpServer.on('upgrade', wrpc.upgrade); + // Registered before listen(): a failing assertion must not leave a live + // server (and its engine) behind. + t.after(async () => { + await wrpc.close().catch(() => {}); + httpServer.closeAllConnections(); + await new Promise((resolve) => httpServer.close(() => resolve())); + }); + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)); + const { port } = httpServer.address(); + return { app, wrpc, httpServer, bodies, port, origin: `http://127.0.0.1:${port}` }; +}; + +test('a path outside basePath falls through to the rest of the app', { skip: noExpress }, async (t) => { + const { origin } = await boot(t); + const res = await fetch(`${origin}/later`); + assert.strictEqual(res.status, 200, 'wrpc called next() instead of answering 404'); + assert.strictEqual(await res.text(), 'later route'); +}); + +test('raw body: a packet POST works with no body parser installed', { skip: noExpress }, async (t) => { + const { origin, bodies } = await boot(t); + const res = await postPacket(`${origin}/api`, 'probe/echo', { a: 1 }); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual((await res.json()).result, { a: 1 }); + assert.strictEqual(bodies[0], undefined, 'nothing parsed the body: the adapter read the stream'); +}); + +test('parsed body: express.json() upstream is re-serialized for the core', { skip: noExpress }, async (t) => { + const { origin, bodies } = await boot(t, { parseJson: true }); + const res = await postPacket(`${origin}/api`, 'probe/echo', { a: 1 }); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual((await res.json()).result, { a: 1 }); + assert.deepStrictEqual(bodies[0], { type: 'call', id: '1', method: 'probe/echo', args: { a: 1 } }); +}); + +test('a body over maxBodySize gets the 400 error packet', { skip: noExpress }, async (t) => { + const { origin } = await boot(t, { maxBodySize: 256 }); + const res = await fetch(`${origin}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'call', id: '1', method: 'probe/echo', args: { pad: 'x'.repeat(4096) } }), + }); + assert.strictEqual(res.status, 400); + const packet = await res.json(); + assert.strictEqual(packet.type, 'callback'); + assert.strictEqual(packet.error.code, 400); + assert.match(packet.error.message, /Body size limit exceeded/); +}); + +test('a standalone engine cannot be driven as middleware', { skip: noUws }, () => { + const engine = createUwsEngine({ uws }); + try { + assert.throws(() => createWrpc({ router: createRouter(), logger: false, engine }), /owns its own network stack/); + } finally { + engine.close(); + } +}); + +test('a non-Engine options.engine is refused at the boundary', () => { + assert.throws( + () => createWrpc({ router: createRouter(), logger: false, engine: {} }), + /does not implement the Engine contract/, + ); +}); + +test('an engine that cannot be upgraded by hand is refused', () => { + // Hosted, valid, but its source exposes no handleUpgrade — there is no way + // to drive the handshake from the app's own 'upgrade' listener. + const source = new EventEmitter(); + const engine = { name: 'fake', attach: () => source, close: () => {} }; + assert.throws( + () => createWrpc({ router: createRouter(), logger: false, engine }), + /does not support manual upgrades/, + ); +}); + +test('the upgrade listener carries WebSocket RPC', { skip: noExpress }, async (t) => { + const { port } = await boot(t); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`); + t.after(() => void client.close()); + await client.load('probe'); + assert.deepStrictEqual(await client.api.probe.echo({ b: 2 }), { b: 2 }); +}); + +test('close() tears the core down', { skip: noExpress }, async (t) => { + const { wrpc, port } = await boot(t); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`); + t.after(() => void client.close()); + await client.load('probe'); + assert.strictEqual(wrpc.rpc.clients.size, 1); + + const dropped = new Promise((resolve) => client.on('close', resolve)); + await wrpc.close(); + assert.strictEqual(wrpc.rpc.clients.size, 0); + await dropped; +}); + +test('codec.rest: a binary REST body round-trips through the middleware', { skip: noExpress }, async (t) => { + const codec = { + rest: { + contentType: 'application/x-wrpc-bin', + encode: (value) => Buffer.concat([Buffer.from([0xab]), Buffer.from(JSON.stringify(value ?? null))]), + decode: (body) => { + const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body); + if (buffer[0] !== 0xab) throw new Error('bad frame'); + return JSON.parse(buffer.subarray(1).toString()); + }, + }, + }; + const router = defineRouter({ + projects: { + create: procedure({ + access: 'public', + http: { method: 'POST', path: '/projects/:orgId', status: 201 }, + handler: async (_ctx, { params, body }) => ({ orgId: params.orgId, name: body?.name }), + }), + }, + }); + const { origin } = await boot(t, { router, codec }); + const res = await fetch(`${origin}/api/projects/9`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-wrpc-bin' }, + body: codec.rest.encode({ name: 'Bin' }), + }); + assert.strictEqual(res.status, 201); + assert.strictEqual(res.headers.get('content-type'), 'application/x-wrpc-bin'); + assert.deepStrictEqual(codec.rest.decode(Buffer.from(await res.arrayBuffer())), { orgId: '9', name: 'Bin' }); +}); diff --git a/tests/adapters/fastify.test.js b/tests/adapters/fastify.test.js new file mode 100644 index 0000000..1c38500 --- /dev/null +++ b/tests/adapters/fastify.test.js @@ -0,0 +1,860 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const http = require('node:http'); +const { EventEmitter } = require('node:events'); + +const { WrpcClient, RpcServer, defineRouter, procedure } = require('../../index.js'); +const { wrpcFastify, findUwsApp } = require('../../fastify.js'); +const { ProtocolClient } = require('../websocket/protocolClient.js'); +const { optional, quiet } = require('./boots.js'); + +const fastify = optional('fastify'); +// Deliberately NOT requireUws(): pnpm resolves a second uWebSockets.js copy +// for fastify-uws, and loading two builds of the native addon into one +// process segfaults on exit. This file only ever touches the copy +// fastify-uws itself loads. +const fastifyUws = optional('fastify-uws'); + +const noFastify = fastify ? false : 'fastify unavailable'; +const noFastifyUws = fastify && fastifyUws ? false : 'fastify-uws unavailable'; + +// `mark` starts a session from inside a route handler: the session lands in +// the SessionManager of whichever RpcServer served the call, so finding it +// in the decorated core proves the routes and the decorator share one. +const createRouter = (trace = []) => + defineRouter({ + probe: { + echo: procedure({ + access: 'public', + handler: async (_context, args) => { + trace.push('procedure'); + return args; + }, + }), + mark: procedure({ + access: 'public', + handler: async (context, args) => { + context.client.startSession(undefined, { marker: args.marker }); + return { ok: true }; + }, + }), + }, + }); + +// `Connection: close` on purpose: fastify-uws does not drop idle keep-alive +// sockets when it closes, and a pooled one would hold the run open for the +// whole undici keep-alive timeout. +const postPacket = (url, method, args) => + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Connection: 'close' }, + body: JSON.stringify({ type: 'call', id: '1', method, args }), + }); + +// `bodyLimit` is a fastify CONSTRUCTOR option, not a plugin one — it has to +// be split out here or it would be forwarded to wrpcFastify and ignored. +const boot = async (t, options = {}) => { + const { serverFactory = null, hooks = null, bodyLimit, ...pluginOptions } = options; + const app = fastify({ + ...(serverFactory ? { serverFactory } : {}), + ...(bodyLimit === undefined ? {} : { bodyLimit }), + logger: false, + }); + // Teardown is registered before anything can throw: a leaked app keeps a + // listening socket (a native one under uws) and would wedge the whole run + // instead of failing the test. + t.after(async () => { + app.server.closeAllConnections?.(); + await app.close().catch(() => {}); + }); + if (hooks) for (const [name, handler] of Object.entries(hooks)) app.addHook(name, handler); + await app.register(wrpcFastify, { logger: false, ...pluginOptions }); + await app.listen({ host: '127.0.0.1', port: 0 }); + const { port } = app.server.address(); + return { app, port, origin: `http://127.0.0.1:${port}` }; +}; + +// A fastify look-alike: enough surface for the plugin body, with the +// backend detection pointed at whatever `server` we hand it. +const fakeFastify = (server = {}) => { + const instance = { + server, + log: quiet, + routes: [], + hooks: new Map(), + decorations: new Map(), + route: (definition) => void instance.routes.push(definition), + decorate: (name, value) => void instance.decorations.set(name, value), + addHook: (name, handler) => void instance.hooks.set(name, handler), + }; + return instance; +}; + +const fakeEngine = (overrides = {}) => { + const source = new EventEmitter(); + const engine = { + name: 'fake', + source, + attached: null, + closed: false, + attach(attachOptions) { + engine.attached = attachOptions; + return source; + }, + close() { + engine.closed = true; + }, + ...overrides, + }; + return engine; +}; + +test('backend detection: a plain fastify runs on the node engine', { skip: noFastify }, async (t) => { + const { app, origin, port } = await boot(t, { router: createRouter() }); + + assert.ok(app.server instanceof http.Server, 'plain fastify owns a real node http server'); + assert.strictEqual(findUwsApp(app.server), null); + // The node engine drives the handshake off the http server's own event. + assert.strictEqual(app.server.listenerCount('upgrade'), 1); + + const res = await postPacket(`${origin}/api`, 'probe/echo', { a: 1 }); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual((await res.json()).result, { a: 1 }); + + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`); + t.after(() => void client.close()); + await client.load('probe'); + assert.deepStrictEqual(await client.api.probe.echo({ b: 2 }), { b: 2 }); +}); + +test('backend detection: a fastify-uws instance runs on the uws engine', { skip: noFastifyUws }, async (t) => { + const { app, origin, port } = await boot(t, { + router: createRouter(), + serverFactory: fastifyUws.serverFactory, + }); + + assert.ok(!(app.server instanceof http.Server), 'fastify-uws fakes the node server'); + const uwsApp = findUwsApp(app.server); + assert.ok(uwsApp, 'the uws TemplatedApp is reachable on the fake server'); + assert.strictEqual(typeof uwsApp.ws, 'function'); + // Nothing was bound to a node upgrade event — uws owns the handshake. + assert.strictEqual(app.server.listenerCount('upgrade'), 0); + + const res = await postPacket(`${origin}/api`, 'probe/echo', { a: 1 }); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual((await res.json()).result, { a: 1 }); + + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`); + t.after(() => void client.close()); + await client.load('probe'); + assert.deepStrictEqual(await client.api.probe.echo({ b: 2 }), { b: 2 }); +}); + +test('findUwsApp: only a real uws app on a uws-backed server passes', () => { + assert.strictEqual(findUwsApp(null), null); + assert.strictEqual(findUwsApp(undefined), null); + assert.strictEqual(findUwsApp({}), null); + assert.strictEqual(findUwsApp('nope'), null); + assert.strictEqual(findUwsApp(http.createServer()), null); + + // The symbol is matched by description, not identity: fastify-uws exports + // no accessor, so a private Symbol('uws.app') is all there is to go on. + const app = { ws: () => {}, any: () => {}, listen: () => {} }; + assert.strictEqual(findUwsApp({ [Symbol('uws.app')]: app }), app); + // Right symbol, wrong payload — still not a TemplatedApp. + assert.strictEqual(findUwsApp({ [Symbol('uws.app')]: {} }), null); + assert.strictEqual(findUwsApp({ [Symbol('other.app')]: app }), null); +}); + +test('options.engine short-circuits detection and preClose owns teardown', async () => { + const engine = fakeEngine(); + // A server nothing can detect: without the explicit engine this throws. + const instance = fakeFastify({ notDetectable: true }); + await wrpcFastify(instance, { router: createRouter(), logger: false, engine }); + + assert.ok(engine.attached, 'the injected engine was attached'); + assert.strictEqual(engine.attached.server, instance.server, 'a hosted engine gets the fastify server'); + assert.ok(instance.decorations.get('wrpc') instanceof RpcServer); + assert.deepStrictEqual( + instance.routes.map((route) => route.url), + ['/api', '/api/events', '/api/:unit/:method'], + 'packet route + SSE stream + REST route', + ); + assert.ok(instance.hooks.has('preClose')); + + assert.strictEqual(engine.closed, false); + await instance.hooks.get('preClose')(); + assert.strictEqual(engine.closed, true); +}); + +test('a non-Engine options.engine is refused at the boundary', async () => { + const instance = fakeFastify(http.createServer()); + await assert.rejects( + wrpcFastify(instance, { router: createRouter(), logger: false, engine: {} }), + /does not implement the Engine contract/, + ); + assert.strictEqual(instance.routes.length, 0, 'nothing is registered when the engine is rejected'); +}); + +test('a fastify instance with no detectable backend fails loudly', async () => { + const instance = fakeFastify({ neitherNodeNorUws: true }); + await assert.rejects( + wrpcFastify(instance, { router: createRouter(), logger: false }), + /could not detect a WebSocket backend/, + ); +}); + +test('the wrpc decorator is the very core the routes call through', { skip: noFastify }, async (t) => { + const { app, origin } = await boot(t, { router: createRouter() }); + + assert.ok(app.wrpc instanceof RpcServer); + // skip-override: the decorator and the routes belong to the ROOT instance, + // not to a child scope that vanishes with the plugin. + assert.strictEqual(app.hasDecorator('wrpc'), true); + assert.strictEqual(wrpcFastify[Symbol.for('skip-override')], true); + + const res = await postPacket(`${origin}/api`, 'probe/mark', { marker: 'from-the-route' }); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual((await res.json()).result, { ok: true }); + + // The session the route handler started is in the decorated core's own + // SessionManager — one core, not one per surface. + const [cookie] = res.headers.getSetCookie(); + const token = cookie.match(/^token=([^;]+)/)[1]; + const session = await app.wrpc.sessions.restore(token); + assert.ok(session, 'the decorated core knows the session the route created'); + assert.strictEqual(session.state.marker, 'from-the-route'); +}); + +test('fastify hooks run before wrpc sees the call', { skip: noFastify }, async (t) => { + // The point of routing RPC through fastify's own router: the app's hooks, + // auth and error handling run first, on RPC calls like on any other route. + const trace = []; + const seen = []; + const onRequest = async (request) => { + trace.push('hook'); + seen.push(`${request.method} ${request.url}`); + }; + const { origin } = await boot(t, { router: createRouter(trace), hooks: { onRequest } }); + + const res = await postPacket(`${origin}/api`, 'probe/echo', { hooked: true }); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual((await res.json()).result, { hooked: true }); + assert.deepStrictEqual(seen, ['POST /api']); + assert.deepStrictEqual(trace, ['hook', 'procedure'], 'the hook ran before the procedure'); +}); + +test('preClose tears the core down and leaves no sockets', { skip: noFastify }, async (t) => { + const { app, port } = await boot(t, { router: createRouter() }); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`); + t.after(() => void client.close()); + await client.load('probe'); + assert.deepStrictEqual(await client.api.probe.echo({ ok: true }), { ok: true }); + assert.strictEqual(app.wrpc.clients.size, 1, 'the WebSocket client is tracked by the core'); + + await app.close(); + assert.strictEqual(app.wrpc.clients.size, 0); + assert.strictEqual(app.server.listening, false); +}); + +test('an in-flight HTTP call keeps its client until the response is written', { skip: noFastify }, async (t) => { + // Regression: onAbort used to be wired to request.raw, whose 'close' fires + // as soon as fastify finishes consuming the body — before the handler runs. + // Every HTTP client was therefore destroyed at call start: rpc.clients was + // empty mid-call and a handler watching client 'close' saw a false abort. + let observed = null; + let abortedEarly = false; + const router = defineRouter({ + probe: { + inspect: procedure({ + access: 'public', + handler: async (context) => { + context.client.on('close', () => { + abortedEarly = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + // Sampled INSIDE the handler: after the response is written the + // client is evicted for real, so checking afterwards proves nothing. + observed = { + tracked: app.wrpc.clients.has(context.client), + size: app.wrpc.clients.size, + abortedEarly, + }; + return { ok: true }; + }, + }), + }, + }); + const booted = await boot(t, { router }); + const { app, origin } = booted; + + const res = await postPacket(`${origin}/api`, 'probe/inspect', {}); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual((await res.json()).result, { ok: true }); + assert.deepStrictEqual( + observed, + { tracked: true, size: 1, abortedEarly: false }, + 'the client must outlive the handler, with no abort while the call runs', + ); + + // ...and it is evicted once the response is out + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.strictEqual(app.wrpc.clients.size, 0); +}); + +test("fastify's own logger is adapted, not handed to the core raw", { skip: noFastify }, async (t) => { + // Regression: the plugin defaulted `console` to fastify.log, but that is + // pino — it has info/warn/error and no `log`, which the core calls after + // writing every successful callback. `fastify.log` is always truthy, so + // `??` never fell through to globalThis.console. The throw landed inside + // handleRpc's catch, which then answered a SECOND time: over WebSocket + // every successful call came back as the result AND a bogus 500, because + // (unlike the HTTP transport) a ws transport has no responded-once guard. + const app = fastify({ logger: { level: 'silent' } }); + t.after(async () => { + app.server.closeAllConnections?.(); + await app.close().catch(() => {}); + }); + assert.strictEqual(typeof app.log.log, 'undefined', 'pino has no console.log'); + + // Booting WITHOUT an explicit `console` is the whole point of this test. + await app.register(wrpcFastify, { router: createRouter() }); + await app.listen({ host: '127.0.0.1', port: 0 }); + const { port } = app.server.address(); + + const rest = await postPacket(`http://127.0.0.1:${port}/api`, 'probe/echo', { via: 'pino' }); + assert.strictEqual(rest.status, 200); + assert.deepStrictEqual((await rest.json()).result, { via: 'pino' }); + + // Counted at the frame level: a WrpcClient would resolve on the first + // callback and quietly ignore the second, hiding the defect. + const peer = new ProtocolClient(`ws://127.0.0.1:${port}/api`); + t.after(() => void peer.close()); + await new Promise((resolve) => peer.on('open', resolve)); + const frames = []; + peer.on('message', (data) => frames.push(JSON.parse(data.toString()))); + peer.sendText(JSON.stringify({ type: 'call', id: '9', method: 'probe/echo', args: { via: 'ws' } })); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.deepStrictEqual(frames, [{ type: 'callback', id: '9', result: { via: 'ws' } }]); +}); + +test('a partial fastify logger is accepted rather than rejected', { skip: noFastify }, async (t) => { + const app = fakeFastify({}); + app.log = { warn: () => {} }; // neither a full console nor a pino + const engine = fakeEngine(); + await wrpcFastify(app, { router: createRouter(), engine }); + assert.ok(app.decorations.get('wrpc') instanceof RpcServer); + t.after(() => engine.close()); +}); + +test('a fastify pino goes in as a structured logger', { skip: noFastify }, async (t) => { + const entries = []; + const app = fakeFastify({}); + // The shape fastify's default logger actually has: `child`/`level` and no + // `log`, which used to need an adapter in between. + const pinoLike = { + level: 'info', + child: () => pinoLike, + info: (entry, message) => entries.push([entry, message]), + debug: () => {}, + warn: () => {}, + error: () => {}, + }; + app.log = pinoLike; + const engine = fakeEngine(); + await wrpcFastify(app, { router: createRouter(), engine }); + const rpc = app.decorations.get('wrpc'); + assert.ok(rpc instanceof RpcServer); + rpc.broadcast('ping', 1); + t.after(() => engine.close()); +}); + +test("body limits are fastify's, not the adapter's", { skip: noFastify }, async (t) => { + // This plugin never touches the request stream — fastify parses the body + // and hands over request.body — so unlike the express and uws adapters + // there is nothing here to meter. fastify's own bodyLimit guards the RPC + // routes and answers 413 before the handler runs. + const oversized = (bytes) => + JSON.stringify({ type: 'call', id: '1', method: 'probe/echo', args: { big: 'x'.repeat(bytes) } }); + + await t.test('the app-wide bodyLimit applies to the RPC routes', async (sub) => { + const { origin } = await boot(sub, { router: createRouter(), bodyLimit: 512 }); + const res = await fetch(`${origin}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Connection: 'close' }, + body: oversized(4096), + }); + assert.strictEqual(res.status, 413); + assert.strictEqual((await res.json()).code, 'FST_ERR_CTP_BODY_TOO_LARGE'); + }); + + await t.test('maxBodySize narrows it per route', async (sub) => { + const { origin } = await boot(sub, { router: createRouter(), maxBodySize: 512 }); + const res = await fetch(`${origin}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Connection: 'close' }, + body: oversized(4096), + }); + assert.strictEqual(res.status, 413); + + const ok = await postPacket(`${origin}/api`, 'probe/echo', { small: true }); + assert.strictEqual(ok.status, 200); + }); + + await t.test('unset, the app keeps its own limit — a plugin must not raise it', async (sub) => { + // MAX_BODY_SIZE (10 MiB) is deliberately NOT applied as a default here: + // silently loosening the host's 1 MiB limit would be a regression the + // app never asked for. + const { origin } = await boot(sub, { router: createRouter(), bodyLimit: 512 }); + const res = await fetch(`${origin}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Connection: 'close' }, + body: oversized(4096), + }); + assert.strictEqual(res.status, 413); + }); +}); + +// --------------------------------------------------------------------------- +// Delegated REST routes: a procedure with an `http` mapping becomes a real +// fastify route — fastify owns routing, validation, serialization and error +// shape; wrpc supplies the Context and the bare handler. + +// Ajv-shaped, permissive: the delegated HTTP path is validated by fastify's +// real ajv; injecting one here satisfies the router's "a schema must have a +// compiler" rule for the ws path without pulling ajv into devDependencies. +const permissiveAjv = { compile: () => () => true }; + +const restRouter = (trace = []) => + defineRouter( + { + projects: { + create: procedure({ + access: 'public', + http: { method: 'POST', path: '/projects/:orgId', status: 201 }, + schema: { + params: { type: 'object', properties: { orgId: { type: 'string' } }, required: ['orgId'] }, + body: { type: 'object', properties: { name: { type: 'string', minLength: 2 } }, required: ['name'] }, + response: { + 201: { type: 'object', properties: { orgId: { type: 'string' }, name: { type: 'string' } } }, + }, + tags: ['Projects'], + }, + preHandler: async (_context, args) => void trace.push(`proc:preHandler:${args.body.name}`), + handler: async (_context, { params, body }) => { + trace.push('handler'); + return { orgId: params.orgId, name: body.name, secret: 'trimmed by fjs' }; + }, + }), + secure: procedure({ + http: { method: 'GET', path: '/projects/:id/secure' }, + handler: async () => ({ ok: true }), + }), + slow: procedure({ + access: 'public', + timeout: 20, + http: { method: 'GET', path: '/projects/:id/slow' }, + handler: () => new Promise(() => {}), + }), + login: procedure({ + access: 'public', + http: { method: 'POST', path: '/login' }, + handler: async (context) => { + context.client.startSession(undefined, { user: 'ada' }); + return { ok: true }; + }, + }), + stamped: procedure({ + access: 'public', + http: { method: 'GET', path: '/projects-stamped' }, + schema: { + headers: { + type: 'object', + properties: { 'x-app-version': { type: 'string' } }, + required: ['x-app-version'], + }, + }, + handler: async (context) => ({ v: context.meta.headers['x-app-version'] }), + }), + }, + }, + { + validation: { ajv: permissiveAjv }, + hooks: { + onRequest: async () => void trace.push('router:onRequest'), + onResponse: async () => void trace.push('router:onResponse'), + onError: async (_context, error) => void trace.push(`router:onError:${error.code ?? 'none'}`), + }, + }, + ); + +test('delegated REST: fastify validates, serializes and answers wire errors', { skip: noFastify }, async (t) => { + const trace = []; + const app = fastify({ logger: false }); + t.after(() => app.close()); + await app.register(wrpcFastify, { router: restRouter(trace), logger: false }); + await app.ready(); + + await t.test('a valid call: status from http.status, fjs trims the body, hooks ran in order', async () => { + trace.length = 0; + const res = await app.inject({ method: 'POST', url: '/api/projects/42', payload: { name: 'Alpha' } }); + assert.strictEqual(res.statusCode, 201); + assert.deepStrictEqual(res.json(), { orgId: '42', name: 'Alpha' }); + assert.deepStrictEqual(trace, ['router:onRequest', 'proc:preHandler:Alpha', 'handler', 'router:onResponse']); + }); + + await t.test("fastify's schema validation answers the wire error shape with details", async () => { + trace.length = 0; + const res = await app.inject({ method: 'POST', url: '/api/projects/42', payload: { name: 'A' } }); + assert.strictEqual(res.statusCode, 400); + const body = res.json(); + assert.strictEqual(body.code, 400); + assert.match(body.message, /fewer than 2 characters/); + assert.strictEqual(body.details.issues.length, 1); + // wrpc's own validator never ran — fastify's did, once. + assert.strictEqual(trace.includes('handler'), false); + assert.strictEqual(trace.at(-1), 'router:onResponse'); + }); + + await t.test('access !== public without a session answers 403 before the handler', async () => { + trace.length = 0; + const res = await app.inject({ method: 'GET', url: '/api/projects/1/secure' }); + assert.strictEqual(res.statusCode, 403); + assert.strictEqual(res.json().code, 403); + assert.strictEqual(trace.includes('handler'), false); + }); + + await t.test('invokeBare keeps the timeout: 408 through the wire error handler and onError', async () => { + trace.length = 0; + const res = await app.inject({ method: 'GET', url: '/api/projects/1/slow' }); + assert.strictEqual(res.statusCode, 408); + assert.strictEqual(res.json().code, 408); + assert.ok(trace.includes('router:onError:408')); + }); + + await t.test('schema.headers travels verbatim: fastify validates it on the delegated route', async () => { + // The carve-out is structural — invokeBare runs no wrpc validators — so + // the ONLY headers check on this path is fastify's own. + const missing = await app.inject({ method: 'GET', url: '/api/projects-stamped' }); + assert.strictEqual(missing.statusCode, 400); + assert.match(missing.json().message, /x-app-version/); + const ok = await app.inject({ + method: 'GET', + url: '/api/projects-stamped', + headers: { 'x-app-version': '5.5' }, + }); + assert.strictEqual(ok.statusCode, 200); + assert.deepStrictEqual(ok.json(), { v: '5.5' }); + }); + + await t.test('startSession from a delegated handler sets the cookie on the fastify reply', async () => { + const res = await app.inject({ method: 'POST', url: '/api/login', payload: {} }); + assert.strictEqual(res.statusCode, 200); + assert.match(String(res.headers['set-cookie'] ?? ''), /token=/); + }); + + await t.test('the delegated route beats the conventional parametric one; packet mode intact', async () => { + const packet = { + type: 'call', + id: '1', + method: 'projects/create', + args: { params: { orgId: 'x' }, query: {}, body: { name: 'Beta' } }, + }; + const res = await app.inject({ method: 'POST', url: '/api', payload: packet }); + assert.strictEqual(res.json().result.name, 'Beta'); + }); + + await t.test('the schema fastify sees is the effective one — wrpc error statuses documented', async () => { + const probe = fastify({ logger: false }); + t.after(() => probe.close()); + const seen = []; + probe.addHook('onRoute', (route) => { + if (route.method === 'POST' && route.url === '/api/projects/:orgId') seen.push(route.schema); + }); + await probe.register(wrpcFastify, { router: restRouter(), logger: false }); + await probe.ready(); + assert.strictEqual(seen.length, 1); + assert.deepStrictEqual(seen[0].tags, ['Projects']); + for (const code of ['201', '400', '429', '500', '503']) assert.ok(seen[0].response[code], `response ${code}`); + }); +}); + +test('delegated REST: the context carries its call identity', { skip: noFastify }, async (t) => { + const seen = []; + const router = defineRouter( + { + projects: { + find: procedure({ + access: 'public', + http: { method: 'GET', path: '/projects/:id' }, + handler: async (context, { params }) => ({ id: params.id, method: context.method }), + }), + }, + }, + { + hooks: { + onRequest: async (context) => void seen.push([context.method, context.procedure]), + }, + }, + ); + const app = fastify({ logger: false }); + t.after(() => app.close()); + await app.register(wrpcFastify, { router, logger: false }); + await app.ready(); + const res = await app.inject({ method: 'GET', url: '/api/projects/7' }); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(res.json(), { id: '7', method: 'projects/find' }); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0][0], 'projects/find'); + assert.strictEqual(seen[0][1], router.getProcedure('projects', undefined, 'find')); +}); + +test('delegated REST: rest.version registers the prefixed url', { skip: noFastify }, async (t) => { + const seen = []; + const router = defineRouter( + { + 'auth.v1': { + signIn: procedure({ + access: 'public', + http: { method: 'POST', path: '/auth/signIn' }, + handler: async (context) => ({ method: context.method }), + }), + }, + }, + { + rest: { version: 'path' }, + hooks: { onRequest: async (context) => void seen.push(context.method) }, + }, + ); + const app = fastify({ logger: false }); + t.after(() => app.close()); + await app.register(wrpcFastify, { router, logger: false }); + await app.ready(); + const res = await app.inject({ method: 'POST', url: '/api/v1/auth/signIn', payload: {} }); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual( + res.json(), + { method: 'auth.v1/signIn' }, + 'the delegated context carries the versioned target', + ); + assert.deepStrictEqual(seen, ['auth.v1/signIn']); +}); + +test('codec.rest and delegated REST routes refuse each other loudly', { skip: noFastify }, async (t) => { + const restCodec = { rest: { encode: (value) => JSON.stringify(value), decode: (body) => JSON.parse(String(body)) } }; + const mapped = () => + defineRouter({ + projects: { + find: procedure({ access: 'public', http: { method: 'GET', path: '/projects/:id' }, handler: async () => 1 }), + }, + }); + + await t.test('codec.rest + http mappings throws at ready', async () => { + const app = fastify({ logger: false }); + t.after(() => app.close().catch(() => {})); + app.register(wrpcFastify, { router: mapped(), logger: false, codec: restCodec }); + await assert.rejects(() => app.ready(), /codec\.rest and delegated REST routes .* are mutually exclusive/); + }); + + await t.test('the options.rpc path is covered by the same check', async () => { + const app = fastify({ logger: false }); + t.after(() => app.close().catch(() => {})); + const rpc = new RpcServer({ router: mapped(), logger: false, codec: restCodec }); + app.register(wrpcFastify, { rpc, logger: false }); + await assert.rejects(() => app.ready(), /mutually exclusive/); + }); + + await t.test('codec.rest without http mappings registers normally', async () => { + const app = fastify({ logger: false }); + t.after(() => app.close()); + await app.register(wrpcFastify, { router: createRouter(), logger: false, codec: restCodec }); + await app.ready(); + const injected = await app.inject({ + method: 'POST', + url: '/api', + payload: { type: 'call', id: '1', method: 'probe/echo', args: { a: 1 } }, + }); + assert.strictEqual(injected.statusCode, 200); + }); +}); + +test('delegated REST: restErrors "app" leaves the error shape to the app', { skip: noFastify }, async (t) => { + const app = fastify({ logger: false }); + t.after(() => app.close()); + await app.register(wrpcFastify, { router: restRouter(), logger: false, restErrors: 'app' }); + await app.ready(); + const res = await app.inject({ method: 'GET', url: '/api/projects/1/secure' }); + assert.strictEqual(res.statusCode, 403); + // fastify's default error body, not wrpc's wire shape + assert.strictEqual(res.json().error, 'Forbidden'); +}); + +// --------------------------------------------------------------------------- +// Reverse engineering: the app's own routes become wrpc procedures, +// dispatched through fastify.inject so the route's whole pipeline runs. + +const { defaultName, defaultUnit, routeSignature } = require('../../src/adapters/mirror.js'); + +test('mirror naming: reverse REST semantics', () => { + const knownUnits = new Set(['projects']); + const name = (method, url) => { + const segments = url.split('/').filter((s) => s.length > 0); + const named = defaultUnit(segments, knownUnits); + return `${named.unit}/${defaultName(method, segments.slice(named.tailIndex))}`; + }; + assert.strictEqual(name('POST', '/projects'), 'projects/create'); + assert.strictEqual(name('POST', '/workspace/projects/:orgId'), 'projects/create'); + assert.strictEqual(name('GET', '/workspace/projects'), 'projects/findAll'); + assert.strictEqual(name('GET', '/workspace/projects/:id'), 'projects/findById'); + assert.strictEqual(name('GET', '/workspace/projects/slug/:slug'), 'projects/findBySlug'); + assert.strictEqual(name('GET', '/workspace/projects/archive'), 'projects/findAllArchive'); + assert.strictEqual(name('PATCH', '/projects/:id'), 'projects/update'); + assert.strictEqual(name('PUT', '/projects/:id'), 'projects/replace'); + assert.strictEqual(name('DELETE', '/projects/:id'), 'projects/delete'); + assert.strictEqual(name('POST', '/projects/:orgId/archive/:id'), 'projects/createArchive'); + assert.strictEqual(name('DELETE', '/org-users/:id'), 'orgUsers/delete'); + assert.strictEqual(defaultUnit([':id']), null); +}); + +test('mirror signature: JSON Schema distilled into the closed format', () => { + const signature = routeSignature({ + params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + body: { + type: 'object', + properties: { name: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } } }, + required: ['name'], + }, + response: { 200: { type: 'object', properties: { id: { type: 'string' }, count: { type: 'integer' } } } }, + }); + assert.deepStrictEqual(signature, { + args: { + params: { id: 'string' }, + body: { name: 'string', 'tags?': ['string'] }, + }, + returns: { 'id?': 'string', 'count?': 'number' }, + }); + // Anything inexpressible degrades to 'unknown', never a guess. + assert.deepStrictEqual(routeSignature({ body: { oneOf: [] } }), { args: { body: 'unknown' } }); + assert.strictEqual(routeSignature(null), null); +}); + +test('mirror: app routes become callable wrpc procedures', { skip: noFastify }, async (t) => { + const app = fastify({ logger: false }); + t.after(() => app.close()); + const router = defineRouter({ own: { ping: procedure({ access: 'public', handler: async () => 'pong' }) } }); + await app.register(wrpcFastify, { router, logger: false, mirror: { access: 'public' } }); + + const seenAuth = []; + app.addHook('onRequest', async (req) => void seenAuth.push(req.headers['x-auth'] ?? null)); + app.post( + '/workspace/projects/:orgId', + { + schema: { + body: { type: 'object', properties: { name: { type: 'string', minLength: 2 } }, required: ['name'] }, + response: { 200: { type: 'object', properties: { name: { type: 'string' }, orgId: { type: 'string' } } } }, + }, + }, + async (req) => ({ name: req.body.name, orgId: req.params.orgId, secret: 'trimmed' }), + ); + app.get('/workspace/projects/slug/:slug', async (req) => ({ slug: req.params.slug })); + app.get('/workspace/projects', async (req) => ({ q: req.query })); + app.get('/boom/:id', async (req, reply) => + reply.code(404).send({ message: 'nope', code: 404, details: { id: req.params.id } }), + ); + app.route({ + method: 'GET', + url: '/named', + config: { wrpc: { unit: 'misc', name: 'custom' } }, + handler: async () => ({ ok: 1 }), + }); + app.route({ method: 'GET', url: '/hidden', config: { wrpc: false }, handler: async () => ({}) }); + + await app.listen({ host: '127.0.0.1', port: 0 }); + const port = app.server.address().port; + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false, reconnect: false }); + t.after(() => void client.close()); + + await t.test('the full route pipeline runs: hooks, validation, serialization', async () => { + await client.load('projects'); + const created = await client.api.projects.create({ params: { orgId: '7' }, body: { name: 'Beta' } }); + assert.deepStrictEqual(created, { name: 'Beta', orgId: '7' }); // fjs trimmed `secret` + await assert.rejects(client.api.projects.create({ params: { orgId: '7' }, body: { name: 'B' } }), (error) => { + assert.strictEqual(error.code, 400); + return true; + }); + }); + + await t.test('reverse naming and the query leg', async () => { + assert.deepStrictEqual(await client.api.projects.findBySlug({ params: { slug: 'alpha' } }), { slug: 'alpha' }); + assert.deepStrictEqual(await client.api.projects.findAll({ query: { x: '1' } }), { q: { x: '1' } }); + }); + + await t.test('route errors flow through with status, message and details', async () => { + await client.load('boom'); + await assert.rejects(client.api.boom.findById({ params: { id: 'zz' } }), (error) => { + assert.strictEqual(error.code, 404); + assert.strictEqual(error.message, 'nope'); + assert.deepStrictEqual(error.details, { id: 'zz' }); + return true; + }); + }); + + await t.test('config.wrpc: object renames, false hides, wrpc routes never mirror', async () => { + await client.load('misc'); + assert.deepStrictEqual(await client.api.misc.custom(), { ok: 1 }); + // 'hidden' is opted out entirely — not even introspectable. + const res = await fetch(`http://127.0.0.1:${port}/api/system/introspect`); + const { result } = await res.json(); + assert.strictEqual('hidden' in result, false); + }); + + await t.test('the headers option maps the wrpc context into the injected request', async () => { + // A second app whose mirror stamps a header from the context. + const app2 = fastify({ logger: false }); + t.after(() => app2.close()); + await app2.register(wrpcFastify, { + router: defineRouter({}), + logger: false, + mirror: { access: 'public', headers: (context) => ({ 'x-auth': `ctx-${typeof context.uuid}` }) }, + }); + const stamped = []; + app2.get('/things', async (req) => { + stamped.push(req.headers['x-auth']); + return { ok: true }; + }); + await app2.listen({ host: '127.0.0.1', port: 0 }); + const port2 = app2.server.address().port; + const client2 = await WrpcClient.connect(`ws://127.0.0.1:${port2}/api`, { heartbeat: false, reconnect: false }); + t.after(() => void client2.close()); + await client2.load('things'); + await client2.api.things.findAll(); + assert.deepStrictEqual(stamped, ['ctx-string']); + }); + + await t.test('mirrored signatures reach introspection', async () => { + const res = await fetch(`http://127.0.0.1:${port}/api/system/introspect`); + const packet = await res.json(); + assert.deepStrictEqual(packet.result.projects.create.signature.args.body, { name: 'string' }); + assert.deepStrictEqual(packet.result.projects.create.meta.mirrored, { + method: 'POST', + path: '/workspace/projects/:orgId', + }); + }); + + await t.test('the wrpc router the app declared is untouched', async () => { + await client.load('own'); + assert.strictEqual(await client.api.own.ping(), 'pong'); + }); +}); + +test('mirror: a naming collision throws at onReady with a hint', { skip: noFastify }, async (t) => { + const app = fastify({ logger: false }); + t.after(() => app.close()); + await app.register(wrpcFastify, { router: defineRouter({}), logger: false, mirror: true }); + app.get('/things/:id', async () => ({})); + app.get('/things/special', async () => ({})); + app.get('/other/things/special', async () => ({})); + await assert.rejects(app.ready(), /things\/findAllSpecial.*config\.wrpc\.name/s); +}); diff --git a/tests/adapters/spec.js b/tests/adapters/spec.js new file mode 100644 index 0000000..6fbbb26 --- /dev/null +++ b/tests/adapters/spec.js @@ -0,0 +1,557 @@ +'use strict'; + +// One behavioral specification, five boots. +// +// Every supported way of standing wrpc up — the batteries-included Server over +// either engine, the fastify plugin over either backend, express middleware on +// a listener it does not own — has to answer identically. So the behavior is +// written once, here, and tests/adapters/swap.test.js replays it against each +// entry of buildBoots(). The only sanctioned difference is who answers a path +// outside basePath (`entry.pathMiss`). +// +// Not a *.test.js — imported by the adapter tests. +// +// Cleanup discipline: every boot registers its close through `t.after` the +// moment it resolves, never as a trailing await. A standalone (uws) engine +// holds a native listen socket, and an assertion failing before an un-hooked +// close would wedge the whole run instead of failing it. + +const assert = require('node:assert'); +const { randomUUID } = require('node:crypto'); +const { Blob } = require('node:buffer'); + +const { WrpcClient, defineRouter, procedure, createEventStream, tracked } = require('../../index.js'); + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +// Filled by the `chat.on.typing` event handler, drained by `chat/typed`. +const typedEvents = []; + +// Open `chat/forever` subscriptions, so a spec can watch one being released. +const liveSubscriptions = new Set(); + +// The single router definition every boot shares. +const router = defineRouter({ + test: { + hello: procedure({ + access: 'public', + handler: async (_context, { name }) => `Hello, ${name}`, + }), + // REST mode hands args in as a plain object: the assertions on argument + // shape (query strings, body merge) all go through here. + echo: procedure({ + access: 'public', + handler: async (_context, args) => args, + }), + fail: procedure({ + access: 'public', + handler: async () => { + const error = new Error('Boom'); + error.code = 418; + throw error; + }, + }), + // What proves no host drops the request identity on its way in. + peekMeta: procedure({ + access: 'public', + handler: async (context) => ({ + url: context.meta.url, + protocol: context.meta.protocol, + hasHeaders: Object.keys(context.meta.headers).length > 0, + appVersion: context.meta.headers['x-app-version'] ?? null, + }), + }), + whoami: procedure({ + access: 'session', + handler: async (context) => ({ user: context.session.state.user }), + }), + notify: procedure({ + access: 'public', + handler: async (context) => { + context.client.sendEvent('test/ping', { ping: true }); + return { ok: true }; + }, + }), + // A declaratively-mapped route: every boot must serve it as a real REST + // endpoint — plain result body, status from http.status — whether the + // host is the core trie (shell/express/uws) or fastify's own router + // (the delegated route). + mapped: procedure({ + access: 'public', + http: { method: 'POST', path: '/things/:thingId', status: 201 }, + handler: async (_context, { params, query, body }) => ({ thingId: params.thingId, q: query, name: body?.name }), + }), + readUpload: procedure({ + access: 'public', + handler: async (context, { id }) => { + const stream = context.client.getStream(id); + const chunks = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return { name: stream.name, size: stream.size, data: Buffer.concat(chunks).toString('utf8') }; + }, + }), + download: procedure({ + access: 'public', + handler: async (context, { name }) => { + const payload = Buffer.from('payload from the server'); + const stream = context.client.createStream(name, payload.length); + queueMicrotask(() => { + stream.write(payload); + stream.end(); + }); + return { id: stream.id }; + }, + }), + }, + // Rooms and inbound events are core behavior, so every boot has to answer + // for them too. `typed` drains what the event handler recorded — an event + // never answers on the wire, so a call is the only way to observe it. + chat: { + join: procedure({ + access: 'public', + handler: async (context, { room }) => { + context.client.join(room); + return { rooms: [...context.client.rooms] }; + }, + }), + leave: procedure({ + access: 'public', + handler: async (context, { room }) => ({ left: context.client.leave(room) }), + }), + shout: procedure({ + access: 'public', + handler: async (context, { room, text, self = true }) => { + const target = self ? context.server.to(room) : context.server.to(room).except(context.client); + return { sent: target.emit('chat/message', { text }) }; + }, + }), + members: procedure({ + access: 'public', + handler: async (context, { room }) => ({ count: context.server.rooms.count(room) }), + }), + typed: procedure({ + access: 'public', + handler: async () => { + const seen = typedEvents.splice(0, typedEvents.length); + return { seen }; + }, + }), + // A subscription: opened with {type:'subscribe'}, answered with a + // stream of {type:'data'} and one {type:'end'}. Every boot must carry it. + ticks: procedure.subscription({ + access: 'public', + handler: async function* (_context, { to = 3, from = 0 } = {}) { + for (let i = from + 1; i <= to; i++) yield tracked(String(i), { n: i }); + }, + }), + forever: procedure.subscription({ + access: 'public', + handler: async function* (_context, _args, { signal }) { + const stream = createEventStream({ signal }); + liveSubscriptions.add(stream); + const timer = setInterval(() => stream.push({ tick: true }), 5); + try { + yield* stream; + } finally { + clearInterval(timer); + liveSubscriptions.delete(stream); + } + }, + }), + on: { + typing: procedure({ + access: 'public', + handler: async (_context, data) => void typedEvents.push(data), + }), + }, + }, + auth: { + login: procedure({ + access: 'public', + handler: async (context, args) => { + context.client.startSession(undefined, { user: args?.user ?? 'ada' }); + return { ok: true }; + }, + }), + logout: procedure({ + access: 'session', + handler: async (context) => context.client.finalizeSession(), + }), + }, +}); + +const callPacket = (method, args = {}) => ({ type: 'call', id: randomUUID(), method, args }); + +// Events travel one way, so there is no reply to await: give the fan-out a +// round trip to land before asserting on it. +const settle = () => new Promise((resolve) => setTimeout(resolve, 25)); + +const waitFor = async (predicate, message) => { + for (let i = 0; i < 100; i++) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.fail(message); +}; + +// A packet-mode POST: the wire form every transport shares. +const rpcPost = async (url, method, args = {}, headers = {}) => { + const packet = callPacket(method, args); + const res = await fetch(url, { + method: 'POST', + headers: { ...JSON_HEADERS, ...headers }, + body: JSON.stringify(packet), + }); + const body = await res.json(); + return { res, body, id: packet.id }; +}; + +const rpcGet = async (url, headers = {}) => { + const res = await fetch(url, { headers }); + const body = await res.json(); + return { res, body }; +}; + +const setCookies = (res) => { + if (typeof res.headers.getSetCookie === 'function') return res.headers.getSetCookie(); + const single = res.headers.get('set-cookie'); + return single ? [single] : []; +}; + +const readSessionToken = (res) => { + for (const cookie of setCookies(res)) { + const [pair] = cookie.split(';'); + const eq = pair.indexOf('='); + if (pair.slice(0, eq).trim() === 'token') return pair.slice(eq + 1).trim(); + } + return null; +}; + +// Logs in over HTTP and returns the Cookie header a browser would send back. +const login = async (base, user) => { + const { res, body } = await rpcPost(base, 'auth/login', { user }); + assert.deepStrictEqual(body.result, { ok: true }, 'login answers'); + const token = readSessionToken(res); + assert.ok(token, 'login sets the session cookie'); + return { token, cookie: `token=${token}` }; +}; + +const runAdapterSpec = async (entry, t) => { + const boot = async (options) => { + const instance = await entry.boot({ router, ...options }); + // Registered immediately: a later assertion failure must not leak a listener. + t.after(() => instance.close()); + return instance; + }; + + const main = await boot({}); + const origin = `http://127.0.0.1:${main.port}`; + const base = `${origin}/api`; + + await t.test('packet endpoint: a call packet is answered with its callback', async () => { + const { res, body, id } = await rpcPost(base, 'test/hello', { name: 'Ada' }); + assert.strictEqual(res.status, 200); + assert.strictEqual(body.type, 'callback'); + assert.strictEqual(body.id, id); + assert.strictEqual(body.result, 'Hello, Ada'); + }); + + await t.test('packet endpoint: system/introspect is public', async () => { + const { res, body } = await rpcPost(base, 'system/introspect', {}); + assert.strictEqual(res.status, 200); + assert.ok(body.result.test, 'introspection exposes the test unit'); + assert.strictEqual(body.result.test.hello.access, 'public'); + assert.strictEqual(body.result.test.whoami.access, 'session'); + }); + + await t.test('REST mode: GET takes its args from the query as strings', async () => { + const { res, body } = await rpcGet(`${base}/test/echo?a=1&b=two`); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual(body.result, { a: '1', b: 'two' }); + }); + + await t.test('REST mode: POST merges query params with the body, body wins', async () => { + const res = await fetch(`${base}/test/echo?a=1&b=query`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ b: 'body', c: 3 }), + }); + const body = await res.json(); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual(body.result, { a: '1', b: 'body', c: 3 }); + }); + + await t.test('a declarative REST route answers a plain result with its own status', async () => { + const res = await fetch(`${base}/things/42?x=1`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ name: 'Alpha' }), + }); + assert.strictEqual(res.status, 201); + assert.deepStrictEqual(await res.json(), { thingId: '42', q: { x: '1' }, name: 'Alpha' }); + }); + + await t.test('an unknown method is a 404 error packet', async () => { + const { res, body, id } = await rpcPost(base, 'test/nothing', {}); + assert.strictEqual(res.status, 404); + assert.strictEqual(body.type, 'callback'); + assert.strictEqual(body.id, id); + assert.strictEqual(body.error.code, 404); + }); + + await t.test('a handler error propagates its own code', async () => { + const { res, body } = await rpcPost(base, 'test/fail', {}); + assert.strictEqual(res.status, 418); + assert.strictEqual(body.error.code, 418); + assert.strictEqual(body.error.message, 'Boom'); + }); + + await t.test('access control: a session procedure without a session is 403', async () => { + const { res, body } = await rpcPost(base, 'test/whoami', {}); + assert.strictEqual(res.status, 403); + assert.strictEqual(body.error.code, 403); + }); + + await t.test('sessions over HTTP: login sets a cookie, the cookie restores it, logout kills it', async () => { + const { cookie } = await login(base, 'grace'); + + const restored = await rpcPost(base, 'test/whoami', {}, { cookie }); + assert.strictEqual(restored.res.status, 200); + assert.deepStrictEqual(restored.body.result, { user: 'grace' }); + + const out = await rpcPost(base, 'auth/logout', {}, { cookie }); + assert.strictEqual(out.res.status, 200); + assert.strictEqual(out.body.result, true); + + const revoked = await rpcPost(base, 'test/whoami', {}, { cookie }); + assert.strictEqual(revoked.res.status, 403); + assert.strictEqual(revoked.body.error.code, 403); + }); + + await t.test('CSRF gate: a cross-site GET gets no ambient session', async () => { + const { cookie } = await login(base, 'ada'); + const url = `${base}/test/whoami`; + + const cross = await rpcGet(url, { cookie, 'sec-fetch-site': 'cross-site', 'sec-fetch-mode': 'navigate' }); + assert.strictEqual(cross.res.status, 403, 'cross-site navigation must not dispatch a session procedure'); + assert.strictEqual(cross.body.error.code, 403); + + const same = await rpcGet(url, { cookie, 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'cors' }); + assert.strictEqual(same.res.status, 200); + assert.deepStrictEqual(same.body.result, { user: 'ada' }); + + // Non-browser peers (curl, server-to-server) send no Fetch metadata at all. + const bare = await rpcGet(url, { cookie }); + assert.strictEqual(bare.res.status, 200); + assert.deepStrictEqual(bare.body.result, { user: 'ada' }); + }); + + await t.test('a path outside basePath is 404', async () => { + const res = await fetch(`${origin}/nowhere`); + const text = await res.text(); + assert.strictEqual(res.status, 404); + // Only the boots where wrpc owns every request answer with a wrpc packet; + // composed as middleware/plugin the host framework's own 404 wins. + if (entry.pathMiss !== 'wrpc') return; + const packet = JSON.parse(text); + assert.strictEqual(packet.type, 'callback'); + assert.strictEqual(packet.error.code, 404); + }); + + await t.test('CORS: an allowed origin is echoed with Vary, a disallowed one is not', async () => { + const cors = { origins: ['https://allowed.example'], credentials: true }; + const server = await boot({ cors }); + const corsBase = `http://127.0.0.1:${server.port}/api`; + + const allowed = await rpcPost(corsBase, 'test/hello', { name: 'CORS' }, { origin: 'https://allowed.example' }); + assert.strictEqual(allowed.res.status, 200); + assert.strictEqual(allowed.res.headers.get('access-control-allow-origin'), 'https://allowed.example'); + assert.strictEqual(allowed.res.headers.get('vary'), 'Origin'); + assert.strictEqual(allowed.res.headers.get('access-control-allow-credentials'), 'true'); + + const denied = await rpcPost(corsBase, 'test/hello', { name: 'CORS' }, { origin: 'https://evil.example' }); + // Refused server-side, not just denied the grant: the page could not + // read the answer either way, but the call itself must not run. + assert.strictEqual(denied.res.status, 403); + assert.strictEqual(denied.res.headers.get('access-control-allow-origin'), null); + assert.strictEqual(denied.res.headers.get('vary'), 'Origin'); + + const preflight = await fetch(corsBase, { + method: 'OPTIONS', + headers: { origin: 'https://allowed.example', 'access-control-request-method': 'POST' }, + }); + await preflight.text(); + assert.strictEqual(preflight.status, 200); + assert.strictEqual(preflight.headers.get('access-control-allow-origin'), 'https://allowed.example'); + assert.strictEqual(preflight.headers.get('access-control-allow-methods'), 'POST, GET, OPTIONS'); + }); + + await t.test('basePath: both endpoints move together', async () => { + const server = await boot({ basePath: '/rpc' }); + const moved = `http://127.0.0.1:${server.port}/rpc`; + + const packet = await rpcPost(moved, 'test/hello', { name: 'Moved' }); + assert.strictEqual(packet.res.status, 200); + assert.strictEqual(packet.body.result, 'Hello, Moved'); + + const rest = await rpcGet(`${moved}/test/echo?a=1`); + assert.strictEqual(rest.res.status, 200); + assert.deepStrictEqual(rest.body.result, { a: '1' }); + + const old = await fetch(`http://127.0.0.1:${server.port}/api`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify(callPacket('test/hello', { name: 'Old' })), + }); + await old.text(); + assert.strictEqual(old.status, 404, 'the default base path no longer answers'); + }); + + await t.test('client.meta: no host drops the request url or headers', async (sub) => { + // The packet endpoint: a per-request client still sees url + headers — + // including a client-declared one carried as a REAL request header. + const { res, body } = await rpcPost(base, 'test/peekMeta', {}, { 'x-app-version': '9.9' }); + assert.strictEqual(res.status, 200); + assert.ok(body.result.url.includes('/api'), `http: meta.url survived (got '${body.result.url}')`); + assert.strictEqual(body.result.hasHeaders, true, 'http: meta.headers observed'); + assert.strictEqual(body.result.appVersion, '9.9', 'http: a declared header rides as a real one'); + // The ws upgrade: url, headers, the negotiated subprotocol — and the + // declared headers, carried by the wrpc_h query (lowercased on arrival). + const client = await WrpcClient.connect(`ws://127.0.0.1:${main.port}/api`, { + headers: { 'X-App-Version': '8.8' }, + }); + sub.after(() => void client.close()); + const meta = await client.call('test/peekMeta'); + assert.ok(meta.url.includes('/api'), `ws: the upgrade url survived (got '${meta.url}')`); + assert.strictEqual(meta.hasHeaders, true, 'ws: the upgrade headers survived'); + assert.strictEqual(meta.protocol, 'wrpc.v1'); + assert.strictEqual(meta.appVersion, '8.8', 'ws: the declared header arrived through the connect url'); + }); + + await t.test('WebSocket: load, call, event and streams over one connection', async (sub) => { + const client = await WrpcClient.connect(`ws://127.0.0.1:${main.port}/api`); + sub.after(() => void client.close()); + + // load() goes through system/introspect + await client.load('test'); + assert.strictEqual(typeof client.api.test.hello, 'function'); + assert.strictEqual(await client.api.test.hello({ name: 'Socket' }), 'Hello, Socket'); + + await assert.rejects(client.api.test.fail(), (error) => error.code === 418 && error.message === 'Boom'); + await assert.rejects(client.api.test.whoami(), (error) => error.code === 403); + + const ping = new Promise((resolve) => client.api.test.on('ping', resolve)); + assert.deepStrictEqual(await client.api.test.notify(), { ok: true }); + assert.deepStrictEqual(await ping, { ping: true }); + + if (!entry.streams) return; + + const data = 'binary payload travelling up to the server'; + const blob = new Blob([data]); + blob.name = 'upload-stream'; + const uploader = client.createBlobUploader(blob); + const uploading = client.api.test.readUpload({ id: uploader.id }); + await uploader.upload(); + const uploaded = await uploading; + assert.strictEqual(uploaded.name, 'upload-stream'); + assert.strictEqual(uploaded.size, blob.size); + assert.strictEqual(uploaded.data, data); + + const { id } = await client.api.test.download({ name: 'download-stream' }); + const readable = client.getStream(id); + const downloaded = await readable.toBlob(); + assert.strictEqual(await downloaded.text(), 'payload from the server'); + }); + + await t.test('rooms and inbound events over two connections', async (sub) => { + const url = `ws://127.0.0.1:${main.port}/api`; + const [ada, grace] = await Promise.all([WrpcClient.connect(url), WrpcClient.connect(url)]); + sub.after(() => { + ada.close(); + grace.close(); + }); + await Promise.all([ada.load('chat'), grace.load('chat')]); + + const received = { ada: [], grace: [] }; + ada.api.chat.on('message', (data) => void received.ada.push(data)); + grace.api.chat.on('message', (data) => void received.grace.push(data)); + + assert.deepStrictEqual(await ada.api.chat.join({ room: 'lobby' }), { rooms: ['lobby'] }); + await grace.api.chat.join({ room: 'lobby' }); + assert.deepStrictEqual(await ada.api.chat.members({ room: 'lobby' }), { count: 2 }); + + // A room broadcast reaches every member, the sender included... + assert.deepStrictEqual(await ada.api.chat.shout({ room: 'lobby', text: 'hello' }), { sent: 2 }); + await settle(); + assert.deepStrictEqual(received.ada, [{ text: 'hello' }]); + assert.deepStrictEqual(received.grace, [{ text: 'hello' }]); + + // ...unless it is excluded. + assert.deepStrictEqual(await ada.api.chat.shout({ room: 'lobby', text: 'psst', self: false }), { sent: 1 }); + await settle(); + assert.strictEqual(received.ada.length, 1, 'except() drops the sender'); + assert.deepStrictEqual(received.grace.at(-1), { text: 'psst' }); + + // A client -> server event is fire-and-forget: nothing comes back on the + // wire, and the handler's effect is observed through a call. + ada.sendEvent('chat/typing', { who: 'ada' }); + await settle(); + assert.deepStrictEqual(await ada.api.chat.typed(), { seen: [{ who: 'ada' }] }); + + assert.deepStrictEqual(await grace.api.chat.leave({ room: 'lobby' }), { left: true }); + assert.deepStrictEqual(await ada.api.chat.members({ room: 'lobby' }), { count: 1 }); + + // A dropped connection releases its rooms without anyone calling leave. + grace.close(); + await waitFor( + async () => (await ada.api.chat.members({ room: 'lobby' })).count === 1, + 'a disconnect must not change a room it had already left', + ); + ada.close(); + await waitFor(() => main.rpc.rooms.has('lobby') === false, 'the last member disconnecting must drop the room'); + }); + + await t.test('subscriptions, resume and cancellation over one connection', async (sub) => { + const client = await WrpcClient.connect(`ws://127.0.0.1:${main.port}/api`); + sub.after(() => void client.close()); + await client.load('chat'); + + // subscribe -> data* -> end + const seen = []; + const ended = new Promise((resolve) => { + client.api.chat.ticks.subscribe({ to: 3 }, { onData: (data) => seen.push(data), onEnd: resolve }); + }); + await ended; + assert.deepStrictEqual(seen, [{ n: 1 }, { n: 2 }, { n: 3 }]); + + // The same feed resumed from an eventId yields only what came after. + const resumed = []; + let handle = null; + const done = new Promise((resolve) => { + handle = client.api.chat.ticks.subscribe( + { to: 3, from: 2 }, + { lastEventId: '2', onData: (data) => resumed.push(data), onEnd: resolve }, + ); + }); + await done; + assert.deepStrictEqual(resumed, [{ n: 3 }]); + assert.strictEqual(handle.lastEventId, '3'); + + // for await, and breaking out of it, reaches the server + const before = liveSubscriptions.size; + for await (const value of client.api.chat.forever.iterate()) { + assert.deepStrictEqual(value, { tick: true }); + break; + } + await waitFor(() => liveSubscriptions.size === before, 'breaking the loop never released the generator'); + + // A dropped connection releases whatever is still running. + const handle2 = client.api.chat.forever.subscribe(); + await waitFor(() => liveSubscriptions.size === before + 1, 'the subscription never opened'); + assert.strictEqual(handle2.closed, false); + client.close(); + await waitFor(() => liveSubscriptions.size === before, 'a disconnect left a generator running'); + }); +}; + +module.exports = { runAdapterSpec, router }; diff --git a/tests/adapters/swap.test.js b/tests/adapters/swap.test.js new file mode 100644 index 0000000..63bd561 --- /dev/null +++ b/tests/adapters/swap.test.js @@ -0,0 +1,15 @@ +'use strict'; + +const test = require('node:test'); + +const { buildBoots } = require('./boots.js'); +const { runAdapterSpec } = require('./spec.js'); + +// The swap test: one specification (tests/adapters/spec.js) replayed against +// every supported way of standing wrpc up. A boot whose optional peer package +// is missing reports as skipped rather than failing the run. +for (const entry of buildBoots()) { + test(`adapter swap: ${entry.name}`, { skip: entry.skip ?? false }, async (t) => { + await runAdapterSpec(entry, t); + }); +} diff --git a/tests/adapters/uws.test.js b/tests/adapters/uws.test.js new file mode 100644 index 0000000..d38bcd1 --- /dev/null +++ b/tests/adapters/uws.test.js @@ -0,0 +1,267 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const http = require('node:http'); + +const { createUwsEngine, UwsSocket, SUCCESS, DROPPED } = require('../../uws.js'); +const { isEngine } = require('../../engine.js'); +const { runEngineContract, standaloneHarness } = require('../engine/engineContract.js'); +const { Server, defineRouter, procedure } = require('../../index.js'); +const { ProtocolClient } = require('../websocket/protocolClient.js'); +const { requireUws } = require('./boots.js'); + +const uws = requireUws(); + +test('createUwsEngine: injection is validated at the boundary', () => { + assert.throws(() => createUwsEngine(), /pass the uWebSockets\.js module as options\.uws/); + assert.throws(() => createUwsEngine({}), /pass the uWebSockets\.js module as options\.uws/); + assert.throws(() => createUwsEngine({ uws: {} }), /pass the uWebSockets\.js module as options\.uws/); + assert.throws(() => createUwsEngine({ app: {} }), /does not look like a uWebSockets\.js TemplatedApp/); +}); + +test('createUwsEngine: shape and capabilities', { skip: !uws && 'uWebSockets.js unavailable' }, () => { + const engine = createUwsEngine({ uws }); + assert.strictEqual(engine.name, 'uws'); + assert.strictEqual(engine.standalone, true); + assert.strictEqual(isEngine(engine), true); + // uws owns liveness via idleTimeout, and exposes no socket-level pause + assert.strictEqual(engine.capabilities.ping, false); + assert.strictEqual(engine.capabilities.pause, false); + assert.strictEqual(engine.capabilities.backpressure, true); + assert.strictEqual(engine.capabilities.deflate, false); + const compressed = createUwsEngine({ uws, compression: 1 }); + assert.strictEqual(compressed.capabilities.deflate, true); + // uws.DISABLED is 0, so a falsy compressor must not read as deflate support + assert.strictEqual(createUwsEngine({ uws, compression: 0 }).capabilities.deflate, false); + engine.close(); + compressed.close(); +}); + +test('createUwsEngine: attaching twice is refused', { skip: !uws && 'uWebSockets.js unavailable' }, () => { + const engine = createUwsEngine({ uws }); + engine.attach({}); + assert.throws(() => engine.attach({}), /already attached/); + engine.close(); +}); + +test( + 'uws engine satisfies the WrpcSocket engine contract', + { skip: !uws && 'uWebSockets.js unavailable' }, + async (t) => { + await runEngineContract( + standaloneHarness(() => createUwsEngine({ uws })), + t, + ); + }, +); + +test('UwsSocket: a poisoned uws handle never escapes as a throw', () => { + // uws invalidates the handle in its close callback; every method on it + // throws afterwards. The WrpcSocket contract has to stay quiet instead. + const poison = () => { + throw new Error('Invalid access of closed uWS.WebSocket/SSLWebSocket.'); + }; + const socket = new UwsSocket( + { send: poison, end: poison, close: poison, getBufferedAmount: poison }, + { remoteAddress: '10.0.0.1', protocol: 'wrpc' }, + ); + assert.strictEqual(socket.remoteAddress, '10.0.0.1'); + assert.strictEqual(socket.protocol, 'wrpc'); + assert.strictEqual(socket.bufferedAmount, 0); + assert.strictEqual(socket.send('x'), false); + assert.doesNotThrow(() => socket.close(1000, 'bye')); + assert.doesNotThrow(() => socket.terminate()); +}); + +test('UwsSocket: a dropped message fails loudly instead of silently', () => { + // A hole in the frame stream would corrupt the RPC protocol, so a uws + // DROPPED status has to surface as an error plus a terminate. + let terminated = false; + const socket = new UwsSocket( + { + send: () => DROPPED, + close: () => { + terminated = true; + }, + end: () => {}, + getBufferedAmount: () => 0, + }, + {}, + ); + const errors = []; + socket.on('error', (error) => errors.push(error)); + assert.strictEqual(socket.send('payload'), false); + assert.strictEqual(errors.length, 1); + assert.match(errors[0].message, /backpressure limit exceeded/); + assert.strictEqual(terminated, true); +}); + +test('UwsSocket: backpressure status maps onto the boolean contract', () => { + let status = SUCCESS; + const socket = new UwsSocket({ send: () => status, getBufferedAmount: () => 4096 }, {}); + assert.strictEqual(socket.send('a'), true); + status = 0; // BACKPRESSURE: buffered, but not lost + assert.strictEqual(socket.send('b'), false); + assert.strictEqual(socket.bufferedAmount, 4096); +}); + +test('UwsSocket: markClosed emits close once and latches', () => { + const socket = new UwsSocket({ getBufferedAmount: () => 0 }, {}); + const seen = []; + socket.on('close', (code, reason) => seen.push([code, reason])); + socket.markClosed(1001, 'going away'); + socket.markClosed(1006, 'again'); + assert.deepStrictEqual(seen, [[1001, 'going away']]); + assert.strictEqual(socket.closed, true); +}); + +test( + 'listen: a busy port rejects as EADDRINUSE so the bind-retry works', + { skip: !uws && 'uWebSockets.js unavailable' }, + async (t) => { + // uws reports only success/failure, never a reason. Tagging the rejection + // EADDRINUSE is what keeps Server.listen()'s retry loop working with a + // standalone engine, so the tag is load-bearing, not cosmetic. + const blocker = http.createServer(); + await new Promise((resolve) => blocker.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => blocker.close(resolve))); + const { port } = blocker.address(); + + const engine = createUwsEngine({ uws }); + t.after(() => engine.close()); + engine.attach({}); + await assert.rejects(engine.listen({ host: '127.0.0.1', port }), (error) => { + assert.strictEqual(error.code, 'EADDRINUSE'); + assert.match(error.message, /Failed to bind 127\.0\.0\.1:/); + return true; + }); + }, +); + +test( + 'Server over uws: listen retries a busy port and then gives up', + { skip: !uws && 'uWebSockets.js unavailable' }, + async (t) => { + const blocker = http.createServer(); + await new Promise((resolve) => blocker.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => blocker.close(resolve))); + const { port } = blocker.address(); + + const server = new Server({ + router: defineRouter({ unit: { noop: procedure({ access: 'public', handler: async () => null }) } }), + host: '127.0.0.1', + port, + logger: false, + engine: createUwsEngine({ uws }), + timeouts: { bind: 20 }, + retry: 2, + }); + t.after(() => server.close()); + await assert.rejects(server.listen(), (error) => error.code === 'EADDRINUSE'); + }, +); + +test( + 'http: a body over the limit is refused instead of buffered', + { skip: !uws && 'uWebSockets.js unavailable' }, + async (t) => { + const server = new Server({ + router: defineRouter({ echo: { args: procedure({ access: 'public', handler: async (_c, args) => args }) } }), + host: '127.0.0.1', + port: 0, + logger: false, + engine: createUwsEngine({ uws, maxBodySize: 256 }), + }); + t.after(() => server.close()); + await server.listen(); + const { port } = server.address(); + + const small = await fetch(`http://127.0.0.1:${port}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Connection: 'close' }, + body: JSON.stringify({ type: 'call', id: '1', method: 'echo/args', args: { a: 'x' } }), + }); + assert.strictEqual(small.status, 200); + + const huge = await fetch(`http://127.0.0.1:${port}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Connection: 'close' }, + body: JSON.stringify({ type: 'call', id: '2', method: 'echo/args', args: { a: 'x'.repeat(4096) } }), + }); + assert.strictEqual(huge.status, 400); + assert.deepStrictEqual(await huge.json(), { + type: 'callback', + id: '', + error: { message: 'Body size limit exceeded', code: 400 }, + }); + }, +); + +test('control frames surface as ping and pong events', { skip: !uws && 'uWebSockets.js unavailable' }, async (t) => { + const engine = createUwsEngine({ uws }); + t.after(() => engine.close()); + const source = engine.attach({}); + const { port } = await engine.listen({ host: '127.0.0.1', port: 0 }); + + const connected = new Promise((resolve) => source.once('connection', resolve)); + const peer = new ProtocolClient(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => peer.on('open', resolve)); + const socket = await connected; + + const pinged = new Promise((resolve) => socket.on('ping', resolve)); + peer.ping(Buffer.from('are you there')); + assert.strictEqual((await pinged).toString(), 'are you there'); + + const ponged = new Promise((resolve) => socket.on('pong', resolve)); + peer.sendFrame(0x0a, Buffer.from('still here')); + assert.strictEqual((await ponged).toString(), 'still here'); + + peer.close(); +}); + +test( + 'codec.rest: a binary REST body round-trips over uws', + { skip: !uws && 'uWebSockets.js unavailable' }, + async (t) => { + const codec = { + rest: { + contentType: 'application/x-wrpc-bin', + encode: (value) => Buffer.concat([Buffer.from([0xab]), Buffer.from(JSON.stringify(value ?? null))]), + decode: (body) => { + const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body); + if (buffer[0] !== 0xab) throw new Error('bad frame'); + return JSON.parse(buffer.subarray(1).toString()); + }, + }, + }; + const server = new Server({ + router: defineRouter({ + projects: { + create: procedure({ + access: 'public', + http: { method: 'POST', path: '/projects/:orgId', status: 201 }, + handler: async (_ctx, { params, body }) => ({ orgId: params.orgId, name: body?.name }), + }), + }, + }), + host: '127.0.0.1', + port: 0, + logger: false, + codec, + engine: createUwsEngine({ uws }), + }); + t.after(() => server.close()); + await server.listen(); + const { port } = server.address(); + + const res = await fetch(`http://127.0.0.1:${port}/api/projects/9`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-wrpc-bin', Connection: 'close' }, + body: codec.rest.encode({ name: 'Bin' }), + }); + assert.strictEqual(res.status, 201); + assert.strictEqual(res.headers.get('content-type'), 'application/x-wrpc-bin'); + assert.deepStrictEqual(codec.rest.decode(Buffer.from(await res.arrayBuffer())), { orgId: '9', name: 'Bin' }); + }, +); diff --git a/tests/auth.test-d.ts b/tests/auth.test-d.ts new file mode 100644 index 0000000..fef42db --- /dev/null +++ b/tests/auth.test-d.ts @@ -0,0 +1,37 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import * as auth from '../auth.js'; +import type { TokenStore } from '../auth.js'; +import type { WrpcClientOptions } from '../index.js'; +import { connect } from '../index.js'; + +// A Map IS the store contract — the reason memoryStore is nearly free. +expectAssignable(new Map()); +expectType>(auth.memoryStore()); +expectAssignable(auth.memoryStore()); +expectError({ get: () => null }); // set/delete are required + +// webStorage takes anything localStorage-shaped, structurally +declare const storage: { + getItem(k: string): string | null; + setItem(k: string, v: string): void; + removeItem(k: string): void; +}; +expectAssignable(auth.webStorage(storage)); +expectAssignable(auth.webStorage(storage, { prefix: 'app:' })); +expectAssignable(auth.cookieStorage({ cookie: '' })); + +// bearerAuth composes straight into the client options +const options = auth.bearerAuth({ + store: auth.memoryStore(), + signIn: async (client) => (await client.call('auth/signIn')) as { access: string }, + refresh: async (client, tokens) => client.call('auth/refresh', { tokens }), + on: [401, 403], +}); +expectAssignable(options); +void connect('ws://host', options); + +// The server halves are TokenTransport-shaped +expectType(auth.bearerTransport().read({ headers: { authorization: 'Bearer x' } })); +expectType(auth.bearerTransport({ scheme: 'Token' }).write('x')); +expectType(auth.payloadTransport({ field: 'token' }).read({ url: '/api?wrpc_meta=%7B%7D' })); +expectType(auth.payloadTransport().ambient); diff --git a/tests/auth.test.js b/tests/auth.test.js new file mode 100644 index 0000000..fd4cfbe --- /dev/null +++ b/tests/auth.test.js @@ -0,0 +1,388 @@ +'use strict'; + +const timers = require('node:timers/promises'); +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { Server, WrpcClient, defineRouter, procedure } = require('../index.js'); +const { + isTokenStore, + memoryStore, + webStorage, + cookieStorage, + bearerAuth, + bearerTransport, + payloadTransport, +} = require('../auth.js'); + +const waitFor = async (predicate, message = 'condition never held') => { + for (let i = 0; i < 200; i++) { + if (predicate()) return; + await timers.setTimeout(5); + } + assert.fail(message); +}; + +// --------------------------------------------------------------------------- +// The store contract, run against every implementation — the engineContract +// pattern: one spec, many implementations. + +const fakeWebStorage = () => { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => void map.set(key, String(value)), + removeItem: (key) => void map.delete(key), + }; +}; + +const fakeDocument = () => { + // Enough of document.cookie for the store: an accessor that appends on + // assignment and serializes name=value pairs on read, honouring Max-Age=0. + const jar = new Map(); + return { + get cookie() { + return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join('; '); + }, + set cookie(text) { + const [pair, ...attrs] = String(text).split('; '); + const eq = pair.indexOf('='); + const name = pair.slice(0, eq); + if (attrs.some((attr) => attr === 'Max-Age=0')) jar.delete(name); + else jar.set(name, pair.slice(eq + 1)); + }, + }; +}; + +const STORES = [ + ['memoryStore', () => memoryStore()], + ['webStorage', () => webStorage(fakeWebStorage())], + ['cookieStorage', () => cookieStorage(fakeDocument())], +]; + +test('token stores: one contract, every implementation', async (t) => { + for (const [name, build] of STORES) { + await t.test(name, async () => { + const store = build(); + assert.strictEqual(isTokenStore(store), true); + assert.ok((await store.get('tokens')) === undefined || (await store.get('tokens')) === null); + await store.set('tokens', { access: 'a1', refresh: 'r1' }); + assert.deepStrictEqual(await store.get('tokens'), { access: 'a1', refresh: 'r1' }); + await store.set('tokens', { access: 'a2' }); + assert.deepStrictEqual(await store.get('tokens'), { access: 'a2' }, 'set replaces'); + await store.delete('tokens'); + const gone = await store.get('tokens'); + assert.ok(gone === undefined || gone === null, 'delete removes'); + await store.delete('tokens'); // idempotent + }); + } +}); + +test('token stores: prefixes isolate applications', async () => { + const backing = fakeWebStorage(); + const a = webStorage(backing, { prefix: 'a:' }); + const b = webStorage(backing, { prefix: 'b:' }); + await a.set('tokens', { access: '1' }); + assert.strictEqual(await b.get('tokens'), undefined); +}); + +// --------------------------------------------------------------------------- +// The server halves on their own + +test('bearerTransport reads the header, then the subprotocol offer, then the parsed declared bag', () => { + const transport = bearerTransport(); + assert.strictEqual(transport.ambient, false); + assert.strictEqual(transport.read({ headers: { authorization: 'Bearer abc' } }), 'abc'); + assert.strictEqual(transport.read({ headers: { authorization: 'Basic abc' } }), null); + // The ws subprotocol carrier: the client offers wrpc.bearer. next + // to the wire revision, keeping the credential out of the connect URL. + assert.strictEqual(transport.read({ headers: { 'sec-websocket-protocol': 'wrpc.v1, wrpc.bearer.tok9' } }), 'tok9'); + // The declared bag arrives PARSED from the core (kebab keys, capped on the + // configurable metaMaxBytes) — this module never re-derives it from the + // URL, so it cannot drift from the core parser. + assert.strictEqual(transport.read({ headers: {}, declared: { authorization: 'Bearer fromquery' } }), 'fromquery'); + assert.strictEqual( + transport.read({ headers: { authorization: 'Bearer real' }, declared: { authorization: 'Bearer other' } }), + 'real', + ); + const raw = encodeURIComponent(JSON.stringify({ Authorization: 'Bearer fromquery' })); + assert.strictEqual(transport.read({ headers: {}, url: `/api?wrpc_h=${raw}` }), null); + assert.strictEqual(transport.write('abc'), null); +}); + +test('payloadTransport reads the declared meta field (header and ws query)', () => { + const transport = payloadTransport(); + const meta = encodeURIComponent(JSON.stringify({ token: 'tok1', v: '1' })); + assert.strictEqual(transport.read({ headers: { 'x-wrpc-meta': meta } }), 'tok1'); + assert.strictEqual(transport.read({ headers: {}, url: `/api?wrpc_meta=${meta}` }), 'tok1'); + assert.strictEqual(transport.read({ headers: {}, url: '/api?other=1' }), null); + assert.strictEqual(payloadTransport({ field: 'sid' }).read({ headers: { 'x-wrpc-meta': meta } }), null); +}); + +// --------------------------------------------------------------------------- +// bearerAuth end to end: sign in -> store -> reconnect presents the header +// -> the upgrade restores the session BEFORE the session-gated re-subscribe. + +const bearerBoot = async (t) => { + const state = { signIns: 0, refreshes: 0, subscribes: 0, expired: new Set() }; + const definition = defineRouter({ + auth: { + signIn: procedure({ + access: 'public', + handler: async (context) => { + state.signIns++; + context.client.startSession(undefined, { user: 'noa' }); + return { access: context.session.token }; + }, + }), + refresh: procedure({ + access: 'public', + handler: async (context, { access }) => { + state.refreshes++; + // A rotated pair: the old token dies, the live connection is + // re-bound, the stored pair covers the next connection. + state.expired.add(access); + context.client.startSession(undefined, { user: 'noa' }); + return { access: context.session.token }; + }, + }), + }, + secure: { + whoami: procedure({ access: 'session', handler: async (context) => context.session.state.user }), + feed: procedure.subscription({ + access: 'session', + handler: async function* (_context, _args, { signal }) { + state.subscribes++; + await timers.setTimeout(60_000, undefined, { signal }).catch(() => {}); + }, + }), + }, + }); + const server = new Server({ + router: definition, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 50 }, + introspection: 'session', + sessions: { transport: bearerTransport() }, + }); + await server.listen(); + t.after(() => server.close()); + return { server, port: server.address().port, state }; +}; + +test('bearerAuth: the full cycle over a real server with a bearer token transport', async (t) => { + const { server, port, state } = await bearerBoot(t); + const store = memoryStore(); + const client = await WrpcClient.connect( + `ws://127.0.0.1:${port}/api`, + Object.assign( + { heartbeat: false, logger: false, reconnectTimeout: 10 }, + bearerAuth({ + store, + signIn: (c) => c.call('auth/signIn'), + refresh: async (c, tokens) => c.call('auth/refresh', { access: tokens?.access }), + }), + ), + ); + t.after(() => void client.close()); + + // First connect: nothing stored -> signIn ran, the pair is in the store. + assert.strictEqual(state.signIns, 1); + const stored = await store.get('tokens'); + assert.ok(stored.access); + + // The session is live (bound by startSession over the socket): a gated + // load and subscription work. + await client.load('secure'); + assert.strictEqual(await client.api.secure.whoami(), 'noa'); + client.api.secure.feed.subscribe({}, {}); + await waitFor(() => state.subscribes === 1, 'the subscription never opened'); + + // Reconnect: the stored token rides the upgrade (wrpc_h), the bearer + // transport restores the session BEFORE the re-subscribe — signIn must + // NOT run again, and the session-gated feed must resume. + for (const connection of server.wsServer.connections) connection.terminate(); + await waitFor(() => state.subscribes === 2, 'the gated subscription did not survive the reconnect'); + assert.strictEqual(state.signIns, 1, 'the stored credential made a second signIn unnecessary'); +}); + +test('bearerAuth: a mid-session refusal refreshes once, rotates the pair and retries', async (t) => { + const { server, port, state } = await bearerBoot(t); + const store = memoryStore(); + const client = await WrpcClient.connect( + `ws://127.0.0.1:${port}/api`, + Object.assign( + { heartbeat: false, logger: false, reconnect: false }, + bearerAuth({ + store, + signIn: (c) => c.call('auth/signIn'), + refresh: async (c, tokens) => c.call('auth/refresh', { access: tokens?.access }), + }), + ), + ); + t.after(() => void client.close()); + await client.load('secure'); + const before = (await store.get('tokens')).access; + + // Expire the session server-side: the next gated call is refused with + // wrpc's own 403, which bearerAuth's default `on` covers. + for (const serverClient of server.clients) await serverClient.finalizeSession(); + + // The refused call triggers ONE refresh (auth/refresh re-binds the live + // connection via startSession) and is retried exactly once. + assert.strictEqual(await client.api.secure.whoami(), 'noa'); + assert.strictEqual(state.refreshes, 1); + const rotated = (await store.get('tokens')).access; + assert.notStrictEqual(rotated, before, 'the stored pair was rotated'); + assert.strictEqual(state.signIns, 1, 'refresh healed the session without a second signIn'); +}); + +test('bearerAuth: a THROWING refresh clears the store like a falsy return does', async () => { + const store = memoryStore(); + store.set('tokens', { access: 'dead', refresh: 'revoked' }); + const options = bearerAuth({ + store, + signIn: () => null, + refresh: () => { + throw new Error('refresh token revoked'); + }, + }); + await assert.rejects(options.refresh.handler({}, new Error('401')), /refresh token revoked/); + // The pair just proved dead: leaving it stored would make every later + // reconnect's authenticate short-circuit on a corpse forever. + assert.strictEqual(store.get('tokens'), undefined); +}); + +// --------------------------------------------------------------------------- +// The carrier matrix: one payloadTransport server, every carrier x both +// meta spellings — the drift this matrix exists to catch is a token that +// restores on ws but silently 403s on http/sse under metaFormat 'prefixed'. + +require('../sse.js'); // registers the 'sse' client transport + +const payloadBoot = async (t) => { + const definition = defineRouter({ + auth: { + signIn: procedure({ + access: 'public', + handler: async (context) => { + context.client.startSession(undefined, { user: 'zoe' }); + return { token: context.session.token }; + }, + }), + }, + secure: { + whoami: procedure({ access: 'session', handler: async (context) => context.session.state.user }), + }, + }); + const server = new Server({ + router: definition, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 50 }, + sessions: { transport: payloadTransport() }, + }); + await server.listen(); + t.after(() => server.close()); + const port = server.address().port; + const first = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: false, + }); + const { token } = await first.call('auth/signIn', {}); + first.close(); + return { server, port, token }; +}; + +test('payloadTransport: the token restores on every carrier and both meta spellings', async (t) => { + const { port, token } = await payloadBoot(t); + const cases = [ + ['ws json', `ws://127.0.0.1:${port}/api`, {}], + ['ws prefixed', `ws://127.0.0.1:${port}/api`, { metaFormat: 'prefixed' }], + ['http json', `http://127.0.0.1:${port}/api`, { transport: 'http' }], + ['http prefixed', `http://127.0.0.1:${port}/api`, { transport: 'http', metaFormat: 'prefixed' }], + ['sse json', `http://127.0.0.1:${port}/api`, { transport: 'sse' }], + ['sse prefixed', `http://127.0.0.1:${port}/api`, { transport: 'sse', metaFormat: 'prefixed' }], + ]; + for (const [label, url, extra] of cases) { + const client = await WrpcClient.connect(url, { + heartbeat: false, + logger: false, + reconnect: false, + meta: { token }, + ...extra, + }); + await client.load('secure'); + assert.strictEqual(await client.api.secure.whoami(), 'zoe', `carrier: ${label}`); + client.close(); + } +}); + +test('payloadTransport: a hand-written x-wrpc-meta- header restores too', async (t) => { + const { port, token } = await payloadBoot(t); + // The curl shape: no wrpc client at all, one per-key header. GET is a safe + // method, but a non-ambient carrier restores on it by design. + const res = await fetch(`http://127.0.0.1:${port}/api/secure/whoami`, { + headers: { 'x-wrpc-meta-token': token }, + }); + const body = await res.json(); + assert.strictEqual(res.status, 200); + assert.strictEqual(body.result, 'zoe'); +}); + +test('bearerAuth over ws: the token rides the subprotocol offer, never the connect URL', async (t) => { + const { server, port } = await bearerBoot(t); + const store = memoryStore(); + const client = await WrpcClient.connect( + `ws://127.0.0.1:${port}/api`, + Object.assign( + { heartbeat: false, logger: false, reconnect: false }, + bearerAuth({ store, signIn: (c) => c.call('auth/signIn') }), + ), + ); + t.after(() => void client.close()); + await client.load('secure'); + // Session restored on reconnect-shaped opens proves the carrier works; + // here the FIRST connect signs in, so assert on a second connection that + // presents the stored token. + const stored = await store.get('tokens'); + assert.ok(stored.access); + const again = await WrpcClient.connect( + `ws://127.0.0.1:${port}/api`, + Object.assign( + { heartbeat: false, logger: false, reconnect: false }, + bearerAuth({ store, signIn: () => assert.fail('stored token must restore without a signIn') }), + ), + ); + t.after(() => void again.close()); + await again.load('secure'); + // The stored token restored the session with NO signIn — the subprotocol + // carrier did the work. + assert.strictEqual(await again.api.secure.whoami(), 'noa'); + // And the upgrade URL the server observed carries no credential: wrpc_h + // (the query fallback that lands in access logs) must be absent. + for (const peer of server.clients) { + assert.ok(!String(peer.meta?.url ?? '').includes('wrpc_h'), 'the bearer token leaked into the connect URL'); + } +}); + +test('payloadTransport: raw fallbacks — prefixed header, oversize canonical, camelCase field', () => { + const transport = payloadTransport(); + // The per-key spelling as a raw header (no parsed bag: the SSE key path). + assert.strictEqual(transport.read({ headers: { 'x-wrpc-meta-token': 'tok2' } }), 'tok2'); + // An oversize canonical header is ignored, not parsed. + const big = 'x'.repeat(3000); + assert.strictEqual(transport.read({ headers: { 'x-wrpc-meta': big } }), null); + // The parsed bag wins over everything. + assert.strictEqual(transport.read({ headers: { 'x-wrpc-meta-token': 'raw' }, meta: { token: 'bag' } }), 'bag'); + // A camelCase field matches its kebab key in the parsed bag. + const camel = payloadTransport({ field: 'authToken' }); + assert.strictEqual(camel.read({ headers: {}, meta: { 'auth-token': 'k1' } }), 'k1'); + assert.strictEqual(camel.read({ headers: { 'x-wrpc-meta-auth-token': 'k2' } }), 'k2'); +}); diff --git a/tests/cli/e2e.test.js b/tests/cli/e2e.test.js new file mode 100644 index 0000000..42e7a55 --- /dev/null +++ b/tests/cli/e2e.test.js @@ -0,0 +1,304 @@ +'use strict'; + +// The CLI, end to end: a real server, the real `bin/wrpc.js` in a child +// process, and then the compiler on what came out. The last step is the point — +// a codegen tool that emits types nothing type-checks against is a text +// generator, so the generated contract is fed straight into `connect()` +// and TypeScript is asked whether it holds up. + +const { test } = require('node:test'); +const assert = require('node:assert'); +const path = require('node:path'); +const os = require('node:os'); +const { existsSync } = require('node:fs'); +const { mkdtemp, readFile, rm, writeFile } = require('node:fs/promises'); +const { execFile } = require('node:child_process'); + +const { Server, WrpcClient, defineRouter, procedure } = require('../../index.js'); + +const ROOT = path.join(__dirname, '..', '..'); +const BIN = path.join(ROOT, 'bin', 'wrpc.js'); +const TSC = path.join(ROOT, 'node_modules', '.bin', 'tsc'); + +const run = (file, args, options = {}) => + new Promise((resolve) => { + execFile(file, args, { cwd: ROOT, ...options }, (error, stdout, stderr) => { + resolve({ code: error?.code ?? 0, stdout, stderr, error }); + }); + }); + +// The router carries `signature` descriptors: they are what turns introspection +// from a list of names into something worth generating types from. +const router = defineRouter({ + chat: { + send: procedure({ + access: 'public', + meta: { description: 'Post a message to a room' }, + signature: { args: { room: 'string', text: 'string', 'replyTo?': 'string' }, returns: { id: 'string' } }, + handler: async (_context, { text }) => ({ id: `id-${text}` }), + }), + list: procedure({ + access: 'public', + signature: { args: { room: 'string' }, returns: [{ id: 'string', text: 'string' }] }, + handler: async () => [{ id: '1', text: 'hi' }], + }), + // No signature at all: still callable, just not described. + ping: procedure({ access: 'public', handler: async () => true }), + onMessage: procedure.subscription({ + access: 'public', + signature: { args: { room: 'string' }, data: { text: 'string', tags: 'string[]' } }, + handler: async function* () { + yield { text: 'hi', tags: ['a'] }; + }, + }), + // An undescribed subscription: the generated marker has to stay callable + // both ways (`subscribe()` and `subscribe(args)`), which is why the args + // slot is `any` rather than `unknown` — `unknown` would refuse the former. + onRaw: procedure.subscription({ + access: 'public', + handler: async function* () { + yield 1; + }, + }), + }, + 'auth.v1': { + signIn: procedure({ + access: 'public', + signature: { args: { login: 'string' }, returns: { token: 'string' } }, + handler: async () => ({ token: 't' }), + }), + }, +}); + +test('CLI e2e: generates a contract from a live server and type-checks it', async (t) => { + const server = new Server({ + router, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 100 }, + }); + await server.listen(); + t.after(() => server.close()); + const { port } = server.address(); + + const dir = await mkdtemp(path.join(os.tmpdir(), 'wrpc-types-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const out = path.join(dir, 'api.d.ts'); + + const generated = await run(process.execPath, [BIN, 'types', `http://127.0.0.1:${port}/api`, '--out', out]); + assert.strictEqual(generated.code, 0, `wrpc types failed: ${generated.stderr}`); + assert.strictEqual(generated.stderr, '', 'a well-described router generates without warnings'); + + const contract = await readFile(out, 'utf8'); + assert.strictEqual( + contract, + [ + '// Generated by `wrpc types`. Do not edit — re-run the command instead.', + `// Source: http://127.0.0.1:${port}/api`, + '', + "import type { SubscriptionContract } from '@alexify/wrpc';", + '', + 'export interface Api {', + " 'auth.v1': {", + ' signIn(args: { login: string }): Promise<{ token: string }>;', + ' };', + ' chat: {', + ' list(args: { room: string }): Promise>;', + ' onMessage: SubscriptionContract<{ room: string }, { text: string; tags: string[] }>;', + ' onRaw: SubscriptionContract;', + ' ping(args?: unknown): Promise;', + ' /** Post a message to a room */', + ' send(args: { room: string; text: string; replyTo?: string }): Promise<{ id: string }>;', + ' };', + ' system: {', + ' introspect(args?: unknown): Promise;', + ' };', + '}', + '', + ].join('\n'), + ); + + // Re-running must produce the same bytes: a diff in a generated file should + // mean the server changed, never that the generator is unstable. + const again = await run(process.execPath, [BIN, 'types', `http://127.0.0.1:${port}/api`, '--out', out]); + assert.strictEqual(again.code, 0); + assert.strictEqual(await readFile(out, 'utf8'), contract); + + // --units narrows it, and the filter is honoured server-side + const narrow = path.join(dir, 'narrow.d.ts'); + const filtered = await run(process.execPath, [ + BIN, + 'types', + `http://127.0.0.1:${port}/api`, + '--units', + 'chat', + '--out', + narrow, + '--interface', + 'ChatApi', + ]); + assert.strictEqual(filtered.code, 0, filtered.stderr); + const narrowed = await readFile(narrow, 'utf8'); + assert.match(narrowed, /export interface ChatApi \{/); + assert.ok(!narrowed.includes("'auth.v1'"), '--units chat must not describe auth.v1'); + assert.ok(!narrowed.includes('system:'), '--units chat must not describe system'); + + await t.test('the generated contract satisfies the typed client', async (sub) => { + if (!existsSync(TSC)) return void sub.skip('typescript is not installed'); + // The generated file imports '@alexify/wrpc' exactly as a consumer's would; + // `paths` supplies the resolution a consumer gets from node_modules. + await writeFile( + path.join(dir, 'use.ts'), + [ + "import { connect } from '@alexify/wrpc';", + "import type { Api } from './api.js';", + '', + 'export const run = async () => {', + " const client = await connect('ws://127.0.0.1:1/');", + " await client.load('chat', 'auth.v1');", + " const sent: { id: string } = await client.api.chat.send({ room: 'a', text: 'hi' });", + " const rows: Array<{ id: string; text: string }> = await client.api.chat.list({ room: 'a' });", + ' const anything: unknown = await client.api.chat.ping();', + " const token: string = (await client.api['auth.v1'].signIn({ login: 'me' })).token;", + ' const texts: Array = [];', + " const feed = client.api.chat.onMessage.subscribe({ room: 'a' }, {", + ' onData: (data) => void texts.push(data.text),', + ' });', + ' feed.unsubscribe();', + ' // An undescribed subscription stays callable with args and without', + ' client.api.chat.onRaw.subscribe().unsubscribe();', + ' client.api.chat.onRaw.subscribe({ whatever: true }).unsubscribe();', + ' return { sent, rows, anything, token, texts };', + '};', + '', + '// ...and the contract is precise enough to REJECT the wrong thing.', + 'export const wrong = async () => {', + " const client = await connect('ws://127.0.0.1:1/');", + ' // @ts-expect-error a room is a string', + ' await client.api.chat.send({ room: 1, text: 2 });', + ' // @ts-expect-error a subscription is not callable', + " await client.api.chat.onMessage({ room: 'a' });", + ' // @ts-expect-error there is no such unit', + " await client.load('nope');", + '};', + '', + ].join('\n'), + ); + await writeFile( + path.join(dir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + skipLibCheck: true, + // No baseUrl: TypeScript 7 removed it, and an absolute `paths` entry + // needs no base to resolve against. + paths: { '@alexify/wrpc': [path.join(ROOT, 'index.d.ts')] }, + }, + files: ['api.d.ts', 'use.ts'], + }), + ); + const checked = await run(TSC, ['-p', path.join(dir, 'tsconfig.json')], { cwd: dir }); + assert.strictEqual(checked.code, 0, `tsc rejected the generated contract:\n${checked.stdout}${checked.stderr}`); + }); +}); + +test('CLI e2e: --schema feeds client.use() with no introspect wire traffic', async (t) => { + let introspects = 0; + // merge() rather than addHook: the shared `router` must stay untouched for + // the other tests. The hook reads context.method — the call identity. + const counted = router.merge( + defineRouter( + {}, + { + hooks: { + onRequest: async (context) => { + if (context.method === 'system/introspect') introspects++; + }, + }, + }, + ), + ); + const server = new Server({ + router: counted, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 100 }, + }); + await server.listen(); + t.after(() => server.close()); + const { port } = server.address(); + + const dir = await mkdtemp(path.join(os.tmpdir(), 'wrpc-schema-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const out = path.join(dir, 'api.d.ts'); + const artifactPath = path.join(dir, 'api.static.js'); + + const generated = await run(process.execPath, [ + BIN, + 'types', + `http://127.0.0.1:${port}/api`, + '--out', + out, + '--schema', + artifactPath, + ]); + assert.strictEqual(generated.code, 0, `wrpc types failed: ${generated.stderr}`); + assert.match(await readFile(out, 'utf8'), /export interface Api \{/, 'the types artifact is still written'); + + const artifact = require(artifactPath); + assert.strictEqual(artifact['auth.v1'].signIn.access, 'public'); + assert.strictEqual(artifact.chat.onMessage.kind, 'subscription'); + + introspects = 0; // the CLI's own fetch was one; the client must add none + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false, reconnect: false }); + t.after(() => void client.close()); + client.use(artifact); + assert.strictEqual(await client.api.chat.ping(), true); + const { token } = await client.api['auth.v1'].signIn({ login: 'me' }); + assert.strictEqual(token, 't'); + assert.strictEqual(introspects, 0, 'static scaffolding produced no introspect call'); +}); + +test('CLI e2e: a server that is not there fails with a message, not a stack', async () => { + // Port 1 is privileged and unbound: a connection there is refused at once. + const result = await run(process.execPath, [BIN, 'types', 'http://127.0.0.1:1/api', '--out', '-']); + assert.strictEqual(result.code, 1); + assert.match(result.stderr, /^wrpc types: Cannot reach http:\/\/127\.0\.0\.1:1\/api: /); + assert.ok(!result.stderr.includes(' at '), 'an expected failure must not print a stack'); + assert.strictEqual(result.stdout, ''); +}); + +test('CLI e2e: --out - writes the contract to stdout', async (t) => { + const server = new Server({ + router, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 100 }, + }); + await server.listen(); + t.after(() => server.close()); + const { port } = server.address(); + + const result = await run(process.execPath, [ + BIN, + 'types', + `http://127.0.0.1:${port}/api`, + '--units', + 'chat', + '--out', + '-', + ]); + assert.strictEqual(result.code, 0, result.stderr); + assert.match(result.stdout, /export interface Api \{/); + assert.match(result.stdout, /onMessage: SubscriptionContract { + const lines = []; + const io = (text) => void lines.push(String(text)); + io.lines = lines; + io.text = () => lines.join('\n'); + return io; +}; + +const warner = () => { + const warnings = []; + const warn = (message) => void warnings.push(message); + warn.warnings = warnings; + warn.matching = (needle) => warnings.filter((message) => message.includes(needle)); + return warn; +}; + +const answer = (packet, overrides = {}) => ({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify(packet), + ...overrides, +}); + +// --------------------------------------------------------------------------- +// argv + +test('CLI parseArgs: the happy paths', () => { + const parsed = parseArgs(['types', 'http://h/api', '--out', 'api.d.ts']); + assert.strictEqual(parsed.url, 'http://h/api'); + assert.strictEqual(parsed.out, 'api.d.ts'); + assert.strictEqual(parsed.interfaceName, 'Api'); + assert.strictEqual(parsed.packageName, '@alexify/wrpc'); + assert.deepStrictEqual(parsed.units, []); + + const full = parseArgs([ + 'types', + 'http://h/api', + '--out', + '-', + '--units', + 'chat, auth.v1', + '--units', + 'presence', + '--interface', + 'Contract', + '--package', + '../wrpc', + ]); + assert.strictEqual(full.out, '-'); + assert.deepStrictEqual(full.units, ['chat', 'auth.v1', 'presence']); + assert.strictEqual(full.interfaceName, 'Contract'); + assert.strictEqual(full.packageName, '../wrpc'); + + assert.deepStrictEqual(parseArgs(['--help']), { help: true }); + assert.deepStrictEqual(parseArgs(['-h']), { help: true }); + assert.deepStrictEqual(parseArgs(['--version']), { version: true }); + assert.deepStrictEqual(parseArgs(['-v']), { version: true }); + // --help wins wherever it appears, even after a broken argument list + assert.deepStrictEqual(parseArgs(['types', '--help']), { help: true }); +}); + +test("CLI parseArgs: '--' ends option parsing", () => { + const parsed = parseArgs(['--out', 'api.d.ts', '--', 'types', 'http://h/api']); + assert.strictEqual(parsed.url, 'http://h/api'); + // ...so a URL that looks like a flag is still a URL + const dashed = parseArgs(['--out', '-', '--', 'types', '--weird-host/api']); + assert.strictEqual(dashed.url, '--weird-host/api'); +}); + +test('CLI parseArgs: rejects every malformed invocation', () => { + const rejects = (argv, needle) => { + assert.throws( + () => parseArgs(argv), + (error) => { + assert.ok(error instanceof CliError, `${needle}: expected a CliError, got ${error}`); + assert.match(error.message, needle); + return true; + }, + `expected ${JSON.stringify(argv)} to be rejected`, + ); + }; + + rejects([], /Missing command/); + rejects(['serve', 'http://h', '--out', 'x'], /Unknown command 'serve'/); + rejects(['types'], /Missing /); + rejects(['types', 'http://h'], /Missing --out/); + rejects(['types', 'http://h', 'extra', '--out', 'x'], /Unexpected argument 'extra'/); + rejects(['types', 'http://h', '--nope'], /Unknown option --nope/); + // A flag with no value must not swallow the next flag + rejects(['types', 'http://h', '--out'], /--out requires a value/); + rejects(['types', 'http://h', '--out', '--units', 'chat'], /--out requires a value/); + rejects(['types', 'http://h', '--units'], /--units requires a value/); + rejects(['types', 'http://h', '--out', 'x', '--units', 'chat,,auth'], /must not contain an empty unit/); + rejects(['types', 'http://h', '--out', 'x', '--interface', 'not an identifier'], /valid TypeScript identifier/); + rejects(['types', 'http://h', '--out', 'x', '--interface', '1Api'], /valid TypeScript identifier/); +}); + +// --------------------------------------------------------------------------- +// rendering + +test('CLI quoteKey: bare identifiers, safely quoted everything else', () => { + assert.strictEqual(quoteKey('chat'), 'chat'); + assert.strictEqual(quoteKey('_$x0'), '_$x0'); + assert.strictEqual(quoteKey('auth.v1'), "'auth.v1'"); + assert.strictEqual(quoteKey('weird-name'), "'weird-name'"); + assert.strictEqual(quoteKey('1st'), "'1st'"); + // Anything that needed escaping keeps JSON's quoting, which is what makes it safe + assert.strictEqual(quoteKey('a\nb'), '"a\\nb"'); + assert.strictEqual(quoteKey('x"y'), '"x\\"y"'); + assert.strictEqual(quoteKey('back\\slash'), '"back\\\\slash"'); + assert.strictEqual(quoteKey("it's"), '"it\'s"'); +}); + +test('CLI renderType: type names, arrays and unions', () => { + const warn = warner(); + assert.strictEqual(renderType('string', warn), 'string'); + assert.strictEqual(renderType('number[]', warn), 'number[]'); + assert.strictEqual(renderType('boolean[][]', warn), 'boolean[][]'); + assert.strictEqual(renderType('string|number', warn), 'string | number'); + assert.strictEqual(renderType(' string | null ', warn), 'string | null'); + assert.strictEqual(renderType(['string'], warn), 'Array'); + assert.strictEqual(renderType([{ id: 'string' }], warn), 'Array<{ id: string }>'); + assert.deepStrictEqual(warn.warnings, []); +}); + +test('CLI renderType: an unknown type name degrades to unknown and says so', () => { + const warn = warner(); + // The allowlist is the whole defence: a descriptor is data, and this is + // where a hostile one would otherwise become code. + assert.strictEqual(renderType('string; export const boom = 1; type Z = ', warn), 'unknown'); + assert.strictEqual(renderType('Date', warn), 'unknown'); + assert.strictEqual(renderType('string|', warn), 'unknown'); + assert.strictEqual(renderType('str ing', warn), 'unknown'); + assert.strictEqual(renderType('string[', warn), 'unknown'); + assert.strictEqual(warn.matching('unknown type').length, 5); +}); + +test('CLI renderType: field maps, optional keys, empty cases', () => { + const warn = warner(); + assert.strictEqual(renderType({ a: 'string', 'b?': 'number' }, warn), '{ a: string; b?: number }'); + assert.strictEqual(renderType({ 'weird-key': 'string' }, warn), "{ 'weird-key': string }"); + // No fields declared means exactly that; 'object' is how you say "unspecified" + assert.strictEqual(renderType({}, warn), 'Record'); + assert.strictEqual(renderType('object', warn), 'object'); + assert.deepStrictEqual(warn.warnings, []); + + assert.strictEqual(renderType({ '?': 'string' }, warn), 'Record'); + assert.strictEqual(warn.matching('empty name').length, 1); +}); + +test('CLI renderType: refuses shapes it cannot mean', () => { + const warn = warner(); + // An array describes an array OF one shape, so a tuple is not a descriptor + assert.strictEqual(renderType(['string', 'number'], warn), 'unknown'); + assert.strictEqual(renderType([], warn), 'unknown'); + assert.strictEqual(warn.matching('exactly one entry').length, 2); + assert.strictEqual(renderType(42, warn), 'unknown'); + assert.strictEqual(renderType(null, warn), 'unknown'); + assert.strictEqual(renderType(true, warn), 'unknown'); + assert.strictEqual(warn.matching('must be a type name').length, 3); +}); + +test('CLI renderType: nesting is capped', () => { + const warn = warner(); + const deep = (depth) => (depth === 0 ? 'string' : { nested: deep(depth - 1) }); + const rendered = renderType(deep(MAX_DEPTH + 4), warn); + assert.strictEqual(warn.matching(`deeper than ${MAX_DEPTH}`).length, 1); + // Capped, not truncated silently: the innermost level says 'unknown' + assert.match(rendered, /unknown/); + assert.strictEqual(warner().warnings.length, 0); + const shallow = warner(); + assert.strictEqual(renderType(deep(1), shallow), '{ nested: string }'); + assert.deepStrictEqual(shallow.warnings, []); +}); + +test('CLI renderTypes: a whole introspection becomes a contract interface', () => { + const warn = warner(); + const text = renderTypes( + { + chat: { + send: { + access: 'public', + meta: { description: 'Post a message' }, + signature: { args: { text: 'string', 'replyTo?': 'string' }, returns: { id: 'string' } }, + }, + ping: { access: 'public' }, + onMessage: { + access: 'public', + kind: 'subscription', + signature: { args: { room: 'string' }, data: { text: 'string' } }, + }, + }, + 'auth.v1': { signIn: { access: 'public', signature: { args: { login: 'string' } } } }, + }, + { url: 'http://127.0.0.1:8000/api', warn }, + ); + + assert.deepStrictEqual(warn.warnings, []); + assert.strictEqual( + text, + [ + '// Generated by `wrpc types`. Do not edit — re-run the command instead.', + '// Source: http://127.0.0.1:8000/api', + '', + "import type { SubscriptionContract } from '@alexify/wrpc';", + '', + 'export interface Api {', + " 'auth.v1': {", + ' signIn(args: { login: string }): Promise;', + ' };', + ' chat: {', + ' onMessage: SubscriptionContract<{ room: string }, { text: string }>;', + ' ping(args?: unknown): Promise;', + ' /** Post a message */', + ' send(args: { text: string; replyTo?: string }): Promise<{ id: string }>;', + ' };', + '}', + '', + ].join('\n'), + ); +}); + +test('CLI renderTypes: no subscriptions means no import, and the name is configurable', () => { + const text = renderTypes( + { chat: { ping: { access: 'public' } } }, + { + url: 'http://h/api', + interfaceName: 'Contract', + packageName: '../../index.js', + }, + ); + assert.ok(!text.includes('import type'), 'an unused import is noise in a generated file'); + assert.match(text, /export interface Contract \{/); + + const withSub = renderTypes({ feed: { onTick: { kind: 'subscription' } } }, { packageName: '../../index.js' }); + assert.match(withSub, /import type \{ SubscriptionContract \} from '\.\.\/\.\.\/index\.js';/); + // Undeclared subscription args stay callable both ways: `any`, not `unknown` + assert.match(withSub, /onTick: SubscriptionContract;/); +}); + +test('CLI renderTypes: hostile names and values cannot escape into code', () => { + const warn = warner(); + // The real path is JSON.parse, where '__proto__' is an OWN property rather + // than the prototype setter an object literal would hit. + const introspection = JSON.parse( + JSON.stringify({ + '*/ export const pwned = 1; /*': { m: { access: 'public' } }, + 'line\nbreak': { m: { access: 'public' } }, + evil: { + m: { + access: 'public', + meta: { description: 'closes */ the comment' }, + // Backticks and template syntax in a field name must stay inert text. + // eslint-disable-next-line no-template-curly-in-string + signature: { args: { '`tpl`': 'string', '${p}': 'number' } }, + }, + }, + }), + ); + introspection.__proto__ = { m: { access: 'public' } }; // eslint-disable-line no-proto + const text = renderTypes(introspection, { url: 'http://h/api', warn }); + + // The hostile name survives as DATA — a quoted key — and never as code: + // no generated line begins with it, so nothing it contains is a statement. + assert.match(text, /^ {2}'\*\/ export const pwned = 1; \/\*': \{$/m); + assert.ok( + !text.split('\n').some((line) => line.trim().startsWith('export const pwned')), + 'a unit name must never become a statement', + ); + assert.match(text, /"line\\nbreak": \{/); + assert.ok(!text.split('\n').some((line) => line.trim() === 'break": {'), 'a newline must not reach the output'); + assert.match(text, /\/\*\* closes \* \/ the comment \*\//); + assert.match(text, /'`tpl`': string; '\$\{p\}': number/); + assert.strictEqual(Object.prototype.m, undefined, 'nothing may be written through __proto__'); + + // ...and the whole thing still parses as TypeScript-shaped text: every + // generated line is a declaration, a brace, a comment or an import. + for (const line of text.split('\n')) { + if (line.trim() === '') continue; + assert.match( + line, + /^(\/\/|\s*\/\*\*|import type |export interface |\s*[$\w'"[]|\s*\};?$|\})/, + `stray line: ${line}`, + ); + } +}); + +test('CLI renderTypes: skips what it cannot describe, loudly', () => { + const warn = warner(); + const text = renderTypes( + { + broken: 'not an object', + unit: { notAMethod: 7, ok: { access: 'public' } }, + // A unit whose only declarations are inbound events has no methods at all + eventsOnly: {}, + mixed: { + call: { access: 'public', signature: { data: 'string' } }, + feed: { kind: 'subscription', signature: { returns: 'string', data: 'string' } }, + bad: { access: 'public', signature: 'nope' }, + }, + }, + { warn }, + ); + + assert.strictEqual(warn.matching("unit 'broken' is not an object").length, 1); + assert.strictEqual(warn.matching('unit/notAMethod is not a method descriptor').length, 1); + assert.strictEqual(warn.matching("mixed/call: 'data' means nothing for a call").length, 1); + assert.strictEqual(warn.matching("mixed/feed: 'returns' means nothing for a subscription").length, 1); + assert.strictEqual(warn.matching('mixed/bad: signature must be an object').length, 1); + assert.ok(!text.includes('broken')); + assert.match(text, /eventsOnly: Record;/); + assert.match(text, /ok\(args\?: unknown\): Promise;/); +}); + +test('CLI renderTypes: an introspection that is not an object is a failure, not an empty file', () => { + assert.throws(() => renderTypes(null), CliError); + assert.throws(() => renderTypes('nope'), /did not answer introspection with an object/); +}); + +test('CLI renderTypes: diagnostics are optional — no warn means no crash', () => { + // Anything worth warning about must still render, whether anyone is + // listening or not: renderTypes is used by the CLI, which passes a warn, + // and directly by tests and tooling, which need not. + const text = renderTypes({ broken: 'not an object', ok: { m: { access: 'public', signature: { args: 'Date' } } } }); + assert.match(text, /m\(args: unknown\): Promise;/); + assert.ok(!text.includes('broken')); +}); + +test('CLI renderTypes: the source comment drops credentials, query strings and junk', () => { + assert.match( + renderTypes({}, { url: 'http://user:pw@h:8000/api?token=secret' }), + /\/\/ Source: http:\/\/h:8000\/api$/m, + ); + // Not a URL at all: kept verbatim, but still sanitised + assert.match(renderTypes({}, { url: 'not a url' }), /\/\/ Source: not a url$/m); + assert.match(renderTypes({}, { url: 'a\nb' }), /\/\/ Source: a b$/m); + assert.match(renderTypes({}), /\/\/ Source: $/m); +}); + +// --------------------------------------------------------------------------- +// talking to a server + +test('CLI fetchIntrospection: sends a call packet and returns the result', async () => { + const calls = []; + const doFetch = async (url, options) => { + calls.push({ url, options }); + return answer({ type: 'callback', id: '1', result: { chat: {} } }); + }; + assert.deepStrictEqual(await fetchIntrospection('http://h/api', [], doFetch), { chat: {} }); + assert.strictEqual(calls[0].url, 'http://h/api'); + assert.strictEqual(calls[0].options.method, 'POST'); + assert.strictEqual(calls[0].options.headers['Content-Type'], 'application/json'); + const sent = JSON.parse(calls[0].options.body); + assert.strictEqual(sent.method, 'system/introspect'); + // No filter must NOT be an empty array: introspect() would filter every unit + // away and answer with nothing. + assert.deepStrictEqual(sent.args, {}); + + await fetchIntrospection('http://h/api', ['chat'], doFetch); + assert.deepStrictEqual(JSON.parse(calls[1].options.body).args, ['chat']); +}); + +test('CLI fetchIntrospection: every way the server can disappoint', async () => { + const rejects = async (doFetch, needle) => { + await assert.rejects( + () => fetchIntrospection('http://h/api', [], doFetch), + (error) => { + assert.ok(error instanceof CliError, `${needle}: expected a CliError, got ${error}`); + assert.match(error.message, needle); + return true; + }, + ); + }; + + await rejects(async () => { + throw new Error('ECONNREFUSED'); + }, /Cannot reach http:\/\/h\/api: ECONNREFUSED/); + await rejects(async () => ({ ok: false, status: 404, statusText: 'Not Found' }), /answered 404 Not Found/); + await rejects(async () => ({ ok: false, status: 500, statusText: '' }), /answered 500$/); + await rejects(async () => answer(null, { text: async () => 'not json' }), /answered something that is not JSON/); + await rejects( + async () => answer({ type: 'callback', id: '1', error: { message: 'forbidden', code: 403 } }), + /refused with 403: forbidden/, + ); + await rejects(async () => answer({ type: 'callback', id: '1', error: {} }), /refused with 500: unknown error/); + await rejects(async () => answer({ type: 'end', id: '1' }), /answered 'end' instead of a callback/); +}); + +// --------------------------------------------------------------------------- +// the command + +test('CLI main: --help and --version answer on stdout with code 0', async () => { + const log = collect(); + assert.strictEqual(await main(['--help'], { log }), 0); + assert.strictEqual(log.text(), USAGE); + + const versionLog = collect(); + assert.strictEqual(await main(['--version'], { log: versionLog, version: () => '9.9.9' }), 0); + assert.strictEqual(versionLog.text(), '9.9.9'); +}); + +test('CLI main: --version with no injected reader reports the package version', async () => { + // Covers the default `version` and `log`: both are resolved at call time, + // so swapping console.log is enough to keep the run quiet. + const { version } = require('../../package.json'); + const lines = []; + const original = console.log; + console.log = (text) => void lines.push(String(text)); + try { + assert.strictEqual(await main(['--version']), 0); + } finally { + console.log = original; + } + assert.deepStrictEqual(lines, [version]); +}); + +test('CLI main: writes the declaration file', async () => { + const written = []; + const doFetch = async () => answer({ type: 'callback', id: '1', result: { chat: { ping: { access: 'public' } } } }); + const code = await main(['types', 'http://h/api', '--out', 'api.d.ts'], { + log: collect(), + error: collect(), + fetch: doFetch, + write: async (file, text) => void written.push({ file, text }), + }); + assert.strictEqual(code, 0); + assert.strictEqual(written.length, 1); + assert.strictEqual(written[0].file, 'api.d.ts'); + assert.match(written[0].text, /export interface Api \{/); + assert.ok(written[0].text.endsWith('\n'), 'a generated file ends with a newline'); +}); + +test("CLI main: '--out -' writes to stdout instead of a file", async () => { + const log = collect(); + const doFetch = async () => answer({ type: 'callback', id: '1', result: { chat: { ping: { access: 'public' } } } }); + const code = await main(['types', 'http://h/api', '--out', '-'], { + log, + error: collect(), + fetch: doFetch, + write: async () => assert.fail('nothing may be written when --out is -'), + }); + assert.strictEqual(code, 0); + assert.match(log.text(), /export interface Api \{/); + assert.ok(!log.text().endsWith('\n'), 'console.log adds the trailing newline itself'); +}); + +test('CLI main: the real fs writer', async () => { + // Covers the default `write`, which is node:fs/promises.writeFile. + const dir = await mkdtemp(path.join(os.tmpdir(), 'wrpc-cli-')); + const file = path.join(dir, 'api.d.ts'); + try { + const doFetch = async () => answer({ type: 'callback', id: '1', result: { chat: { ping: { access: 'public' } } } }); + const code = await main(['types', 'http://h/api', '--out', file], { + log: collect(), + error: collect(), + fetch: doFetch, + }); + assert.strictEqual(code, 0); + assert.match(await readFile(file, 'utf8'), /ping\(args\?: unknown\): Promise;/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('CLI main: warnings go to stderr and do not fail the run', async () => { + const error = collect(); + const doFetch = async () => + answer({ + type: 'callback', + id: '1', + result: { chat: { ping: { access: 'public', signature: { args: 'Date' } } } }, + }); + const code = await main(['types', 'http://h/api', '--out', '-'], { + log: collect(), + error, + fetch: doFetch, + write: async () => {}, + }); + assert.strictEqual(code, 0); + assert.match(error.text(), /wrpc types: unknown type 'Date'/); +}); + +test('CLI main: failures answer 1 with a one-line message', async () => { + const error = collect(); + assert.strictEqual(await main(['types', 'http://h/api'], { log: collect(), error }), 1); + assert.deepStrictEqual(error.lines, ["wrpc types: Missing --out . Use '--out -' to write to stdout."]); + + const unreachable = collect(); + const code = await main(['types', 'http://h/api', '--out', 'x'], { + log: collect(), + error: unreachable, + fetch: async () => { + throw new Error('ECONNREFUSED'); + }, + }); + assert.strictEqual(code, 1); + assert.match(unreachable.text(), /Cannot reach http:\/\/h\/api/); +}); + +test('CLI main: a path it cannot write to is a message, not a stack', async () => { + // The commonest real failure — a mistyped --out — must not answer with ENOENT + // and a stack trace. + const error = collect(); + const code = await main(['types', 'http://h/api', '--out', 'no/such/dir/api.d.ts'], { + log: collect(), + error, + fetch: async () => answer({ type: 'callback', id: '1', result: { chat: { ping: { access: 'public' } } } }), + }); + assert.strictEqual(code, 1); + assert.match(error.text(), /^wrpc types: Cannot write no\/such\/dir\/api\.d\.ts: /); + assert.ok(!error.text().includes(' at '), 'an expected failure must not print a stack'); +}); + +test('CLI main: an unexpected failure reports its stack rather than pretending to succeed', async () => { + const error = collect(); + const code = await main(['types', 'http://h/api', '--out', '-'], { + error, + fetch: async () => answer({ type: 'callback', id: '1', result: { chat: { ping: { access: 'public' } } } }), + // Writing to a closed stdout is the realistic version of this: nothing + // downstream expects it, so the last-resort handler has to say what broke. + log: () => { + throw new TypeError('EPIPE'); + }, + }); + assert.strictEqual(code, 1); + assert.match(error.text(), /TypeError: EPIPE/); + assert.match(error.text(), /at /, 'an unexpected failure keeps its stack'); +}); + +test('CLI main: a thrown non-Error still reports on the default stderr', async () => { + // Covers the default `error` writer and the stackless branch of the last + // resort handler — something thrown that is not an Error has no `.stack`. + const lines = []; + const original = console.error; + console.error = (text) => void lines.push(String(text)); + try { + const code = await main(['types', 'http://h/api', '--out', '-'], { + fetch: async () => answer({ type: 'callback', id: '1', result: { chat: { ping: { access: 'public' } } } }), + log: () => { + throw 'just a string'; // eslint-disable-line no-throw-literal + }, + }); + assert.strictEqual(code, 1); + } finally { + console.error = original; + } + assert.deepStrictEqual(lines, ['wrpc types: just a string']); +}); + +test('CLI main: a write that fails with a non-Error still says what it was doing', async () => { + const error = collect(); + const code = await main(['types', 'http://h/api', '--out', 'x'], { + log: collect(), + error, + fetch: async () => answer({ type: 'callback', id: '1', result: { chat: { ping: { access: 'public' } } } }), + write: async () => { + throw 'disk on fire'; // eslint-disable-line no-throw-literal + }, + }); + assert.strictEqual(code, 1); + assert.deepStrictEqual(error.lines, ['wrpc types: Cannot write x: disk on fire']); +}); + +// --------------------------------------------------------------------------- +// --schema: the static introspection artifact + +test('CLI parseArgs: --schema and --format', () => { + const parsed = parseArgs(['types', 'http://h/api', '--out', 'api.d.ts', '--schema', 'api.static.js']); + assert.strictEqual(parsed.schema, 'api.static.js'); + assert.strictEqual(parsed.format, 'cjs', 'cjs is the default format'); + const esm = parseArgs(['types', 'http://h/api', '--out', '-', '--schema', 'api.static.js', '--format', 'esm']); + assert.strictEqual(esm.format, 'esm'); + + const rejects = (argv, needle) => { + assert.throws( + () => parseArgs(argv), + (error) => { + assert.ok(error instanceof CliError, `${needle}: expected a CliError, got ${error}`); + assert.match(error.message, needle); + return true; + }, + ); + }; + rejects(['types', 'http://h', '--out', 'x', '--format', 'esm'], /--format only applies to --schema/); + rejects( + ['types', 'http://h', '--out', 'x', '--schema', 's.js', '--format', 'umd'], + /--format must be 'cjs' or 'esm'/, + ); + rejects( + ['types', 'http://h', '--out', '-', '--schema', '-'], + /one of --out, --schema and --openapi may write to stdout/, + ); + rejects(['types', 'http://h', '--out', 'x', '--schema'], /--schema requires a value/); +}); + +test('renderStatic: a sorted, verbatim introspection module', () => { + const introspection = { + zeta: { b: { access: 'public' }, a: { access: 'public', kind: 'subscription' } }, + alpha: { m: { access: 'public', schema: { body: { type: 'object' } } } }, + }; + const cjs = renderStatic(introspection, { url: 'http://h/api?token=secret' }); + assert.ok(cjs.startsWith("'use strict';\n"), 'cjs opens with use strict'); + assert.match(cjs, /^module\.exports = \{$/m); + assert.match(cjs, /\/\/ Source: http:\/\/h\/api$/m, 'credentials and query never reach the file'); + assert.ok(cjs.endsWith(';\n'), 'the artifact ends with a newline'); + // Deterministic: unit and method keys are sorted whatever order they came in + assert.ok(cjs.indexOf('"alpha"') < cjs.indexOf('"zeta"')); + assert.ok(cjs.indexOf('"a"') < cjs.indexOf('"b"')); + const again = renderStatic( + { alpha: { m: introspection.alpha.m }, zeta: { a: introspection.zeta.a, b: introspection.zeta.b } }, + { url: 'http://h/api?token=secret' }, + ); + assert.strictEqual(cjs, again, 'byte-identical across runs and key orders'); + // The payload survives verbatim: require()-equivalent shape via JSON + const literal = cjs.slice(cjs.indexOf('module.exports = ') + 'module.exports = '.length, -2); + assert.deepStrictEqual(JSON.parse(literal), { + alpha: { m: { access: 'public', schema: { body: { type: 'object' } } } }, + zeta: { a: { access: 'public', kind: 'subscription' }, b: { access: 'public' } }, + }); + + const esm = renderStatic(introspection, { url: 'http://h/api', format: 'esm' }); + assert.ok(!esm.includes('use strict')); + assert.match(esm, /^export default \{$/m); + + const warn = warner(); + const skipped = renderStatic({ ok: { m: { access: 'public' } }, broken: 42 }, { warn }); + assert.strictEqual(warn.matching("unit 'broken'").length, 1); + assert.ok(!skipped.includes('broken')); +}); + +test("renderStatic: a '__proto__' unit lands as data, not as a prototype", () => { + const introspection = JSON.parse('{"__proto__": {"m": {"access": "public"}}, "chat": {}}'); + const cjs = renderStatic(introspection, {}); + assert.match(cjs, /"__proto__"/, 'the key is emitted as a plain property'); +}); + +test('CLI main: --schema writes both artifacts from one fetch', async () => { + const written = []; + let fetches = 0; + const doFetch = async () => { + fetches++; + return answer({ type: 'callback', id: '1', result: { chat: { ping: { access: 'public' } } } }); + }; + const code = await main(['types', 'http://h/api', '--out', 'api.d.ts', '--schema', 'api.static.js'], { + log: collect(), + error: collect(), + fetch: doFetch, + write: async (file, text) => void written.push({ file, text }), + }); + assert.strictEqual(code, 0); + assert.strictEqual(fetches, 1, 'one fetch feeds both artifacts'); + assert.strictEqual(written.length, 2); + assert.strictEqual(written[0].file, 'api.d.ts'); + assert.match(written[0].text, /export interface Api \{/); + assert.strictEqual(written[1].file, 'api.static.js'); + assert.match(written[1].text, /module\.exports = \{/); + assert.match(written[1].text, /"ping"/); +}); + +test("CLI main: '--schema -' streams the module to stdout while --out writes the file", async () => { + const log = collect(); + const written = []; + const doFetch = async () => answer({ type: 'callback', id: '1', result: { chat: { ping: { access: 'public' } } } }); + const code = await main(['types', 'http://h/api', '--out', 'api.d.ts', '--schema', '-', '--format', 'esm'], { + log, + error: collect(), + fetch: doFetch, + write: async (file, text) => void written.push({ file, text }), + }); + assert.strictEqual(code, 0); + assert.strictEqual(written.length, 1, 'only the types artifact hit the filesystem'); + assert.match(log.text(), /export default \{/); +}); + +test('CLI renderTypes: typed-event blocks — `on` becomes `sends`, `emits` becomes `events`', () => { + const warnings = []; + const text = renderTypes( + { + chat: { + send: { access: 'public', signature: { args: { text: 'string' }, returns: { id: 'string' } } }, + on: { + typing: { access: 'session', signature: { args: { on: 'boolean' } } }, + bare: { access: 'public' }, + }, + emits: { + message: { data: { text: 'string', from: 'string' } }, + confirm: { data: { id: 'string' }, returns: 'boolean' }, + }, + }, + }, + { warn: (message) => warnings.push(message) }, + ); + assert.match(text, /sends: \{/); + assert.match(text, /typing: \{on: boolean\}|typing: \{ on: boolean \}/); + assert.match(text, /bare: unknown;/); + assert.match(text, /events: \{/); + assert.match(text, /message: \(data: \{.*text: string.*\}\) => void;/s); + assert.match(text, /confirm: \(data: \{.*id: string.*\}\) => boolean;/s); + // Neither reserved block leaks as a callable method. + assert.ok(!/\bon\(/.test(text)); + assert.ok(!/\bemits\(/.test(text)); + assert.deepStrictEqual(warnings, []); +}); + +test('CLI renderOpenApi: http-mapped procedures project to an OpenAPI 3 document', () => { + const warnings = []; + const doc = JSON.parse( + renderOpenApi( + { + orders: { + get: { + access: 'public', + http: { method: 'GET', path: '/v1/orders/:id' }, + schema: { + params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + querystring: { type: 'object', properties: { expand: { type: 'boolean' } } }, + }, + }, + create: { + access: 'session', + http: { method: 'POST', path: '/v1/orders', status: 201 }, + schema: { body: { type: 'object', properties: { sku: { type: 'string' } }, required: ['sku'] } }, + meta: { description: 'Creates an order' }, + }, + // No http mapping: not a REST endpoint, absent from the document. + internal: { access: 'public' }, + on: { ping: { access: 'public' } }, + }, + }, + { url: 'http://x/api', warn: (message) => warnings.push(message) }, + ), + ); + assert.strictEqual(doc.openapi, '3.0.3'); + assert.deepStrictEqual(Object.keys(doc.paths).sort(), ['/v1/orders', '/v1/orders/{id}']); + const get = doc.paths['/v1/orders/{id}'].get; + assert.strictEqual(get.operationId, 'orders/get'); + assert.deepStrictEqual( + get.parameters.map((p) => [p.name, p.in, p.required]), + [ + ['id', 'path', true], + ['expand', 'query', false], + ], + ); + const post = doc.paths['/v1/orders'].post; + assert.strictEqual(post.summary, 'Creates an order'); + assert.ok(post.responses['201']); + assert.ok(post.responses.default.content['application/json'].schema.properties.code); + assert.strictEqual(post.requestBody.content['application/json'].schema.required[0], 'sku'); + assert.deepStrictEqual(warnings, []); +}); + +test('CLI parseArgs: --openapi rides along, one stdout at most', () => { + const options = parseArgs(['types', 'http://x/api', '--out', 'api.d.ts', '--openapi', 'openapi.json']); + assert.strictEqual(options.openapi, 'openapi.json'); + assert.throws(() => parseArgs(['types', 'http://x/api', '--out', '-', '--openapi', '-']), /one of/); +}); diff --git a/tests/client.internals.test.js b/tests/client.internals.test.js index 9705648..043d4e5 100644 --- a/tests/client.internals.test.js +++ b/tests/client.internals.test.js @@ -113,6 +113,30 @@ test('WrpcClient packet handling', async (t) => { ); await assert.rejects(loadPromise, (error) => error instanceof WrpcError && error.code === 500); }); + + await t.test('#handlePacket carries wire error details onto the WrpcError', async () => { + const { client, transport } = makeClient(); + transport.active = true; + const loadPromise = client.load('greeting'); + await timers.setImmediate(); + const sentPacket = JSON.parse(transport.sent[0]); + const details = { issues: [{ message: 'name required', path: ['name'] }] }; + transport.emit( + 'message', + JSON.stringify({ type: 'callback', id: sentPacket.id, error: { message: 'invalid', code: 400, details } }), + ); + await assert.rejects(loadPromise, (error) => { + assert.ok(error instanceof WrpcError); + assert.strictEqual(error.code, 400); + assert.deepStrictEqual(error.details, details); + return true; + }); + }); + + await t.test('WrpcError leaves details absent when the wire carried none', () => { + const error = new WrpcError({ message: 'nope', code: 404 }); + assert.strictEqual('details' in error, false); + }); }); test('WrpcClient static online/offline/initialize', async (t) => { @@ -208,3 +232,82 @@ test('WrpcClient.connect with a Service Worker (event transport)', async (t) => assert.strictEqual(client.active, false); }); }); + +// --------------------------------------------------------------------------- +// Static introspection: use() + +test('use(): static introspection without wire traffic', async (t) => { + const artifact = () => ({ + greeting: { hello: { access: 'public' } }, + feed: { ticks: { access: 'public', kind: 'subscription' } }, + }); + + await t.test('scaffolds synchronously, before open(), with zero wire traffic', () => { + const { client, transport } = makeClient(); + const result = client.use(artifact()); + assert.strictEqual(result, client, 'use() returns this'); + assert.strictEqual(typeof client.api.greeting.hello, 'function'); + assert.strictEqual(typeof client.api.feed.ticks.subscribe, 'function', 'a kind: subscription scaffolds one'); + assert.strictEqual(transport.sent.length, 0, 'nothing went over the wire'); + }); + + await t.test('a use() unit is a real Emitter: server events land on it', async () => { + const { client, transport } = makeClient(); + client.use(artifact()); + const seen = []; + client.api.greeting.on('waved', (data) => void seen.push(data)); + transport.emit('message', JSON.stringify({ type: 'event', name: 'greeting/waved', data: { at: 1 } })); + await timers.setImmediate(); + assert.deepStrictEqual(seen, [{ at: 1 }]); + }); + + await t.test('load() after use() flips the unit to dynamic: it reloads on reconnect', async (t) => { + const transport = new FakeTransport('fake://x'); + const client = new WrpcClient('fake://x', transport, { + heartbeat: false, + reconnect: { minDelay: 1, maxDelay: 2, retries: 2 }, + }); + t.after(() => void client.close()); + await client.open(); + client.use(artifact()); + const loadPromise = client.load('greeting'); + await timers.setImmediate(); + const sent = JSON.parse(transport.sent.at(-1)); + assert.strictEqual(sent.method, 'system/introspect'); + transport.emit('message', JSON.stringify({ type: 'callback', id: sent.id, result: { greeting: { wave: {} } } })); + await loadPromise; + assert.strictEqual(typeof client.api.greeting.wave, 'function'); + assert.strictEqual(client.api.greeting.hello, undefined, 'the stale static method is gone'); + // Reconnect re-introspects the load()ed unit only — 'feed' stays static. + transport.sent.length = 0; + transport.close(); + for (let i = 0; i < 200 && transport.sent.length === 0; i++) await timers.setTimeout(5); + const packet = JSON.parse(transport.sent[0]); + assert.strictEqual(packet.method, 'system/introspect'); + assert.deepStrictEqual(packet.args, ['greeting'], 'the static unit is not re-introspected'); + transport.emit('message', JSON.stringify({ type: 'callback', id: packet.id, result: { greeting: { wave: {} } } })); + }); + + await t.test('use() after load() is a no-op for that unit (dynamic wins)', async () => { + const { client, transport } = makeClient(); + transport.active = true; + const loadPromise = client.load('greeting'); + await timers.setImmediate(); + const sent = JSON.parse(transport.sent[0]); + transport.emit('message', JSON.stringify({ type: 'callback', id: sent.id, result: { greeting: { wave: {} } } })); + await loadPromise; + client.use(artifact()); + assert.strictEqual(client.api.greeting.hello, undefined, 'the artifact did not overwrite the loaded unit'); + assert.strictEqual(typeof client.api.greeting.wave, 'function'); + assert.strictEqual(typeof client.api.feed.ticks.subscribe, 'function', 'other units still scaffold'); + }); + + await t.test('bad shapes throw a TypeError', () => { + const { client } = makeClient(); + assert.throws(() => client.use(null), TypeError); + assert.throws(() => client.use([]), TypeError); + assert.throws(() => client.use('nope'), TypeError); + assert.throws(() => client.use({ unit: 'nope' }), /unit 'unit' must be an object/); + assert.throws(() => client.use({ unit: [] }), TypeError); + }); +}); diff --git a/tests/client.resilience.test.js b/tests/client.resilience.test.js new file mode 100644 index 0000000..5dc87cb --- /dev/null +++ b/tests/client.resilience.test.js @@ -0,0 +1,1745 @@ +'use strict'; + +const http = require('node:http'); +const timers = require('node:timers/promises'); +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { WebsocketServer } = require('#ws'); +const { Server, WrpcClient, defineRouter, procedure } = require('../index.js'); +const { backoffDelay, jsonParse } = require('../src/utils.js'); + +const noop = () => {}; + +const waitFor = async (predicate, message = 'condition never held') => { + for (let i = 0; i < 100; i++) { + if (predicate()) return; + await timers.setTimeout(5); + } + assert.fail(message); +}; + +const router = (extra = {}) => + defineRouter({ + test: { + hello: procedure({ access: 'public', handler: async () => 'hi' }), + notify: procedure({ + access: 'public', + handler: async (context, { name = 'test/ping' } = {}) => { + context.client.sendEvent(name, { ping: true }); + return { ok: true }; + }, + }), + ...extra, + }, + }); + +const createServer = async (definition, port = 0) => { + const server = new Server({ + router: definition, + host: '127.0.0.1', + port, + protocol: 'http', + logger: false, + timeouts: { bind: 50 }, + retry: 20, + }); + await server.listen(); + return { server, port: server.address().port }; +}; + +// --------------------------------------------------------------------------- +// The backoff schedule, as a pure function + +test('backoffDelay: truncated exponential growth with full jitter', async (t) => { + const schedule = { minDelay: 100, maxDelay: 1000, factor: 2 }; + + await t.test('without jitter the window itself is the delay', () => { + const delays = [0, 1, 2, 3, 4].map((attempt) => backoffDelay({ ...schedule, attempt, jitter: false })); + assert.deepStrictEqual(delays, [100, 200, 400, 800, 1000], 'growth is capped by maxDelay'); + }); + + await t.test('full jitter spreads the delay over the whole window', () => { + assert.strictEqual(backoffDelay({ ...schedule, attempt: 2, random: () => 0 }), 0); + assert.strictEqual(backoffDelay({ ...schedule, attempt: 2, random: () => 1 }), 400); + assert.strictEqual(backoffDelay({ ...schedule, attempt: 2, random: () => 0.5 }), 200); + }); + + await t.test('a real random source stays inside the window', () => { + for (let attempt = 0; attempt < 8; attempt++) { + const window = Math.min(schedule.maxDelay, schedule.minDelay * 2 ** attempt); + for (let i = 0; i < 100; i++) { + const delay = backoffDelay({ ...schedule, attempt }); + assert.ok(delay >= 0 && delay <= window, `delay ${delay} outside [0, ${window}]`); + } + } + }); + + await t.test('a huge attempt count cannot overflow past the cap', () => { + assert.strictEqual(backoffDelay({ ...schedule, attempt: 5000, jitter: false }), 1000); + assert.strictEqual(backoffDelay({ minDelay: 100, maxDelay: 1000, factor: 0, attempt: 3, jitter: false }), 100); + }); + + await t.test('defaults: factor 2 and jitter on', () => { + const delay = backoffDelay({ minDelay: 100, maxDelay: 1000, attempt: 1, random: () => 1 }); + assert.strictEqual(delay, 200); + }); +}); + +test('reconnect: the reconnectTimeout shorthand keeps the delay it asked for', async (t) => { + const { server, port } = await createServer(router()); + t.after(() => server.close()); + const url = `ws://127.0.0.1:${port}/api`; + + // A shorthand above the default 30 s cap used to be silently capped back + // down to 30 s, reconnecting FASTER than asked. + const shorthand = await WrpcClient.connect(url, { reconnectTimeout: 60_000, random: () => 1, heartbeat: false }); + const explicit = await WrpcClient.connect(url, { + reconnect: { minDelay: 60_000 }, + random: () => 1, + heartbeat: false, + }); + t.after(() => { + shorthand.close(); + explicit.close(); + }); + + const first = new Promise((resolve) => shorthand.once('reconnecting', resolve)); + const second = new Promise((resolve) => explicit.once('reconnecting', resolve)); + await server.close(); + + assert.strictEqual((await first).delay, 60_000); + assert.strictEqual((await second).delay, 60_000, 'both spellings mean the same thing'); +}); + +test('reconnect: a socket abandoned by terminate() cannot close its replacement', async (t) => { + const { server, port } = await createServer(router()); + t.after(() => server.close()); + + const { ws: WsTransport } = WrpcClient.transport; + const transport = new WsTransport(`ws://127.0.0.1:${port}/api`); + t.after(() => transport.close()); + await transport.open(); + + const closes = []; + transport.on('close', () => void closes.push(Date.now())); + + // terminate() reports the close and walks away from a still-live socket — + // which is what a heartbeat timeout does. The reconnect then installs a new + // socket while the old one is still finishing its close handshake. + transport.terminate(); + assert.strictEqual(closes.length, 1); + assert.strictEqual(transport.active, false); + + await transport.open(); + assert.strictEqual(transport.active, true); + + await timers.setTimeout(150); // the abandoned socket's own 'close' lands here + assert.strictEqual(transport.active, true, 'the abandoned socket must not close its replacement'); + assert.strictEqual(closes.length, 1, 'and must not report a close it does not own'); +}); + +// --------------------------------------------------------------------------- +// Reconnect + +test('reconnect: the backoff schedule drives the retries and then gives up', async (t) => { + const { server, port } = await createServer(router()); + const delays = []; + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + reconnect: { minDelay: 10, maxDelay: 40, factor: 2, retries: 4 }, + random: () => 1, // pin the jitter to the top of the window + heartbeat: false, + }); + t.after(() => void client.close()); + client.on('error', noop); // connection refusals are expected here + client.on('reconnecting', ({ delay }) => delays.push(delay)); + const exhausted = new Promise((resolve) => client.once('reconnect-failed', resolve)); + + await server.close(); + const failure = await exhausted; + + assert.deepStrictEqual(delays, [10, 20, 40, 40], 'exponential growth, capped at maxDelay'); + assert.strictEqual(failure.attempts, 4); + assert.strictEqual(client.active, false); +}); + +test('reconnect: the api is rebuilt and its listeners survive', async (t) => { + const legacy = { legacy: procedure({ access: 'public', handler: async () => 'old' }) }; + const first = await createServer(router(legacy)); + const { port } = first; + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + reconnect: { minDelay: 10, maxDelay: 20, jitter: false }, + heartbeat: false, + }); + t.after(() => void client.close()); + client.on('error', noop); + + await client.load('test'); + assert.strictEqual(await client.api.test.hello(), 'hi'); + assert.strictEqual(await client.api.test.legacy(), 'old'); + + const unit = client.api.test; + const events = []; + unit.on('ping', (data) => events.push(data)); + + const reconnected = new Promise((resolve) => client.once('reconnect', resolve)); + await first.server.close(); + // The replacement drops the `legacy` method: a reload has to reflect that. + const second = await createServer(router(), port); + t.after(() => second.server.close()); + + const info = await reconnected; + assert.deepStrictEqual(info.units, ['test'], 'every loaded unit is reloaded'); + assert.ok(info.attempts >= 1, 'the payload reports how many attempts it took'); + + assert.strictEqual(client.api.test, unit, 'the unit object is reused, not replaced'); + assert.strictEqual(await client.api.test.hello(), 'hi', 'the api works again after a restart'); + assert.strictEqual(client.api.test.legacy, undefined, 'a method the server dropped stops being callable'); + + await client.api.test.notify(); + await timers.setTimeout(20); + assert.deepStrictEqual(events, [{ ping: true }], 'listeners registered before the drop still fire'); +}); + +test('reconnect: an explicit close is not a reconnect', async (t) => { + const { server, port } = await createServer(router()); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false }); + await client.load('test'); + + let reconnects = 0; + client.on('reconnect', () => void reconnects++); + const closed = new Promise((resolve) => client.once('close', resolve)); + client.close(); + await closed; + await timers.setTimeout(30); + + assert.strictEqual(client.active, false); + assert.strictEqual(reconnects, 0, 'a closed client does not reconnect on its own'); + + const reopened = new Promise((resolve) => client.once('open', resolve)); + await client.open(); + await reopened; + t.after(() => void client.close()); + await timers.setTimeout(20); + assert.strictEqual(reconnects, 0, 'reopening by hand is a fresh start, not a reconnect'); +}); + +// --------------------------------------------------------------------------- +// Heartbeat + +test('heartbeat: the server answers an app-level ping', async (t) => { + const { server, port } = await createServer(router()); + t.after(() => server.close()); + + const socket = new WebSocket(`ws://127.0.0.1:${port}/api`); + t.after(() => socket.close()); + await new Promise((resolve) => socket.addEventListener('open', resolve, { once: true })); + const answer = new Promise((resolve) => { + socket.addEventListener('message', ({ data }) => resolve(jsonParse(data)), { once: true }); + }); + socket.send(JSON.stringify({ type: 'ping' })); + assert.deepStrictEqual(await answer, { type: 'pong' }); +}); + +test('heartbeat: an unanswered ping forces a reconnect', async (t) => { + const httpServer = http.createServer(); + const wsServer = new WebsocketServer({ server: httpServer }); + const pings = []; + let answer = true; + wsServer.on('connection', (ws) => { + ws.on('message', (raw) => { + const packet = jsonParse(raw.toString()) || {}; + if (packet.type !== 'ping') return; + pings.push(packet); + if (answer) ws.send(JSON.stringify({ type: 'pong' })); + }); + }); + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)); + const { port } = httpServer.address(); + t.after(() => void httpServer.close()); + + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}`, { + heartbeat: { interval: 15, timeout: 60 }, + reconnect: false, + }); + t.after(() => void client.close()); + client.on('error', noop); + + await t.test('an answered heartbeat keeps the connection open', async () => { + await timers.setTimeout(80); + assert.ok(pings.length >= 2, `expected repeated pings, got ${pings.length}`); + assert.strictEqual(client.active, true); + }); + + await t.test('a silent peer trips the liveness timer and closes', async () => { + answer = false; + const timedOut = new Promise((resolve) => client.once('heartbeat-timeout', resolve)); + const closed = new Promise((resolve) => client.once('close', resolve)); + await timedOut; + await closed; + assert.strictEqual(client.active, false); + }); +}); + +test('heartbeat: the client answers a server-initiated ping', async (t) => { + const httpServer = http.createServer(); + const wsServer = new WebsocketServer({ server: httpServer }); + const packets = []; + let peer = null; + wsServer.on('connection', (ws) => { + peer = ws; + ws.on('message', (raw) => void packets.push(jsonParse(raw.toString()))); + }); + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)); + const { port } = httpServer.address(); + t.after(() => void httpServer.close()); + + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}`, { heartbeat: false, reconnect: false }); + t.after(() => void client.close()); + + await t.test('a ping is answered with a pong', async () => { + peer.send(JSON.stringify({ type: 'ping' })); + await waitFor(() => packets.length === 1); + assert.deepStrictEqual(packets[0], { type: 'pong' }); + }); + + await t.test('an unsolicited pong is ignored', async () => { + peer.send(JSON.stringify({ type: 'pong' })); + await timers.setTimeout(30); + assert.strictEqual(client.active, true, 'nothing was waiting on it, so nothing happens'); + assert.strictEqual(packets.length, 1); + }); +}); + +test('errors: a background failure with no listener is logged, not thrown', async (t) => { + const { server, port } = await createServer(router()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + reconnect: { minDelay: 5, maxDelay: 5, jitter: false, retries: 1 }, + heartbeat: false, + }); + t.after(() => void client.close()); + + const logged = []; + const { error } = globalThis.console; + globalThis.console.error = (...args) => void logged.push(args); + t.after(() => { + globalThis.console.error = error; + }); + + const exhausted = new Promise((resolve) => client.once('reconnect-failed', resolve)); + await server.close(); + await exhausted; + + assert.strictEqual(client.listenerCount('error'), 0, 'nothing is listening for errors'); + assert.ok(logged.length > 0, 'the connection failure reached the console instead of throwing'); +}); + +test('heartbeat: only a transport that can die silently gets one', () => { + const { ws: WsTransport, http: HttpTransport, event: EventTransport } = WrpcClient.transport; + assert.strictEqual(new WsTransport('ws://127.0.0.1:1').heartbeat, true); + assert.strictEqual(new HttpTransport('http://127.0.0.1:1').heartbeat, false); + assert.strictEqual(EventTransport.getInstance('http://127.0.0.1:1').heartbeat, false); +}); + +// --------------------------------------------------------------------------- +// Unhandled events + +test('events: one that reaches no listener surfaces as unhandled-event', async (t) => { + const { server, port } = await createServer(router()); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false }); + t.after(() => void client.close()); + await client.load('test'); + + await t.test('an event for a unit that was never loaded', async () => { + const unhandled = new Promise((resolve) => client.once('unhandled-event', resolve)); + await client.api.test.notify({ name: 'other/ping' }); + assert.deepStrictEqual(await unhandled, { name: 'other/ping', data: { ping: true } }); + }); + + await t.test('an event on a loaded unit nobody subscribed to', async () => { + const unhandled = new Promise((resolve) => client.once('unhandled-event', resolve)); + await client.api.test.notify({ name: 'test/ping' }); + assert.deepStrictEqual(await unhandled, { name: 'test/ping', data: { ping: true } }); + }); + + await t.test('a unit name from Object.prototype resolves to nothing', async () => { + // `api` is a plain object, so 'constructor' and 'toString' used to + // resolve up the prototype chain and be treated as loaded units. + for (const name of ['constructor/ping', 'toString/ping', '__proto__/ping', 'hasOwnProperty/ping']) { + const unhandled = new Promise((resolve) => client.once('unhandled-event', resolve)); + await client.api.test.notify({ name }); + assert.deepStrictEqual(await unhandled, { name, data: { ping: true } }); + } + assert.strictEqual(await client.api.test.hello(), 'hi', 'the client survived every one'); + }); + + await t.test('an event name with an empty event part is unhandled', async () => { + const unhandled = new Promise((resolve) => client.once('unhandled-event', resolve)); + await client.api.test.notify({ name: 'test/' }); + assert.deepStrictEqual(await unhandled, { name: 'test/', data: { ping: true } }); + }); + + await t.test('a listener takes precedence over unhandled-event', async () => { + let unhandled = 0; + client.on('unhandled-event', () => void unhandled++); + const received = new Promise((resolve) => client.api.test.once('ping', resolve)); + await client.api.test.notify({ name: 'test/ping' }); + assert.deepStrictEqual(await received, { ping: true }); + await timers.setTimeout(20); + assert.strictEqual(unhandled, 0); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 4: the connection lifecycle settles everything deterministically. + +test('in-flight calls are rejected with 503 the moment the connection dies', async (t) => { + const definition = router({ + slow: procedure({ access: 'public', handler: async () => timers.setTimeout(5000, 'late') }), + }); + const { server, port } = await createServer(definition); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + reconnect: false, + callTimeout: 30_000, + }); + t.after(() => void client.close()); + await client.load('test'); + + const started = Date.now(); + const pending = client.api.test.slow(); + await timers.setTimeout(20); + // The server terminates this peer: the pending call must settle NOW, not + // in 30 seconds. + for (const peer of server.clients) peer.destroy(); + for (const connection of server.wsServer.connections) connection.terminate(); + const error = await pending.then( + () => null, + (failure) => failure, + ); + assert.ok(error, 'the call must reject'); + assert.strictEqual(error.code, 503); + assert.match(error.message, /Connection closed/); + assert.ok(Date.now() - started < 5000, 'settled by the disconnect, not by the timeout'); +}); + +test('an explicit close() rejects in-flight calls too', async (t) => { + const definition = router({ + slow: procedure({ access: 'public', handler: async () => timers.setTimeout(5000, 'late') }), + }); + const { server, port } = await createServer(definition); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + reconnect: false, + callTimeout: 30_000, + }); + await client.load('test'); + const pending = client.api.test.slow(); + await timers.setTimeout(20); + client.close(); + await assert.rejects(pending, (error) => error.code === 503); +}); + +test('a failed load() on reconnect does not kill the subscriptions silently', async (t) => { + const state = { subscribes: 0 }; + const definition = defineRouter({ + feed: { + ticks: procedure.subscription({ + access: 'public', + handler: async function* (_context, _args, { signal }) { + state.subscribes++; + await timers.setTimeout(60_000, undefined, { signal }).catch(() => {}); + }, + }), + }, + }); + const { server, port } = await createServer(definition); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + reconnectTimeout: 20, + logger: false, + }); + t.after(() => void client.close()); + await client.load('feed'); + client.api.feed.ticks.subscribe({}, {}); + await waitFor(() => state.subscribes === 1, 'the subscription never opened'); + + // Force a reconnect. The re-subscribe must happen even though restore also + // reloads units — the coupling that used to kill subscriptions is gone. + for (const connection of server.wsServer.connections) connection.terminate(); + await waitFor(() => state.subscribes === 2, 'the subscription was not restored after the reconnect'); +}); + +// --------------------------------------------------------------------------- +// Backoff correctness when the failure happens AFTER the socket opened: the +// attempt counter is reset to 0 the moment 'open' fires, so a post-open +// failure (restore, and later authenticate) must put it back before it +// terminates — otherwise the cycle hammers minDelay forever. + +const sessionGatedBoot = async (t) => { + const definition = defineRouter({ + auth: { + login: procedure({ + access: 'public', + handler: async (context) => void context.client.startSession(undefined, { user: 'a' }), + }), + }, + secret: { + peek: procedure({ access: 'session', handler: async () => 'ok' }), + }, + }); + const server = new Server({ + router: definition, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 50 }, + introspection: 'session', + }); + await server.listen(); + t.after(() => server.close()); + return { server, port: server.address().port }; +}; + +test('restore: a failing load() grows the backoff and eventually exhausts retries', async (t) => { + const { server, port } = await sessionGatedBoot(t); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: { minDelay: 10, maxDelay: 1000, factor: 2, jitter: false, retries: 3 }, + }); + t.after(() => void client.close()); + // introspection: 'session' + a ws login (which sets no cookie) is exactly + // the scenario in which restore's load() fails on every new socket. + client.use({ auth: { login: { access: 'public' } } }); + await client.api.auth.login(); + await client.load('secret'); + assert.strictEqual(await client.api.secret.peek(), 'ok'); + + const delays = []; + let failures = 0; + client.on('reconnecting', ({ delay }) => void delays.push(delay)); + client.on('restore-failed', () => void failures++); + const exhausted = new Promise((resolve) => client.on('reconnect-failed', resolve)); + + for (const connection of server.wsServer.connections) connection.terminate(); + + const result = await exhausted; + // Before the fix the counter reset to 0 on every open, so the schedule was + // [10, 10, 10, ...] forever and 'reconnect-failed' never fired at all. + assert.deepStrictEqual(delays, [10, 20, 40]); + assert.strictEqual(failures, 3); + assert.strictEqual(result.attempts, 3); +}); + +test('restore-failed: a throwing listener is escalated, not an unhandled rejection', async (t) => { + const { server, port } = await sessionGatedBoot(t); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: { minDelay: 10, maxDelay: 20, factor: 2, jitter: false, retries: 1 }, + }); + t.after(() => void client.close()); + client.use({ auth: { login: { access: 'public' } } }); + await client.api.auth.login(); + await client.load('secret'); + + const escalated = []; + client.on('error', (error) => void escalated.push(error.message)); + client.on('restore-failed', () => { + throw new Error('listener boom'); + }); + const exhausted = new Promise((resolve) => client.on('reconnect-failed', resolve)); + for (const connection of server.wsServer.connections) connection.terminate(); + await exhausted; + assert.ok(escalated.includes('listener boom')); +}); + +test('close({ drain }) lets in-flight calls finish and refuses new ones with 503', async (t) => { + const definition = router({ + slow: procedure({ access: 'public', handler: async () => timers.setTimeout(150, 'done') }), + }); + const { server, port } = await createServer(definition); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + reconnect: false, + }); + t.after(() => void client.close()); + await client.load('test'); + + const inFlight = client.api.test.slow(); + await timers.setTimeout(20); + const closing = server.close({ drain: 2000 }); + await timers.setTimeout(20); + // The drain window refuses NEW work... + await assert.rejects(client.api.test.hello(), (error) => error.code === 503); + // ...but finishes what it started. + assert.strictEqual(await inFlight, 'done'); + await closing; +}); + +test('the queue is abort-aware and the timeout covers the queue wait', async (t) => { + const definition = router({ + queued: procedure({ + access: 'public', + timeout: 120, + queue: { concurrency: 1, size: 10 }, + handler: async () => timers.setTimeout(80, 'ran'), + }), + }); + const { server, port } = await createServer(definition); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false, reconnect: false }); + t.after(() => void client.close()); + await client.load('test'); + + // First occupies the slot (~80ms); second waits ~80ms in the queue and + // has ~40ms of budget left — its handler alone needs 80 more, so the + // DEADLINE (not a fresh per-handler timeout) must fail it with 408. + const first = client.api.test.queued(); + const second = client.api.test.queued(); + assert.strictEqual(await first, 'ran'); + await assert.rejects(second, (error) => error.code === 408); +}); + +test('an HTTP transport failure settles the exact calls it carried', async (t) => { + // A server that answers 502 with an HTML body — the proxy failure shape + // wrpc packets never travel in. Without the synthesized answers the + // introspect call under load() would hang out its whole callTimeout. + const proxy = http.createServer((req, res) => { + res.writeHead(502, { 'Content-Type': 'text/html' }); + res.end('Bad Gateway'); + }); + await new Promise((resolve) => proxy.listen(0, '127.0.0.1', resolve)); + t.after(() => proxy.close()); + const { port } = proxy.address(); + + const client = await WrpcClient.connect(`http://127.0.0.1:${port}/api`, { + reconnect: false, + callTimeout: 30_000, + }); + t.after(() => void client.close()); + const started = Date.now(); + await assert.rejects(client.load('test'), (error) => error.code === 502); + assert.ok(Date.now() - started < 5000, 'settled by the synthesized answer, not the timeout'); +}); + +// --------------------------------------------------------------------------- +// Transport fallback: `transport: [a, b, ...]` — candidates in order, +// retries per candidate, loud capability loss. + +const { Emitter } = require('../src/utils.js'); + +// A fake client transport on the ClientTransport contract, registered under +// a throwaway name (the pattern the heartbeat test above set). `kill()` +// makes every further open() reject the way a dead socket does — an error +// plus a 'close' on the next microtask. +const registerFake = (name, { persistent = true } = {}) => { + const instances = []; + class Fake extends Emitter { + constructor(url) { + super(); + this.url = url; + this.persistent = persistent; + this.heartbeat = false; + this.active = false; + this.dead = false; + instances.push(this); + } + + async open(options = {}) { + this.lastOpen = options; // what the client resolved for THIS open + if (this.dead) { + queueMicrotask(() => this.emit('close')); + throw new Error(`open failed (${this.url})`); + } + this.active = true; + this.emit('open'); + } + + kill() { + this.dead = true; + this.active = false; + this.emit('close'); + } + + write() { + return true; + } + + send() { + return true; + } + + close() { + this.active = false; + this.emit('close'); + } + + terminate() { + // The base ClientTransport contract: terminate reports the close, so + // the reconnect cycle a post-open failure forces can continue. + this.close(); + } + + online() {} + + offline() {} + } + WrpcClient.transport[name] = Fake; + return { instances, teardown: () => delete WrpcClient.transport[name] }; +}; + +test('fallback: the candidate list is validated up front', async () => { + await assert.rejects(WrpcClient.connect('ws://x/api', { transport: [] }), /must not be empty/); + await assert.rejects(WrpcClient.connect('ws://x/api', { transport: ['ws', 'nope'] }), /Unknown transport 'nope'/); + await assert.rejects(WrpcClient.connect('ws://x/api', { transport: ['ws', 'event'] }), /cannot contain 'event'/); +}); + +test('fallback: a dying candidate hands over to the next; the old one goes inert', async (t) => { + const first = registerFake('fake-a'); + const second = registerFake('fake-b'); + t.after(() => { + first.teardown(); + second.teardown(); + }); + + const client = await WrpcClient.connect('http://host/api', { + transport: ['fake-a', 'fake-b'], + reconnect: { retries: 2, minDelay: 1, maxDelay: 1, jitter: false }, + heartbeat: false, + }); + t.after(() => void client.close()); + + const fallbacks = []; + client.on('transport-fallback', (info) => fallbacks.push(info)); + const failed = []; + client.on('reconnect-failed', (info) => failed.push(info)); + + const a = first.instances[0]; + a.kill(); + + await waitFor(() => second.instances.length > 0 && second.instances[0].active, 'fake-b never took over'); + assert.deepStrictEqual(fallbacks, [{ from: 'fake-a', to: 'fake-b' }]); + assert.strictEqual(failed.length, 0, 'reconnect-failed must wait for the LAST candidate'); + assert.strictEqual(client.active, true); + // Retries reset per candidate: the swap arrived with a fresh counter. + assert.strictEqual(client.attempt, 0); + + // A late 'close' from the abandoned transport must not disturb the live + // candidate — its listeners came off at the swap. + a.emit('close'); + await timers.setTimeout(10); + assert.strictEqual(client.active, true); +}); + +test('fallback: only the LAST candidate exhausting emits reconnect-failed', async (t) => { + const first = registerFake('fake-a'); + const second = registerFake('fake-b'); + t.after(() => { + first.teardown(); + second.teardown(); + }); + + const client = await WrpcClient.connect('http://host/api', { + transport: ['fake-a', 'fake-b'], + reconnect: { retries: 1, minDelay: 1, maxDelay: 1, jitter: false }, + heartbeat: false, + }); + t.after(() => void client.close()); + + const fallbacks = []; + client.on('transport-fallback', (info) => fallbacks.push(info)); + const exhausted = new Promise((resolve) => client.once('reconnect-failed', resolve)); + + first.instances[0].kill(); + await waitFor(() => second.instances.length > 0, 'fake-b never constructed'); + // The second candidate is dead on arrival too. + second.instances[0].dead = true; + second.instances[0].close(); + await exhausted; + assert.strictEqual(fallbacks.length, 1, 'no wrap-around: one pass over the list'); +}); + +test('fallback: the URL is re-spelled per candidate scheme', async (t) => { + const first = registerFake('fake-a'); + t.after(() => first.teardown()); + const client = await WrpcClient.connect('wss://host/api', { + transport: ['fake-a', 'http'], + reconnect: { retries: 0 }, + heartbeat: false, + }); + t.after(() => void client.close()); + // The fake saw the ws spelling (non-ws candidates get http/https). + assert.strictEqual(first.instances[0].url, 'https://host/api'); +}); + +test('fallback: landing on a non-persistent transport fails live subscriptions loudly', async (t) => { + const feed = procedure.subscription({ + access: 'public', + handler: async function* (_context, _args, { signal }) { + yield 1; + await new Promise((resolve) => { + signal.addEventListener('abort', resolve, { once: true }); + }); + }, + }); + const { server, port } = await createServer(router({ feed })); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + transport: ['ws', 'http'], + reconnect: { retries: 0, minDelay: 1, maxDelay: 1, jitter: false }, + heartbeat: false, + }); + t.after(() => void client.close()); + await client.load('test'); + + const seen = []; + const errors = []; + client.api.test.feed.subscribe({}, { onData: (value) => seen.push(value), onError: (error) => errors.push(error) }); + await waitFor(() => seen.length > 0, 'the feed never produced'); + + const fallbacks = []; + client.on('transport-fallback', (info) => fallbacks.push(info)); + await server.close(); + + await waitFor(() => errors.length > 0, 'the subscription never failed'); + assert.strictEqual(errors[0].code, 400); + assert.match(errors[0].message, /cannot carry subscriptions/); + assert.deepStrictEqual(fallbacks, [{ from: 'ws', to: 'http' }]); + client.close(); +}); + +// --------------------------------------------------------------------------- +// The authenticate hook: awaited inside open() on the first connect, and on +// every reconnect BEFORE the subscriptions are re-opened and units reloaded. + +const authBoot = async (t, { introspection = 'session' } = {}) => { + const order = []; + const state = { subscribes: 0, logins: 0 }; + const definition = defineRouter({ + auth: { + login: procedure({ + access: 'public', + handler: async (context) => { + state.logins++; + order.push('login'); + context.client.startSession(undefined, { user: 'ann' }); + }, + }), + }, + feed: { + ticks: procedure.subscription({ + access: 'session', + handler: async function* (_context, _args, { signal }) { + state.subscribes++; + order.push('subscribe'); + await timers.setTimeout(60_000, undefined, { signal }).catch(() => {}); + }, + }), + }, + }); + const server = new Server({ + router: definition, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 50 }, + introspection, + }); + await server.listen(); + t.after(() => server.close()); + return { server, port: server.address().port, order, state }; +}; + +test('authenticate: awaited on first connect, and before restore on reconnect', async (t) => { + const { server, port, order, state } = await authBoot(t); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnectTimeout: 10, + authenticate: (c) => c.call('auth/login'), + }); + t.after(() => void client.close()); + // connect() resolved authenticated: a session-gated load works right away + // even though the server's introspection itself requires a session. + assert.strictEqual(state.logins, 1); + await client.load('feed'); + client.api.feed.ticks.subscribe({}, {}); + await waitFor(() => state.subscribes === 1, 'the subscription never opened'); + + // Force a reconnect. The DECISIVE assertion is the arrival order on the + // new socket: login (the credential) must precede the re-subscribe, which + // an 'open' listener could never guarantee — and the session-gated + // subscription resuming at all proves the credential arrived first. + for (const connection of server.wsServer.connections) connection.terminate(); + await waitFor(() => state.subscribes === 2, 'the subscription was not restored after the reconnect'); + assert.deepStrictEqual(order, ['login', 'subscribe', 'login', 'subscribe']); + assert.strictEqual(state.logins, 2); +}); + +test('authenticate: the hook receives { reconnected, attempts }', async (t) => { + const { server, port } = await authBoot(t, { introspection: true }); + const infos = []; + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnectTimeout: 10, + authenticate: (c, info) => void infos.push(info), + }); + t.after(() => void client.close()); + assert.deepStrictEqual(infos, [{ reconnected: false, attempts: 0 }]); + for (const connection of server.wsServer.connections) connection.terminate(); + await waitFor(() => infos.length === 2, 'the hook never re-ran on reconnect'); + assert.strictEqual(infos[1].reconnected, true); + assert.ok(infos[1].attempts >= 1); +}); + +test('authenticate: a failing hook walks the backoff and emits authenticate-failed', async (t) => { + const { server, port } = await authBoot(t, { introspection: true }); + let allow = true; + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: { minDelay: 10, maxDelay: 1000, factor: 2, jitter: false, retries: 3 }, + authenticate: () => { + if (!allow) throw new Error('credential rejected'); + }, + }); + t.after(() => void client.close()); + + const delays = []; + const failed = []; + client.on('reconnecting', ({ delay }) => void delays.push(delay)); + client.on('authenticate-failed', (info) => void failed.push(info)); + const exhausted = new Promise((resolve) => client.on('reconnect-failed', resolve)); + + allow = false; + for (const connection of server.wsServer.connections) connection.terminate(); + await exhausted; + // The same growth lock as the restore path: without the attempt-count + // restoration this would be [10, 10, 10, ...] forever. + assert.deepStrictEqual(delays, [10, 20, 40]); + assert.strictEqual(failed.length, 3); + assert.strictEqual(failed[0].error.message, 'credential rejected'); + assert.strictEqual(failed[0].reconnected, true); + assert.ok(failed[0].attempts >= 1); +}); + +test('authenticate: a first-connect failure rejects connect() and leaves no zombie', async (t) => { + const { port } = await authBoot(t, { introspection: true }); + const before = WrpcClient.connections.size; + await assert.rejects( + WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnectTimeout: 10, + authenticate: () => { + throw new Error('bad password'); + }, + }), + (error) => error.message === 'bad password', + ); + // open() had registered the client before the transport was reached; the + // rejection must not leave it behind for WrpcClient.online() to revive. + assert.strictEqual(WrpcClient.connections.size, before); +}); + +test('authenticate: a hook that closes the client stops the retry cycle', async (t) => { + const { server, port } = await authBoot(t, { introspection: true }); + let fatal = false; + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: { minDelay: 5, maxDelay: 10, factor: 2, jitter: false, retries: 10 }, + authenticate: (c) => { + if (!fatal) return; + c.close(); // the documented "a bad password won't fix itself" escape + throw new Error('give up'); + }, + }); + const reconnecting = []; + client.on('reconnecting', (info) => void reconnecting.push(info)); + fatal = true; + for (const connection of server.wsServer.connections) connection.terminate(); + await waitFor(() => !WrpcClient.connections.has(client), 'close() inside the hook never took effect'); + const settled = reconnecting.length; + await timers.setTimeout(60); + assert.strictEqual(reconnecting.length, settled, 'the cycle kept scheduling after close()'); +}); + +test('authenticate: exhausted retries fall through to the next transport candidate', async (t) => { + const a = registerFake('authfba'); + const b = registerFake('authfbb'); + t.after(() => { + a.teardown(); + b.teardown(); + }); + let calls = 0; + const client = await WrpcClient.connect('fake://x', { + transport: ['authfba', 'authfbb'], + heartbeat: false, + logger: false, + reconnect: { minDelay: 5, maxDelay: 10, factor: 2, jitter: false, retries: 1 }, + authenticate: () => { + calls++; + if (calls === 1) return; // the first connect succeeds on candidate A + if (b.instances.some((i) => i.active)) return; // candidate B accepts + throw new Error('still refused on A'); + }, + }); + t.after(() => void client.close()); + const fallbacks = []; + client.on('transport-fallback', (info) => void fallbacks.push(info)); + // close(), not kill(): candidate A must keep OPENING successfully so the + // failure that exhausts its retries is the authenticate hook, not the socket. + a.instances.at(-1).close(); + await waitFor(() => b.instances.some((i) => i.active), 'the fallback candidate never opened'); + assert.deepStrictEqual(fallbacks, [{ from: 'authfba', to: 'authfbb' }]); + assert.ok(calls >= 3, 'the hook must have been retried on A and re-run on B'); +}); + +test('no authenticate hook: open fires before reconnect, restore unchanged', async (t) => { + const definition = router(); + const { server, port } = await createServer(definition); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnectTimeout: 10, + }); + t.after(() => void client.close()); + await client.load('test'); + const events = []; + client.on('open', () => void events.push('open')); + client.on('reconnect', () => void events.push('reconnect')); + for (const connection of server.wsServer.connections) connection.terminate(); + await waitFor(() => events.includes('reconnect'), 'never reconnected'); + assert.deepStrictEqual(events, ['open', 'reconnect']); +}); + +// --------------------------------------------------------------------------- +// The refresh hook: single-flight, one-shot retry, original error surfaces. + +const refreshBoot = async (t) => { + const state = { ok: false, ids: [], hits: 0 }; + const definition = defineRouter({ + flaky: { + get: procedure({ + access: 'public', + handler: async (context) => { + state.hits++; + state.ids.push(context.uuid); + if (!state.ok) { + const error = new Error('expired'); + error.code = 401; + error.expose = true; + throw error; + } + return 'fresh'; + }, + }), + teapot: procedure({ + access: 'public', + handler: async () => { + const error = new Error('teapot'); + error.code = 418; + error.expose = true; + throw error; + }, + }), + }, + }); + const { server, port } = await createServer(definition); + t.after(() => server.close()); + return { server, port, state }; +}; + +// A transport the test drives by hand. `sent` is every packet the client +// wrote (it writes synchronously, so they are all there the moment the calls +// are issued) and `deliver` feeds answers back. +// +// The single-flight tests below use it because the property they assert — +// refusals arriving while a run is open all join that one run — is a client +// invariant with a precondition no wire can guarantee. Over a real socket the +// refusals are both what STARTS the run and what has to arrive during it, so +// any delivery spread eats the window: emission can be forced simultaneous, +// but a loaded Linux box may still split one write burst across reads where +// loopback here coalesces it. Every clock-based window tried against this +// raced. Here the test delivers the refusals and holds the run open itself, +// so the precondition is established rather than hoped for. The real-socket +// refresh path stays covered by the server-backed tests around these. +const registerScripted = (name) => { + const sent = []; + let live = null; + class Scripted extends Emitter { + constructor(url) { + super(); + this.url = url; + this.persistent = true; + this.heartbeat = false; + this.active = false; + live = this; + } + + async open() { + this.active = true; + this.emit('open'); + } + + write(packet) { + sent.push(packet); + return true; + } + + send(packet) { + return this.write(packet); + } + + close() { + this.active = false; + this.emit('close'); + } + + terminate() { + this.close(); + } + + online() {} + + offline() {} + } + WrpcClient.transport[name] = Scripted; + return { + sent, + calls: () => sent.filter((packet) => packet.type === 'call'), + deliver: (packet) => void live.emit('message', JSON.stringify(packet)), + refuse: (packet, code = 401) => + void live.emit( + 'message', + JSON.stringify({ type: 'callback', id: packet.id, error: { message: 'expired', code } }), + ), + teardown: () => delete WrpcClient.transport[name], + }; +}; + +test('refresh: a 401 is refreshed once and the call retried with a fresh packet', async (t) => { + const { port, state } = await refreshBoot(t); + let refreshes = 0; + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: false, + refresh: () => { + refreshes++; + state.ok = true; + }, + }); + t.after(() => void client.close()); + await client.load('flaky'); + assert.strictEqual(await client.api.flaky.get(), 'fresh'); + assert.strictEqual(refreshes, 1); + // Two server-side invocations — the refusal and the retry — each its own + // packet (context uuids differ), never a re-send of the same id. + assert.strictEqual(state.hits, 2); + assert.notStrictEqual(state.ids[0], state.ids[1]); +}); + +test('refresh: ten concurrent 401s produce exactly one refresh', async (t) => { + const wire = registerScripted('scripted-join'); + t.after(wire.teardown); + let refreshes = 0; + let release; + const held = new Promise((resolve) => (release = resolve)); + const client = await WrpcClient.connect('ws://scripted/api', { + transport: 'scripted-join', + heartbeat: false, + logger: false, + reconnect: false, + refresh: async () => { + refreshes++; + await held; + }, + }); + t.after(() => void client.close()); + + // Issued before any run exists, so every one of them is allowed to join + // (a call issued DURING a run is the deadlock guard's case instead). + const calls = Array.from({ length: 10 }, () => client.call('flaky/get')); + const issued = wire.calls(); + assert.strictEqual(issued.length, 10, 'the client writes synchronously: all ten are on the wire'); + + // Refused in one turn, and the run cannot end until this test says so — + // the window is a fact here, not a race. + for (const packet of issued) wire.refuse(packet); + await waitFor(() => refreshes === 1, 'the refresh never ran'); + release(); + + // Each joined caller re-issues exactly once, under a fresh packet id. + await waitFor(() => wire.calls().length === 20, 'the refused calls never re-issued'); + const retries = wire.calls().slice(10); + assert.strictEqual(new Set(retries.map((packet) => packet.id)).size, 10, 'each retry has its own id'); + for (const packet of retries) wire.deliver({ type: 'callback', id: packet.id, result: 'fresh' }); + + assert.deepStrictEqual(await Promise.all(calls), new Array(10).fill('fresh')); + assert.strictEqual(refreshes, 1); +}); + +test('refresh: a failing refresh surfaces the ORIGINAL error, and retries never loop', async (t) => { + const { port, state } = await refreshBoot(t); + let refreshes = 0; + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: false, + refresh: () => { + refreshes++; + throw new Error('refresh broke'); + }, + }); + t.after(() => void client.close()); + await client.load('flaky'); + await assert.rejects(client.api.flaky.get(), (error) => error.code === 401 && error.message === 'expired'); + assert.strictEqual(refreshes, 1); + + // A refresh that "succeeds" without fixing anything: the retry's second + // 401 surfaces as-is instead of triggering another refresh. + const stubborn = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: false, + refresh: () => void refreshes++, + }); + t.after(() => void stubborn.close()); + refreshes = 0; + state.ok = false; + await stubborn.load('flaky'); + await assert.rejects(stubborn.api.flaky.get(), (error) => error.code === 401); + assert.strictEqual(refreshes, 1); +}); + +test('refresh: codes outside `on` do not trigger it, and `on` widens the trigger', async (t) => { + const { port, state } = await refreshBoot(t); + let refreshes = 0; + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: false, + refresh: { on: [401, 403], handler: () => void refreshes++ }, + }); + t.after(() => void client.close()); + await client.load('flaky'); + await assert.rejects(client.api.flaky.teapot(), (error) => error.code === 418); + assert.strictEqual(refreshes, 0); + state.ok = true; + assert.strictEqual(await client.api.flaky.get(), 'fresh'); +}); + +test('refresh: never fires for calls made inside the authenticate hook', async (t) => { + const { port } = await refreshBoot(t); + let refreshes = 0; + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: false, + authenticate: async (c) => { + // A 401 inside the hook is the hook's to handle — a refresh here + // would recurse into the very flow that is authenticating. + await c.call('flaky/get').catch(() => {}); + }, + refresh: () => void refreshes++, + }); + t.after(() => void client.close()); + assert.strictEqual(refreshes, 0); +}); + +test('headers: the function form is re-evaluated on every open', async (t) => { + const fake = registerFake('hdrfake'); + t.after(() => fake.teardown()); + let version = 0; + const client = await WrpcClient.connect('fake://x', { + transport: ['hdrfake'], + heartbeat: false, + logger: false, + reconnect: { minDelay: 5, maxDelay: 10, factor: 2, jitter: false, retries: 3 }, + headers: () => ({ 'x-app-version': String(++version) }), + }); + t.after(() => void client.close()); + const transport = fake.instances.at(-1); + assert.deepStrictEqual(transport.lastOpen.headers, { 'x-app-version': '1' }); + // The reconnect resolves the function AGAIN — a rotated value is presented, + // not the one frozen at construction. + transport.close(); + await waitFor(() => transport.lastOpen.headers?.['x-app-version'] === '2', 'the reconnect kept the stale headers'); +}); + +test('meta: the connection-phase option rides open() alongside headers', async (t) => { + const fake = registerFake('metafake'); + t.after(() => fake.teardown()); + let n = 0; + const client = await WrpcClient.connect('fake://x', { + transport: ['metafake'], + heartbeat: false, + logger: false, + reconnect: { minDelay: 5, maxDelay: 10, factor: 2, jitter: false, retries: 3 }, + meta: () => ({ v: String(++n) }), + }); + t.after(() => void client.close()); + const transport = fake.instances.at(-1); + assert.deepStrictEqual(transport.lastOpen.meta, { v: '1' }); + transport.close(); + await waitFor(() => transport.lastOpen.meta?.v === '2', 'the reconnect kept the stale meta'); +}); + +test('headers: an invalid option throws loudly at construction', async () => { + const WsTransport = WrpcClient.transport.ws; + assert.throws(() => new WrpcClient('ws://x', new WsTransport('ws://x'), { headers: ['nope'] }), /options\.headers/); +}); + +test('declared: keys are kebab-normalized before any transport sees them', async (t) => { + const fake = registerFake('kebabfake'); + t.after(() => fake.teardown()); + const client = await WrpcClient.connect('fake://x', { + transport: ['kebabfake'], + heartbeat: false, + logger: false, + headers: { xAppVersion: '2', 'x-already': 'kebab', numeric: 7, nested: { a: 1 }, missing: null }, + meta: { userId: 1, traceId: 'abc' }, + }); + t.after(() => void client.close()); + const transport = fake.instances.at(-1); + + // One normalization point, upstream of every carrier — which is why ws and + // the worker transport need no code of their own. + assert.deepStrictEqual(transport.lastOpen.headers, { + 'x-app-version': '2', + 'x-already': 'kebab', + numeric: '7', // stringified: over ws this used to be dropped outright + nested: '{"a":1}', + // `missing: null` is absent — a header cannot say "absent" + }); + assert.strictEqual(Object.hasOwn(transport.lastOpen.headers, 'missing'), false); + + // Meta keeps its JSON types in the default carrier: only a header carrier + // has to flatten, and flattening here would lose information for nothing. + assert.deepStrictEqual(transport.lastOpen.meta, { 'user-id': 1, 'trace-id': 'abc' }); +}); + +test('declared: an unsendable header value is dropped with a warning, not fatal', async (t) => { + const fake = registerFake('warnfake'); + t.after(() => fake.teardown()); + const warnings = []; + const client = await WrpcClient.connect('fake://x', { + transport: ['warnfake'], + heartbeat: false, + logger: { warn: (entry) => warnings.push(entry), info: () => {}, error: () => {}, debug: () => {}, log: () => {} }, + headers: { userName: 'José', ok: 'plain' }, + }); + t.after(() => void client.close()); + const transport = fake.instances.at(-1); + assert.deepStrictEqual(transport.lastOpen.headers, { ok: 'plain' }); + assert.ok( + warnings.some((entry) => entry.event === 'declared.unsendable' && entry.key === 'user-name'), + 'the drop must be visible on the side that can fix it', + ); +}); + +test("metaFormat: 'prefixed' flattens values on EVERY transport, not just the header legs", async (t) => { + const fake = registerFake('pfxfake'); + t.after(() => fake.teardown()); + const client = await WrpcClient.connect('fake://x', { + transport: ['pfxfake'], + heartbeat: false, + logger: false, + metaFormat: 'prefixed', + meta: { userId: 7, isRetry: true, nested: { a: 1 }, missing: null, plain: 'x' }, + }); + t.after(() => void client.close()); + const transport = fake.instances.at(-1); + + // The guarantee is about the bag the SERVER observes, not the wire: ws + // still carries one JSON query parameter, but its values are already + // flattened, so switching transports cannot change the shape. + assert.deepStrictEqual(transport.lastOpen.meta, { + 'user-id': '7', + 'is-retry': 'true', + nested: '{"a":1}', + plain: 'x', + }); + assert.strictEqual(transport.lastOpen.metaPrefixed, true); +}); + +test('metaFormat: an invalid value throws loudly at construction', async () => { + const WsTransport = WrpcClient.transport.ws; + assert.throws( + () => new WrpcClient('ws://x', new WsTransport('ws://x'), { metaFormat: 'nope' }), + /options\.metaFormat/, + ); + // The default is untouched by the option's absence. + const fine = new WrpcClient('ws://x', new WsTransport('ws://x'), { meta: { a: 1 } }); + assert.ok(fine); +}); + +// --------------------------------------------------------------------------- +// The reconnect/refresh edges: refresh re-entry, subscription refresh, +// backoff stability, connect timeout, and the coded settlement of calls a +// dead transport strands. + +const net = require('node:net'); +const { ClientTransport } = require('../src/client.js'); + +test('refresh: a call made BY the refresh handler surfaces its refusal instead of deadlocking', async (t) => { + const { port, state } = await refreshBoot(t); + let inner = null; + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: false, + refresh: async (c) => { + // The handler's own call is refused too (state.ok is still false). + // Before the joined-run guard this awaited the very promise that was + // awaiting it — a permanent, client-wide wedge with no timer left to + // break it. + inner = await c.call('flaky/get', {}).then( + () => 'resolved', + (error) => error.code, + ); + state.ok = true; + }, + }); + t.after(() => void client.close()); + await client.load('flaky'); + assert.strictEqual(await client.api.flaky.get(), 'fresh'); + assert.strictEqual(inner, 401, "the handler's own call must surface its 401, not join the refresh"); +}); + +const subscriptionRefreshBoot = async (t) => { + const state = { ok: false, subscribes: 0 }; + const definition = defineRouter({ + feed: { + ticks: procedure.subscription({ + access: 'public', + handler: async function* (_context, _args, { signal }) { + state.subscribes++; + if (!state.ok) { + const error = new Error('expired'); + error.code = 401; + error.expose = true; + throw error; + } + yield 'tick'; + await timers.setTimeout(60_000, undefined, { signal }).catch(() => {}); + }, + }), + }, + }); + const { server, port } = await createServer(definition); + t.after(() => server.close()); + return { server, port, state }; +}; + +test('refresh: a refused subscribe runs the refresh and re-opens the feed', async (t) => { + const { port, state } = await subscriptionRefreshBoot(t); + let refreshes = 0; + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: false, + refresh: () => { + refreshes++; + state.ok = true; + }, + }); + t.after(() => void client.close()); + await client.load('feed'); + const values = []; + const errors = []; + client.api.feed.ticks.subscribe({}, { onData: (v) => void values.push(v), onError: (e) => void errors.push(e) }); + // The refused subscribe runs the single-flight refresh and re-opens: the + // feed delivers instead of dying with a terminal 401 while plain calls + // heal — the silent half-dead client this path used to produce. + await waitFor(() => values.length === 1, 'the feed never delivered after the refresh'); + assert.deepStrictEqual(values, ['tick']); + assert.strictEqual(refreshes, 1); + assert.strictEqual(state.subscribes, 2); + assert.deepStrictEqual(errors, []); +}); + +test('refresh: a subscribe refused AGAIN after the refresh is terminal — no loop', async (t) => { + const { port, state } = await subscriptionRefreshBoot(t); + let refreshes = 0; + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: false, + // "Succeeds" without fixing anything: the retry's refusal must surface. + refresh: () => void refreshes++, + }); + t.after(() => void client.close()); + await client.load('feed'); + const errors = []; + client.api.feed.ticks.subscribe({}, { onData: noop, onError: (e) => void errors.push(e) }); + await waitFor(() => errors.length === 1, 'the second refusal never surfaced'); + assert.strictEqual(errors[0].code, 401); + assert.strictEqual(refreshes, 1); + assert.strictEqual(state.subscribes, 2); + await timers.setTimeout(30); + assert.strictEqual(state.subscribes, 2, 'a terminal refusal must not keep re-subscribing'); +}); + +test('reconnect: an accept-then-drop peer climbs the backoff instead of pinning at minDelay', async (t) => { + const fake = registerFake('acceptdrop'); + t.after(fake.teardown); + const client = await WrpcClient.connect('fake://x', { + transport: ['acceptdrop'], + heartbeat: false, + logger: false, + reconnect: { minDelay: 10, maxDelay: 1000, factor: 2, jitter: false, retries: 3 }, + }); + t.after(() => void client.close()); + const transport = fake.instances.at(-1); + const delays = []; + client.on('reconnecting', ({ delay }) => void delays.push(delay)); + const exhausted = new Promise((resolve) => client.on('reconnect-failed', resolve)); + // From now on every open is accepted and immediately dropped by the peer + // — the shape of a server shedding load mid-restart. + const accept = transport.open.bind(transport); + transport.open = async (options) => { + await accept(options); + queueMicrotask(() => transport.close()); + }; + transport.close(); + await exhausted; + // Before the stability window this read [10, 10, 10, ...] forever: every + // TCP open re-zeroed the counter, retries never exhausted and + // 'reconnect-failed' never fired. + assert.deepStrictEqual(delays, [10, 20, 40]); +}); + +test('connectTimeout: a handshake that never answers rejects with a coded 408', async (t) => { + // A TCP listener that accepts and never speaks: the WebSocket handshake + // neither opens nor errors, which used to park the ladder forever. + const black = net.createServer(() => {}); + await new Promise((resolve) => black.listen(0, '127.0.0.1', resolve)); + t.after(() => black.close()); + const { port } = black.address(); + const before = WrpcClient.connections.size; + const started = Date.now(); + await assert.rejects( + WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: false, + connectTimeout: 50, + }), + (error) => error.code === 408 && /Connect timeout/.test(error.message), + ); + assert.ok(Date.now() - started < 5000, 'settled by connectTimeout, not a transport default'); + assert.strictEqual(WrpcClient.connections.size, before, 'the failed connect must not leave a zombie'); +}); + +test('batching: a call flushed onto a dead transport settles with a coded 503, not callTimeout', async (t) => { + const { server, port } = await createServer(router()); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: false, + batch: true, + callTimeout: 30_000, + }); + t.after(() => void client.close()); + client.on('error', noop); + await client.load('test'); + for (const connection of server.wsServer.connections) connection.terminate(); + await waitFor(() => !client.active, 'the drop never reached the client'); + const started = Date.now(); + // The frame was spliced out of #pending before write threw, so neither + // #failCalls nor a 'close' could see these ids — the flush itself settles. + await assert.rejects(client.api.test.hello({}), (error) => error.code === 503); + assert.ok(Date.now() - started < 5000, 'settled by the flush failure, not the timeout'); +}); + +test('a call issued while the transport is down rejects with a coded 503', async (t) => { + const { server, port } = await createServer(router()); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: false, + callTimeout: 30_000, + }); + t.after(() => void client.close()); + await client.load('test'); + for (const connection of server.wsServer.connections) connection.terminate(); + await waitFor(() => !client.active, 'the drop never reached the client'); + const started = Date.now(); + await assert.rejects(client.api.test.hello({}), (error) => error.code === 503 && /Not connected/.test(error.message)); + assert.ok(Date.now() - started < 5000, 'rejected on write, not at the timeout'); +}); + +test('failPackets: only call packets earn synthesized answers', () => { + const transport = new ClientTransport('x'); + const seen = []; + transport.on('message', (text) => void seen.push(jsonParse(text))); + const frame = JSON.stringify([{ type: 'call', id: 'a' }, { type: 'subscribe', id: 's' }, { type: 'ping' }]); + transport.failPackets(frame, 502); + // The subscribe is NOT answered: its terminal signal is an `end` packet, + // and a synthesized callback for it would throw in #settle as stale. + assert.deepStrictEqual(seen, [ + [{ type: 'callback', id: 'a', error: { message: 'HTTP request failed (502)', code: 502 } }], + ]); +}); + +test('heartbeat-timeout: a throwing listener surfaces through error, never as an unhandled rejection', async (t) => { + const httpServer = http.createServer(); + const wsServer = new WebsocketServer({ server: httpServer }); + let answer = true; + wsServer.on('connection', (ws) => { + ws.on('message', (raw) => { + const packet = jsonParse(raw.toString()) || {}; + if (packet.type === 'ping' && answer) ws.send(JSON.stringify({ type: 'pong' })); + }); + }); + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)); + const { port } = httpServer.address(); + t.after(() => void httpServer.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}`, { + heartbeat: { interval: 15, timeout: 30 }, + logger: false, + reconnect: false, + }); + t.after(() => void client.close()); + const escalated = new Promise((resolve) => client.on('error', resolve)); + client.on('heartbeat-timeout', () => { + throw new Error('listener exploded'); + }); + answer = false; + const error = await escalated; + assert.strictEqual(error.message, 'listener exploded'); +}); + +test('refresh: a failing run logs and emits refresh-failed, once for all joined callers', async (t) => { + const wire = registerScripted('scripted-failing'); + t.after(wire.teardown); + const failures = []; + let started = 0; + let release; + const held = new Promise((resolve) => (release = resolve)); + const client = await WrpcClient.connect('ws://scripted/api', { + transport: 'scripted-failing', + heartbeat: false, + logger: false, + reconnect: false, + // Held open until this test releases it, so both refusals are inside the + // run by construction. A handler that finished before the second refusal + // landed would start a second run — which is the behaviour under test. + refresh: async () => { + started++; + await held; + throw new Error('refresh broke'); + }, + }); + t.after(() => void client.close()); + client.on('refresh-failed', (info) => void failures.push(info)); + + // Two concurrent refusals join ONE refresh run: the callers surface their + // original 401s, the run's own failure is reported exactly once. + const calls = [client.call('flaky/get'), client.call('flaky/get')]; + const issued = wire.calls(); + assert.strictEqual(issued.length, 2); + for (const packet of issued) wire.refuse(packet); + await waitFor(() => started === 1, 'the refresh never started'); + release(); + + const results = await Promise.allSettled(calls); + assert.ok(results.every((r) => r.status === 'rejected' && r.reason.code === 401)); + assert.strictEqual(failures.length, 1); + assert.strictEqual(failures[0].error.message, 'refresh broke'); + assert.strictEqual(failures[0].cause.code, 401); +}); + +test('CallOptions.timeout: the per-call deadline beats callTimeout and rides the packet', async (t) => { + const definition = defineRouter({ + slow: { + // The server-side budget check: a generous procedure timeout that the + // caller's packet field must SHORTEN. + nap: procedure({ + access: 'public', + timeout: 30_000, + handler: async (context) => { + await timers.setTimeout(500, undefined, { signal: context.signal }).catch(() => {}); + return 'done'; + }, + }), + }, + }); + const { server, port } = await createServer(definition); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: false, + callTimeout: 30_000, + }); + t.after(() => void client.close()); + await client.load('slow'); + const started = Date.now(); + await assert.rejects(client.api.slow.nap(undefined, { timeout: 60 }), (error) => error.code === 408); + assert.ok(Date.now() - started < 5000, 'the per-call deadline, not callTimeout, fired'); +}); + +test('retry: listed codes are re-issued with fresh packet ids; others surface as-is', async (t) => { + const state = { hits: 0, ids: [] }; + const definition = defineRouter({ + flaky: { + get: procedure({ + access: 'public', + handler: async (context) => { + state.hits++; + state.ids.push(context.uuid); + if (state.hits < 3) { + const error = new Error('overloaded'); + error.code = 503; + error.expose = true; + throw error; + } + return 'finally'; + }, + }), + teapot: procedure({ + access: 'public', + handler: async () => { + const error = new Error('teapot'); + error.code = 418; + error.expose = true; + throw error; + }, + }), + }, + }); + const { server, port } = await createServer(definition); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + logger: false, + reconnect: false, + retry: { attempts: 2, on: [503], minDelay: 5, maxDelay: 10, jitter: false }, + }); + t.after(() => void client.close()); + await client.load('flaky'); + assert.strictEqual(await client.api.flaky.get(), 'finally'); + assert.strictEqual(state.hits, 3, 'two retries after the first failure'); + assert.strictEqual(new Set(state.ids).size, 3, 'every attempt is its own packet'); + // A code outside the policy surfaces immediately. + await assert.rejects(client.api.flaky.teapot(), (error) => error.code === 418); + // Exhausted attempts surface the LAST refusal. + state.hits = -10; // 503 forever + await assert.rejects(client.api.flaky.get(), (error) => error.code === 503); +}); diff --git a/tests/client.test.js b/tests/client.test.js index 42dd4f9..4709526 100644 --- a/tests/client.test.js +++ b/tests/client.test.js @@ -92,6 +92,18 @@ test('Client / calls', async (t) => { assert.deepStrictEqual(result, { success: true }); }); + await t.test('client.call() reaches a method whose unit was never loaded', async () => { + // No scaffolding involved: the target is addressed by wire string, which + // is what an authenticate hook uses before load() has built `api`. + const fresh = await WrpcClient.connect('ws://localhost:8000/', { callTimeout: 300 }); + try { + assert.deepStrictEqual(await fresh.call('test/test'), { success: true }); + assert.strictEqual(fresh.api.test, undefined, 'call() must not scaffold anything'); + } finally { + fresh.close(); + } + }); + await t.test('handles parallel api calls', async () => { const promises = []; for (let i = 0; i < 10; i++) promises.push(client.api.test.test()); @@ -101,7 +113,8 @@ test('Client / calls', async (t) => { await t.test('handles call timeouts', async () => { const promise = client.api.test.timeout(); - await assert.rejects(promise, new Error('Request timeout')); + // Coded like every client-produced refusal: `error.code` checks work. + await assert.rejects(promise, (error) => error.message === 'Request timeout' && error.code === 408); }); await t.test('handles api errors', async () => { diff --git a/tests/client/transportContract.js b/tests/client/transportContract.js new file mode 100644 index 0000000..efe92ff --- /dev/null +++ b/tests/client/transportContract.js @@ -0,0 +1,36 @@ +'use strict'; + +// The shared client-transport contract, mirroring tests/engine/engineContract: +// one structural spec, run against every registered transport — built-in and +// subpath alike. Structural rather than behavioral on purpose: each +// transport's WIRE behavior has its own suite; what this locks is the shape +// `connect()` constructs against, so a third-party registrant that would die +// at runtime dies here instead. + +const assert = require('node:assert'); + +const { isClientTransport } = require('../../src/client.js'); + +// Awaited, and awaited by its caller: a subtest a synchronous parent never +// waits for is CANCELLED rather than run on Node 22 (Node 24's runner waits), +// which is how this suite reported `cancelledByParent` for every case there. +const runTransportContract = async (t, name, Transport) => { + await t.test(`${name}: satisfies the ClientTransport contract`, () => { + assert.strictEqual(isClientTransport(Transport), true); + const proto = Transport.prototype; + for (const method of ['open', 'close', 'write', 'terminate', 'send', 'on', 'off', 'online', 'offline']) { + assert.strictEqual(typeof proto[method], 'function', `${name}#${method} must be a function`); + } + }); + + await t.test(`${name}: instance flags and url`, () => { + const instance = new Transport(`x://host/${name}`); + assert.strictEqual(instance.url, `x://host/${name}`); + assert.strictEqual(typeof instance.active, 'boolean'); + assert.strictEqual(typeof instance.persistent, 'boolean'); + assert.strictEqual(typeof instance.heartbeat, 'boolean'); + assert.strictEqual(instance.active, false, 'a fresh transport starts inactive'); + }); +}; + +module.exports = { runTransportContract }; diff --git a/tests/client/transports.test.js b/tests/client/transports.test.js new file mode 100644 index 0000000..9a52566 --- /dev/null +++ b/tests/client/transports.test.js @@ -0,0 +1,39 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { WrpcClient, ClientTransport, isClientTransport } = require('../../src/client.js'); +require('../../sse.js'); // registers the sse transport +const { runTransportContract } = require('./transportContract.js'); + +test('client transports: every registered transport passes the shared contract', async (t) => { + const names = Object.keys(WrpcClient.transport); + assert.deepStrictEqual([...names].sort(), ['event', 'http', 'sse', 'ws']); + for (const name of names) await runTransportContract(t, name, WrpcClient.transport[name]); +}); + +test('client transports: the registry is null-prototyped and assigned into', () => { + // A peer-influenced name must not answer with Object.prototype's function. + assert.strictEqual(WrpcClient.transport.toString, undefined); + assert.strictEqual(Object.getPrototypeOf(WrpcClient.transport), null); +}); + +test('client transports: a malformed registrant is refused with the port-shaped TypeError', async (t) => { + class Broken {} // no open/close/write + WrpcClient.transport.broken = Broken; + t.after(() => delete WrpcClient.transport.broken); + assert.strictEqual(isClientTransport(Broken), false); + await assert.rejects( + WrpcClient.connect('x://host', { transport: 'broken' }), + (error) => error instanceof TypeError && /ClientTransport contract/.test(error.message), + ); + await assert.rejects( + WrpcClient.connect('x://host', { transport: ['broken', 'ws'] }), + (error) => error instanceof TypeError && /ClientTransport contract/.test(error.message), + ); + // The bare base class does NOT qualify: open/write are exactly what a + // subclass exists to provide, and a registrant that forgot them must fail + // here, not on the first connect. + assert.strictEqual(isClientTransport(ClientTransport), false); +}); diff --git a/tests/engine.test-d.ts b/tests/engine.test-d.ts new file mode 100644 index 0000000..4199a44 --- /dev/null +++ b/tests/engine.test-d.ts @@ -0,0 +1,109 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import * as engine from '../engine.js'; +import type { + Engine, + EngineAttachOptions, + EngineConnectionSource, + EngineRequest, + EngineVerifyClientInfo, + WrpcSocket, +} from '../engine.js'; +import type { Connection } from '../ws.js'; + +const nodeEngine = engine.createNodeEngine(); +expectType(nodeEngine); +expectType(nodeEngine.name); +expectType(nodeEngine.capabilities.backpressure); +expectType(nodeEngine.capabilities.pause); +expectType(nodeEngine.close()); +expectType(nodeEngine.close({ code: 1001, reason: 'bye' })); + +// The port has two kinds; both markers are optional on the base contract +expectType(nodeEngine.standalone); +expectType<((options: { host?: string; port?: number }) => Promise) | undefined>(nodeEngine.listen); +if (nodeEngine.listen) { + expectType>(nodeEngine.listen({ host: '127.0.0.1', port: 0 })); +} + +declare const httpServer: import('node:http').Server; +const source = nodeEngine.attach({ server: httpServer, pingInterval: 5000 }); +expectType(source); +source.on('connection', (socket, req) => { + expectType(socket); + // The port promises an IncomingMessage-shaped request, not a node one: + // a standalone engine synthesizes its own (see uws.test-d.ts). + expectType(req); + expectType(req.url); + expectType(req.socket.remoteAddress); + expectType(socket.send('x')); + expectType(socket.bufferedAmount); + socket.close(1000, 'done'); + socket.terminate(); + socket.pause?.(); + socket.resume?.(); +}); + +// A real IncomingMessage satisfies the port's request shape +declare const incoming: import('node:http').IncomingMessage; +expectAssignable(incoming); + +// Manual-upgrade mode: hosted engines MAY be driven from the app's own +// 'upgrade' listener instead of binding to a server (the express adapter). +declare const duplex: import('node:stream').Duplex; +declare const head: Buffer; +expectType< + ((req: import('node:http').IncomingMessage, socket: import('node:stream').Duplex, head: Buffer) => void) | undefined +>(source.handleUpgrade); +if (source.handleUpgrade) { + expectType(source.handleUpgrade(incoming, duplex, head)); +} + +// attach(): `server` is optional — absent for standalone engines and for +// the manual-upgrade mode; `onHttpCall` is the standalone HTTP entry point. +expectAssignable({}); +expectAssignable({ server: httpServer }); +expectAssignable({ path: '/ws', protocols: ['wrpc'], closeTimeout: 1000 }); +expectAssignable({ + onHttpCall: (call) => { + expectType(call.method); + expectType(call.url); + return undefined; + }, +}); +expectAssignable({ + verifyClient: (info) => { + expectType(info); + expectType(info.req); + expectType(info.socket); + expectType(info.head); + return true; + }, + handleProtocols: (offered, req) => { + expectType>(offered); + expectType(req); + return offered[0] ?? false; + }, +}); +expectError({ standalone: true }); +// createNodeEngine is pre-attach configuration: it never takes a server +expectError(engine.createNodeEngine({ server: httpServer })); + +// The built-in Connection satisfies the WrpcSocket contract +declare const connection: Connection; +expectAssignable(connection); + +// A hand-rolled standalone engine satisfies the port +expectAssignable({ + name: 'custom', + standalone: true, + capabilities: { backpressure: true, ping: false, deflate: false, cork: false, pause: false }, + attach: () => source, + listen: async () => ({ address: '127.0.0.1', family: 'IPv4', port: 8000 }), + close: () => {}, +}); + +// isEngine narrows unknown values +declare const candidate: unknown; +if (engine.isEngine(candidate)) { + expectType(candidate); +} diff --git a/tests/engine/engineContract.js b/tests/engine/engineContract.js new file mode 100644 index 0000000..9580254 --- /dev/null +++ b/tests/engine/engineContract.js @@ -0,0 +1,265 @@ +'use strict'; + +// Shared contract suite for server-side engines (the Engine + WrpcSocket +// port). F2 ran it against the built-in node engine; F3 reuses it for the +// uWebSockets.js adapter. Not a *.test.js — imported by engine tests. +// +// Engines differ in who owns the listener, so the suite talks to a harness +// rather than to attach() directly: +// +// harness.createEngine() -> a fresh Engine +// harness.boot(engine, attachOptions) -> { source, port, teardown } +// +// Passing a bare createEngine function keeps the hosted (node http server) +// harness, which is what a hosted engine wants. + +const assert = require('node:assert'); +const http = require('node:http'); + +const { ProtocolClient } = require('../websocket/protocolClient.js'); + +// Hosted engines attach to a node http server the suite owns. +const hostedHarness = (createEngine) => ({ + createEngine, + async boot(engine, attachOptions = {}) { + const httpServer = http.createServer(); + const source = engine.attach({ server: httpServer, pingInterval: 5000, ...attachOptions }); + await new Promise((resolve) => httpServer.listen(0, resolve)); + const { port } = httpServer.address(); + const teardown = async () => { + engine.close(); + await new Promise((resolve) => httpServer.close(resolve)); + }; + return { source, port, teardown }; + }, +}); + +// Standalone engines own listening; the suite only asks for a port. +const standaloneHarness = (createEngine) => ({ + createEngine, + async boot(engine, attachOptions = {}) { + const source = engine.attach({ ...attachOptions }); + const { port } = await engine.listen({ host: '127.0.0.1', port: 0 }); + const teardown = async () => { + engine.close(); + }; + return { source, port, teardown }; + }, +}); + +const openPeer = (port) => + new Promise((resolve, reject) => { + const peer = new ProtocolClient(`ws://127.0.0.1:${port}`); + peer.on('open', () => resolve(peer)); + peer.on('close', () => reject(new Error('handshake rejected'))); + }); + +// `t` is a node:test context; `harness` is either a createEngine function +// (hosted) or a full harness object. +const runEngineContract = async (harness, t) => { + const { createEngine, boot: bootEngine } = typeof harness === 'function' ? hostedHarness(harness) : harness; + + // `sub` is the subtest context: teardown is registered as an after-hook + // so a failing assertion cannot leave a listening engine behind and wedge + // the whole run (a standalone engine holds a native listen socket). + const boot = async (sub, attachOptions) => { + const engine = createEngine(); + const booted = await bootEngine(engine, attachOptions); + sub.after(() => booted.teardown()); + return { engine, ...booted }; + }; + + await t.test('engine shape: name, capabilities, attach, close', async () => { + const engine = createEngine(); + assert.strictEqual(typeof engine.name, 'string'); + assert.strictEqual(typeof engine.attach, 'function'); + assert.strictEqual(typeof engine.close, 'function'); + // A standalone engine owns the network stack and must be able to listen. + if (engine.standalone) assert.strictEqual(typeof engine.listen, 'function'); + const caps = engine.capabilities; + for (const key of ['backpressure', 'ping', 'deflate', 'cork', 'pause']) { + assert.strictEqual(typeof caps[key], 'boolean', `capability ${key}`); + } + engine.close(); // an engine that owns native resources must not be leaked + }); + + await t.test('connection event delivers a WrpcSocket and the upgrade request', async (sub) => { + const { source, port } = await boot(sub); + const connected = new Promise((resolve) => source.once('connection', (socket, req) => resolve({ socket, req }))); + const peer = await openPeer(port); + const { socket, req } = await connected; + + assert.strictEqual(typeof socket.send, 'function'); + assert.strictEqual(typeof socket.close, 'function'); + assert.strictEqual(typeof socket.terminate, 'function'); + assert.strictEqual(typeof socket.bufferedAmount, 'number'); + assert.strictEqual(typeof socket.on, 'function'); + assert.ok(req.headers, 'upgrade request with headers'); + assert.strictEqual(typeof req.url, 'string', 'upgrade request with a url'); + + peer.close(); + }); + + await t.test('echo: text and binary round-trip, send returns a boolean', async (sub) => { + const { source, port } = await boot(sub); + source.on('connection', (socket) => { + socket.on('message', (data, isBinary) => { + const accepted = isBinary ? socket.send(Buffer.from(data)) : socket.send(data.toString()); + assert.strictEqual(typeof accepted, 'boolean'); + }); + }); + const peer = await openPeer(port); + + const textEcho = new Promise((resolve) => peer.once('message', resolve)); + peer.sendText('contract echo'); + assert.strictEqual((await textEcho).toString(), 'contract echo'); + + // ProtocolClient surfaces binary data frames via 'frame', not 'message' + const binaryEcho = new Promise((resolve) => { + peer.once('frame', (opcode, payload) => resolve({ opcode, payload })); + }); + peer.sendBinary(Buffer.from([1, 2, 3, 250])); + const { opcode, payload } = await binaryEcho; + assert.strictEqual(opcode, 0x02); + assert.deepStrictEqual(Buffer.from(payload), Buffer.from([1, 2, 3, 250])); + + peer.close(); + }); + + await t.test('message payloads survive the callback that delivered them', async (sub) => { + // Engines that expose their receive buffer (or a neutered ArrayBuffer, + // as uws does) must copy before handing the payload to the RPC core, + // which consumes stream chunks asynchronously. + const { source, port } = await boot(sub); + const retained = new Promise((resolve) => { + source.once('connection', (socket) => { + socket.on('message', (data) => { + const kept = data; + setTimeout(() => resolve(Buffer.from(kept)), 20); + }); + }); + }); + const peer = await openPeer(port); + peer.sendBinary(Buffer.from([9, 8, 7, 6])); + assert.deepStrictEqual(await retained, Buffer.from([9, 8, 7, 6])); + peer.close(); + }); + + await t.test('close(code, reason) reaches the peer', async (sub) => { + const { source, port } = await boot(sub); + source.on('connection', (socket) => socket.close(4001, 'contract bye')); + // The close frame may arrive in the same TCP segment as the 101 + // response — the listener must be attached before the handshake + const peer = new ProtocolClient(`ws://127.0.0.1:${port}`); + const closed = await new Promise((resolve) => { + peer.on('close', (code, reason) => resolve({ code, reason })); + }); + assert.strictEqual(closed.code, 4001); + assert.strictEqual(closed.reason.toString(), 'contract bye'); + }); + + await t.test('terminate() drops the peer without a close frame', async (sub) => { + const { source, port } = await boot(sub); + source.on('connection', (socket) => socket.terminate()); + // Not openPeer: a fast engine can terminate before the peer finished + // processing the 101, and waiting for 'open' would race that. + const peer = new ProtocolClient(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => peer.socket.once('close', resolve)); + }); + + await t.test('peer close surfaces as a close event with code and reason', async (sub) => { + const { source, port } = await boot(sub); + const socketClosed = new Promise((resolve) => { + source.once('connection', (socket) => { + socket.on('close', (code, reason) => resolve({ code, reason })); + }); + }); + const peer = await openPeer(port); + peer.close(4002, 'peer leaving'); + const { code, reason } = await socketClosed; + assert.strictEqual(code, 4002); + assert.strictEqual(String(reason), 'peer leaving'); + }); + + await t.test('a closed socket reports no buffer and refuses sends', async (sub) => { + const { source, port } = await boot(sub); + const gone = new Promise((resolve) => { + source.once('connection', (socket) => { + socket.on('close', () => resolve(socket)); + }); + }); + const peer = await openPeer(port); + peer.close(4003, 'done'); + const socket = await gone; + // uws poisons its handle on close; the contract says stay quiet, not throw + assert.strictEqual(socket.send('after close'), false); + assert.strictEqual(typeof socket.bufferedAmount, 'number'); + socket.close(1000, 'again'); + socket.terminate(); + }); + + await t.test('verifyClient rejection blocks the upgrade', async (sub) => { + const { port } = await boot(sub, { verifyClient: () => false }); + const res = await ProtocolClient.attemptHandshake({ + host: '127.0.0.1', + port, + path: '/', + headers: { + Upgrade: 'websocket', + Connection: 'Upgrade', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Key': Buffer.from('0123456789abcdef').toString('base64'), + }, + timeoutMs: 600, + }); + assert.strictEqual(parseInt(res.statusLine.split(' ')[1], 10), 403); + }); + + await t.test('subprotocol negotiation echoes the selected protocol', async (sub) => { + const { source, port } = await boot(sub, { protocols: ['wrpc'] }); + const connected = new Promise((resolve) => source.once('connection', resolve)); + const res = await ProtocolClient.attemptHandshake({ + host: '127.0.0.1', + port, + path: '/', + headers: { + Upgrade: 'websocket', + Connection: 'Upgrade', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Key': Buffer.from('0123456789abcdef').toString('base64'), + 'Sec-WebSocket-Protocol': 'nope, wrpc', + }, + timeoutMs: 600, + }); + assert.strictEqual(parseInt(res.statusLine.split(' ')[1], 10), 101); + assert.strictEqual(res.headers['sec-websocket-protocol'], 'wrpc'); + const socket = await connected; + assert.strictEqual(socket.protocol, 'wrpc'); + }); + + await t.test('stopListening (when present) refuses new peers while accepted ones keep working', async (sub) => { + const probe = createEngine(); + // Optional capability, standalone-shaped: hosted engines have no + // listener of their own to stop. Skipped, never failed, elsewhere. + if (typeof probe.stopListening !== 'function') { + probe.close?.(); + return void sub.skip('engine has no stopListening'); + } + probe.close?.(); + const { engine, source, port } = await boot(sub, {}); + const connected = new Promise((resolve) => source.once('connection', resolve)); + const peer = await openPeer(port); + sub.after(() => peer.close()); + const socket = await connected; + engine.stopListening(); + // The accepted socket still echoes... + socket.on('message', (data) => socket.send(String(data))); + const answered = new Promise((resolve) => peer.on('message', resolve)); + peer.sendText('still-alive'); + assert.strictEqual(String(await answered), 'still-alive'); + // ...while a NEW connection is refused at the listener. + await assert.rejects(openPeer(port), () => true, 'a connect after stopListening must fail'); + }); +}; + +module.exports = { runEngineContract, hostedHarness, standaloneHarness }; diff --git a/tests/engine/node.test.js b/tests/engine/node.test.js new file mode 100644 index 0000000..77c6879 --- /dev/null +++ b/tests/engine/node.test.js @@ -0,0 +1,18 @@ +'use strict'; + +const test = require('node:test'); + +const { createNodeEngine, isEngine } = require('../../src/engine/index.js'); +const { runEngineContract } = require('./engineContract.js'); +const assert = require('node:assert'); + +test('isEngine: structural engine detection', () => { + assert.strictEqual(isEngine(createNodeEngine()), true); + assert.strictEqual(isEngine(null), false); + assert.strictEqual(isEngine({}), false); + assert.strictEqual(isEngine({ name: 'x', attach() {} }), false); +}); + +test('node engine satisfies the WrpcSocket engine contract', async (t) => { + await runEngineContract(() => createNodeEngine(), t); +}); diff --git a/tests/express.test-d.ts b/tests/express.test-d.ts new file mode 100644 index 0000000..f68a6f0 --- /dev/null +++ b/tests/express.test-d.ts @@ -0,0 +1,59 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import * as adapter from '../express.js'; +import type { CreateWrpcOptions, WrpcMiddleware } from '../express.js'; +import type { Engine, EngineConnectionSource } from '../engine.js'; +import type { RpcServer, Router } from '../index.js'; + +type IncomingMessage = import('node:http').IncomingMessage; +type ServerResponse = import('node:http').ServerResponse; +type Duplex = import('node:stream').Duplex; + +declare const router: Router; +declare const rpc: RpcServer; +declare const engine: Engine; +declare const req: IncomingMessage; +declare const res: ServerResponse; +declare const socket: Duplex; +declare const head: Buffer; + +const wrpc = adapter.createWrpc({ router }); +expectType(wrpc); +expectType(adapter.createWrpc()); + +// The pieces the app wires up itself: nothing here owns the listener +expectType(wrpc.rpc); +expectType(wrpc.engine); +expectType(wrpc.wsServer); +expectType>(wrpc.close()); + +// handler: express/connect middleware, (req, res, next) => void +expectType(wrpc.handler(req, res, () => {})); +expectAssignable<(req: IncomingMessage, res: ServerResponse, next: () => void) => void>(wrpc.handler); +expectError(wrpc.handler(req, res)); +expectError(wrpc.handler(req)); + +// upgrade: wire to httpServer.on('upgrade', wrpc.upgrade) +expectType(wrpc.upgrade(req, socket, head)); +expectAssignable<(req: IncomingMessage, socket: Duplex, head: Buffer) => void>(wrpc.upgrade); +declare const httpServer: import('node:http').Server; +httpServer.on('upgrade', wrpc.upgrade); + +// Options: the RpcServer surface plus the adapter-specific knobs +expectAssignable({}); +expectAssignable({ + router, + sessions: { cookie: { name: 'sid', maxAge: 3600 } }, + cors: { origins: ['https://app.example'], credentials: true }, + basePath: '/rpc', + logger: globalThis.console, +}); +expectAssignable({ rpc }); +expectAssignable({ router, engine, maxBodySize: 1024 }); +expectAssignable({ + router, + ws: { path: '/ws', protocols: ['wrpc'], maxBackpressure: 4096, verifyClient: () => true }, +}); +// `ws` is forwarded to attach() minus the server the adapter never binds +expectError({ router, ws: { server: httpServer } }); +expectError({ router, maxBodySize: '1024' }); +expectError({ router, handler: () => {} }); diff --git a/tests/fastify.test-d.ts b/tests/fastify.test-d.ts new file mode 100644 index 0000000..2c8f6c0 --- /dev/null +++ b/tests/fastify.test-d.ts @@ -0,0 +1,65 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import * as adapter from '../fastify.js'; +import type { FastifyLike, WrpcFastifyOptions } from '../fastify.js'; +import type { Engine } from '../engine.js'; +import type { RpcServer, Router } from '../index.js'; +import type { UwsApp } from '../uws.js'; + +declare const fastify: FastifyLike; +declare const router: Router; +declare const rpc: RpcServer; +declare const engine: Engine; + +// The plugin is an async fastify plugin: (instance, options) => Promise +expectType>(adapter.wrpcFastify(fastify)); +expectType>(adapter.wrpcFastify(fastify, { router })); +expectAssignable<(instance: FastifyLike, options?: WrpcFastifyOptions) => Promise>(adapter.wrpcFastify); +expectError(adapter.wrpcFastify()); +expectError(adapter.wrpcFastify({})); + +// A real fastify instance is what the plugin actually receives +declare const realFastify: import('fastify').FastifyInstance; +expectAssignable(realFastify); + +// Options: the RpcServer surface plus the three adapter-specific knobs +expectAssignable({}); +expectAssignable({ router }); +expectAssignable({ + router, + sessions: { cookie: { name: 'sid', sameSite: 'Strict' }, generateToken: () => 'token' }, + cors: { origins: ['https://app.example'], credentials: true }, + basePath: '/rpc', + logger: globalThis.console, +}); +// `rpc` reuses an existing core, `engine` skips backend detection +expectAssignable({ rpc }); +expectAssignable({ router, engine }); +// `ws` is forwarded to the engine's attach() minus the server it never owns +expectAssignable({ + router, + ws: { path: '/ws', protocols: ['wrpc'], pingInterval: 5000, verifyClient: () => true }, +}); +declare const httpServer: import('node:http').Server; +expectError({ router, ws: { server: httpServer } }); +expectError({ router: 'chat' }); +expectError({ router, basePath: 8000 }); + +// findUwsApp digs the uWebSockets.js app out of a fastify-uws server +expectType(adapter.findUwsApp(realFastify.server)); +expectType(adapter.findUwsApp(null)); +expectType(adapter.findUwsApp({})); +expectError(adapter.findUwsApp()); + +// Mirror: existing routes as wrpc procedures +expectAssignable({ router, mirror: true }); +expectAssignable({ + router, + restErrors: 'app', + mirror: { + access: 'public', + include: (route) => route.method !== 'GET', + unit: (route) => (route.url.startsWith('/legacy') ? 'legacy' : undefined), + name: () => undefined, + headers: (context) => ({ authorization: `Bearer ${String(context.uuid)}` }), + }, +}); diff --git a/tests/helpers/server.js b/tests/helpers/server.js new file mode 100644 index 0000000..8bc5d1d --- /dev/null +++ b/tests/helpers/server.js @@ -0,0 +1,46 @@ +'use strict'; + +// The boot lines every server-driving suite used to copy: an http Server on +// a free port with quiet logs, torn down through t.after the moment it +// resolves (never as a trailing await — a failing assertion before an +// un-hooked close wedges the run, and with a uws engine it wedges it with a +// native listen socket). Not a *.test.js: node --test must not run helpers. + +const timers = require('node:timers/promises'); +const assert = require('node:assert'); + +const { Server, WrpcClient } = require('../../index.js'); + +/** Boots a Server on 127.0.0.1:0 and registers its teardown. */ +const bootServer = async (t, options = {}) => { + const server = new Server({ + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 50 }, + ...options, + }); + await server.listen(); + t.after(() => server.close()); + const { port } = server.address(); + return { server, port, url: `ws://127.0.0.1:${port}${server.rpc.basePath}`, origin: `http://127.0.0.1:${port}` }; +}; + +/** Connects a WrpcClient (no heartbeat, no reconnect) and registers its close. */ +const connectClient = async (t, url, options = {}) => { + const client = await WrpcClient.connect(url, { heartbeat: false, reconnect: false, ...options }); + t.after(() => void client.close()); + return client; +}; + +/** Polls `predicate` every 5ms; fails the test after ~1.5s. */ +const waitFor = async (predicate, message = 'condition never held') => { + for (let i = 0; i < 300; i++) { + if (await predicate()) return; + await timers.setTimeout(5); + } + assert.fail(message); +}; + +module.exports = { bootServer, connectClient, waitFor }; diff --git a/tests/index.browser.test.js b/tests/index.browser.test.js index c6f7b58..1a75066 100644 --- a/tests/index.browser.test.js +++ b/tests/index.browser.test.js @@ -9,6 +9,7 @@ const { generateUUID } = require('../src/runtime/browser.js'); test('browser barrel exposes only browser-safe exports', () => { assert.deepStrictEqual(Object.keys(wrpcBrowser).sort(), [ 'Emitter', + 'EventStream', 'WrpcClient', 'WrpcClientProxy', 'WrpcError', @@ -16,8 +17,14 @@ test('browser barrel exposes only browser-safe exports', () => { 'WrpcWritable', 'chunkDecode', 'chunkEncode', + 'connect', + 'createEventStream', + 'isCodec', ]); assert.strictEqual(typeof wrpcBrowser.Emitter, 'function'); + assert.strictEqual(typeof wrpcBrowser.connect, 'function'); + assert.strictEqual(typeof wrpcBrowser.EventStream, 'function'); + assert.strictEqual(typeof wrpcBrowser.createEventStream, 'function'); assert.strictEqual(typeof wrpcBrowser.WrpcClient, 'function'); assert.strictEqual(typeof wrpcBrowser.WrpcClientProxy, 'function'); assert.strictEqual(typeof wrpcBrowser.WrpcError, 'function'); diff --git a/tests/index.test-d.ts b/tests/index.test-d.ts index fd4d801..0ebf64e 100644 --- a/tests/index.test-d.ts +++ b/tests/index.test-d.ts @@ -1,4 +1,4 @@ -import { expectType } from 'tsd'; +import { expectAssignable, expectError, expectType } from 'tsd'; import * as wrpc from '../index.js'; import type { Emitter, @@ -6,30 +6,724 @@ import type { WrpcClientProxy, WrpcError, Server, + RpcServer, Client, Context, Session, ServerTransport, WrpcReadable, WrpcWritable, - WebsocketServer, - Connection, - Frame, - FrameParser, + ClientTransport, + ServerHttpTransport, + Router, + Procedure, + RoomRegistry, + Broadcast, + SessionStore, + HttpCall, } from '../index.js'; +import type { Engine, EngineConnectionSource } from '../engine.js'; expectType(wrpc.Emitter); expectType(wrpc.WrpcClient); expectType(wrpc.WrpcClientProxy); expectType(wrpc.WrpcError); expectType(wrpc.Server); +expectType(wrpc.RpcServer); expectType(wrpc.Client); expectType(wrpc.Context); expectType(wrpc.Session); expectType(wrpc.ServerTransport); expectType(wrpc.WrpcReadable); expectType(wrpc.WrpcWritable); -expectType(wrpc.WebsocketServer); -expectType(wrpc.Connection); -expectType(wrpc.Frame); -expectType(wrpc.FrameParser); +expectType(wrpc.Router); +expectType(wrpc.Procedure); + +// Engine internals moved to the '@alexify/wrpc/ws' subpath (see ws.test-d.ts) +expectError(wrpc.WebsocketServer); +expectError(wrpc.Connection); +expectError(wrpc.Frame); +expectError(wrpc.FrameParser); + +// Router / procedures +const router = wrpc.defineRouter({ + chat: { + send: wrpc.procedure({ + access: 'public', + handler: async (context, args) => { + expectType(context); + return { ok: true, args }; + }, + }), + shorthand: async () => 42, + }, + 'auth.v1': { + signIn: { handler: async () => true }, + }, +}); +expectType(router); +expectType(router.getProcedure('chat', '*', 'send')); + +// The REST version strategy: 'path' or a function taking the vN token +expectAssignable[1]>({ rest: { version: 'path' } }); +expectAssignable[1]>({ + rest: { version: (version: string, path: string) => `/${version}${path}` }, +}); +expectError(wrpc.defineRouter({}, { rest: { version: 'header' } })); + +// Inbound events: the reserved `on` key, handlers shaped like procedures +const eventful = wrpc.defineRouter({ + chat: { + send: async () => ({ ok: true }), + on: { + typing: async (context, data) => { + expectType(context); + void data; + }, + seen: { access: 'session', handler: async () => {} }, + }, + }, +}); +expectType(eventful.getEventHandler('chat', '*', 'typing')); +expectType(eventful.getEventHandler('chat', undefined, 'typing')); +expectType(router.merge(router)); +expectType(wrpc.procedure(async () => 1)); +expectError(wrpc.procedure({ access: 'public' })); // handler is required +expectAssignable({ + handler: async () => 1, + input: (value: unknown) => value, + timeout: 5000, + queue: { concurrency: 8, size: 16, timeout: 1000 }, + meta: { description: 'x' }, +}); + +// The codegen descriptor: a closed shape language, so a typo is a type error +// rather than a silent `unknown` in whatever the CLI generates. +expectAssignable({ args: { room: 'string', 'limit?': 'number' }, returns: 'boolean' }); +expectAssignable({ args: 'object', data: [{ id: 'string', tags: 'string[]' }] }); +expectAssignable({}); +expectAssignable({ nested: { deeper: ['string'] } }); +// An array describes an array OF one shape, so a tuple is not a descriptor +expectError({ args: ['string', 'number'] }); +expectError({ args: 42 }); +expectError({ nonsense: 'string' }); +expectAssignable({ + handler: async () => 1, + signature: { args: { text: 'string' }, returns: { id: 'string' } }, +}); + +// Subscriptions: an async generator handler is one, and so is the explicit +// spelling; both scaffold subscribe/iterate on the client. +const feed = wrpc.defineRouter({ + chat: { + onMessage: { + access: 'public', + handler: async function* (context, args, options) { + expectType(context); + expectType(options.signal); + expectType(options.lastEventId); + void args; + yield wrpc.tracked('1', { text: 'hi' }); + }, + }, + onTyping: wrpc.procedure.subscription({ + access: 'public', + handler: async function* () { + yield { typing: true }; + }, + }), + }, +}); +expectType(feed.getProcedure('chat', '*', 'onMessage')); +// queue and timeout mean something else for a stream, so they are refused +expectError(wrpc.procedure.subscription({ handler: async function* () {}, timeout: 100 })); + +declare const subscriptionMethod: wrpc.SubscriptionMethod; +expectType<'subscription'>(subscriptionMethod.kind); +const subscription = subscriptionMethod.subscribe({ room: 'a' }, { lastEventId: '3', onData: () => {} }); +expectType(subscription.id); +expectType(subscription.lastEventId); +expectType(subscription.closed); +expectType(subscription.unsubscribe()); + +// The resume primitives +const log = wrpc.createEventLog<{ text: string }>({ size: 10 }); +expectType(log.push({ text: 'hi' })); +expectType> | null>(log.since('2')); +expectType(log.lastEventId); +const stream = wrpc.createEventStream({ highWaterMark: 4 }); +expectType(stream.push(1)); +expectType(stream.end()); +expectType(stream.dropped); + +// Sessions +expectAssignable(new wrpc.MemorySessionStore()); +expectAssignable({ + store: new wrpc.MemorySessionStore(), + cookie: { name: 'sid', secure: false, sameSite: 'Strict', maxAge: 3600 }, + generateToken: () => 'token', +}); + +// Server options: router-based signature; the old (application, options) is gone +expectAssignable({ router }); +expectAssignable({ + router, + host: 'localhost', + port: 8000, + protocol: 'http', + basePath: '/rpc', + cors: { origins: ['https://app.example'], credentials: true }, + ws: { pingInterval: 5000, perMessageDeflate: true }, +}); +expectAssignable({ origins: (origin: string) => origin.endsWith('.example') }); +expectError({ router, kind: 'server' }); + +// The logger option: a Console and a structured logger both fit the same +// all-optional shape, and `false` is the way to silence a server outright. +expectAssignable(globalThis.console); +declare const pinoLike: { + level: string; + child(bindings: Record): typeof pinoLike; + info(entry: object, message?: string): void; + debug(entry: object, message?: string): void; + warn(entry: object, message?: string): void; + error(entry: object, message?: string): void; +}; +expectAssignable(pinoLike); +expectAssignable({ router, logger: false }); +expectAssignable({ router, logger: pinoLike }); +expectAssignable({ router, logger: globalThis.console }); +expectError({ router, logger: 'verbose' }); +expectAssignable({ logger: pinoLike }); +expectAssignable({ logger: false }); + +// Telemetry: both injection modes, and a real OTel span shape assignable to +// the structural view without importing @opentelemetry/api. +declare const tracer: wrpc.WrpcTracer; +declare const meter: wrpc.WrpcMeter; +declare const otelApi: wrpc.WrpcTelemetryApi; +expectAssignable({ router, telemetry: { api: otelApi } }); +expectAssignable({ router, telemetry: { tracer, meter, includeIdentity: false } }); +expectAssignable({ router, telemetry: { tracer } }); +expectAssignable({ router, telemetry: null }); +expectAssignable({ end: () => {} }); +expectError({ router, telemetry: { tracer: 'a tracer' } }); + +// RpcServer core surface +declare const rpc: RpcServer; +expectType(rpc.router); +expectType(rpc.basePath); +expectType>(rpc.clients); +expectType>(rpc.close()); + +// Rooms: chainable targets, a local recipient count +declare const someClient: Client; +expectType(rpc.to('chat')); +expectType(rpc.to('chat', 'lobby').except(someClient).local()); +expectType(rpc.to('chat').emit('message', { text: 'hi' })); +expectType(rpc.broadcast('announce')); +expectType | null>(rpc.to('chat').rooms); +expectType(rpc.rooms); +expectType>(rpc.rooms.members('chat')); +expectType(rpc.instanceId); +expectType(someClient.join('chat')); +expectType(someClient.leave('chat')); +expectType>(someClient.rooms); +expectAssignable({ + method: 'POST', + url: '/api', + headers: {}, + body: '{}', + respond: (response) => { + expectType(response.status); + }, +}); + +// Server is an Emitter and exposes the core +declare const server: Server; +expectAssignable(server); +expectType(server.rpc); +expectType>(server.listen()); +expectType>(server.close()); + +// A standalone engine (uWebSockets.js) owns the network stack, so there is +// no node http server: read the bound address through server.address(). +expectType(server.httpServer); +expectType(server.wsServer); +expectType<{ address: string; family: string; port: number } | string | null>(server.address()); +expectError(server.address('http')); + +// Engines are injected through ServerOptions.engine +declare const engine: Engine; +expectAssignable({ router, engine }); +expectAssignable({ router, engine, ws: { path: '/ws' } }); +expectError({ router, engine: { name: 'broken' } }); + +// Client sessions are store-backed and async where they touch the store +declare const client: Client; +expectType(client.initializeSession()); +expectType(client.startSession('token', {})); +expectType>(client.restoreSession('token')); +expectType>(client.finalizeSession()); +expectType>(client.sessionReady); +expectType>(client.emit('room/event', { x: 1 })); + +// Context.session mirrors the live client session +declare const context: Context; +expectType(context.session); +// ...and Context.server is how a handler reaches rooms +expectType(context.server); +expectType(client.server); + +// Every public getter of Context and Client is typed: the documented +// `context.log.info(...)` pattern must compile, and so must the rest — +// this block is what makes .d.ts drift a CI failure instead of a user bug. +expectType(context.log); +context.log.info({ event: 'probe' }, 'message'); +expectType(context.uuid); +expectType>(context.state); +expectType(context.signal); +expectType(context.client); +// The call identity: the wire target and the resolved procedure +expectType(context.method); +expectType(context.procedure); +// onDisconnect's payload carries the pre-destroy room snapshot +expectAssignable((_client: Client, payload: { rooms: Set } | null) => void payload); +expectType(client.log); +expectType(client.transportKind); +expectType(client.persistent); +expectType(client.binary); +expectType(client.generateId()); +expectType(client.maxCalls); +expectType(client.maxSubscriptions); +expectType>(client.rooms); + +// The Phase-2 options compile everywhere they are accepted +expectAssignable({ generateId: () => 'id-1', protocols: ['wrpc.v1'] }); +expectAssignable({ protocols: [] }); +expectAssignable({ router, generateId: () => 'id-1' }); +expectAssignable({ router, introspection: 'session', maxCalls: 64 }); +expectAssignable({ router, maxBodySize: 1024 }); + +// Cluster: identity, replicated presence, introspection, commands, messaging +expectType(client.id); +expectType>(client.data); +expectType>(client.ask('chat/confirm', { q: 1 }, { timeout: 1000 })); +expectType>(client.expectAnswer('id-1', 1000)); +expectType(client.settleAnswer({ id: 'id-1', result: 1 })); +expectType(rpc.cluster); +expectType(rpc.getClient('node-1.abc')); +// The shell delegates, and Cluster is a VALUE export (instanceof works). +expectType(server.cluster); +expectType(server.getClient('node-1.abc')); +expectType(wrpc.Cluster); +expectType(rpc.cluster.instanceId); +expectType(rpc.cluster.epoch); +expectType(rpc.cluster.connected); +expectType(rpc.cluster.count('chat')); +expectType<{ total: number; instances: Record }>(rpc.cluster.presence('chat')); +expectType>(rpc.cluster.instances()); +expectType & { incomplete?: boolean; truncated?: boolean }>>( + rpc.cluster.fetchClients({ room: 'x' }), +); +expectType(rpc.cluster.join('node-1.abc', 'ops')); +expectType(rpc.cluster.leave({ room: 'chat' }, 'archive')); +expectType(rpc.cluster.disconnect({})); +expectType(rpc.cluster.sendEvent('cache/invalidate', { key: 'users' })); +expectType>(rpc.cluster.ask('stats', {}, { timeout: 500 })); +expectType(rpc.cluster.respond('stats', (data, from) => ({ data, from }))); +expectType(rpc.cluster.unrespond('stats')); +expectAssignable({ router, cluster: { presenceInterval: 1000, requestTimeout: 500 } }); + +// Acks: the broadcast question aggregates, never rejects +expectType>(rpc.to('chat').ask('chat/poll', { q: 1 }, { timeout: 1000 })); +declare const askResult: wrpc.AskResult; +expectType>(askResult.answers); +expectType>(askResult.errors); +expectType(askResult.expected); +expectType(askResult.incomplete); + +// Hooks: three registration levels type-check, unknown phases do not +const hook: wrpc.Hook = async (ctx, payload) => { + void ctx.state; + void payload; +}; +expectAssignable({ onRequest: hook, preHandler: [hook, hook] }); +expectAssignable({ onConnect: async (c) => void c.transportKind }); +expectError({ onRequets: hook }); +expectError({ onConnect: hook }); +wrpc.defineRouter({}, { hooks: { onRequest: hook } }).addHook('onError', hook); +expectAssignable({ + handler: async () => null, + preHandler: hook, + preSerialization: [hook], +}); +expectError({ handler: async () => null, access: 'admin' }); + +// Transport subclasses are type-only: not reachable as runtime values... +expectError(wrpc.ClientTransport); +expectError(wrpc.ServerHttpTransport); +expectError(wrpc.ServerWsTransport); +expectError(wrpc.ServerEventTransport); +// ...but their types stay usable through the static registries +declare const clientWs: ClientTransport; +expectType(clientWs.active); +expectType(new wrpc.WrpcClient.transport.ws('ws://localhost')); +declare const httpTransport: ServerHttpTransport; +expectType>(httpTransport.getCookies()); +expectType(httpTransport.responded); + +// buildHeaders computes per-request CORS headers +expectType>(wrpc.buildHeaders()); +expectType>(wrpc.buildHeaders({ origins: ['https://a'] }, 'https://a')); + +// WrpcClientOptions: real proxy option is typed, phantom handlers are gone +expectAssignable({ proxy: (data: string) => void data }); +expectError({ packetHandler: (data: string) => void data }); + +// Resilience options: backoff, heartbeat, and their off switches +expectAssignable({ + reconnect: { minDelay: 100, maxDelay: 5000, factor: 1.5, jitter: false, retries: 10, stableAfter: 1000 }, + heartbeat: { interval: 1000, timeout: 200 }, + random: () => 0.5, +}); +expectAssignable({ reconnect: false, heartbeat: false }); +expectAssignable({ connectTimeout: 5000 }); +expectAssignable({ connectTimeout: false }); +expectError({ connectTimeout: '5s' }); +expectError({ reconnect: { minDelay: '100' } }); +expectError({ reconnect: { stableAfter: '1s' } }); +declare const wsClient: WrpcClient; +expectType(wsClient.attempt); +expectType(wsClient.sendEvent('chat/typing', { on: true })); +// use(): the static introspection artifact — raw system/introspect shape +expectType(wsClient.use({ chat: { ping: { access: 'public' } } })); +expectAssignable[0]>({ + chat: { + ping: { access: 'public' }, + onMessage: { access: 'public', kind: 'subscription' }, + create: { + access: 'public', + http: { method: 'POST', path: '/chats', status: 201 }, + schema: { body: { type: 'object' } }, + }, + }, +}); +expectError(wsClient.use('nope')); +expectError(wsClient.use({ chat: { ping: { kind: 'subscription' } } })); // access is required +// The receiving half of a server ask: one responder per name. +expectType(wsClient.respond('chat/confirm', (data) => ({ ok: true, data }))); +expectType(wsClient.respond('chat/confirm', async () => 'answer')); +expectType(wsClient.unrespond('chat/confirm')); + +// --------------------------------------------------------------------------- +// Contract-first typed client. The contract is a plain interface; nothing is +// generated, and an UNtyped client must keep behaving exactly as it did. + +interface Contract { + chat: { + send(args: { text: string }): Promise<{ id: string }>; + ping(): Promise; + find(args?: { q: string }): Promise>; + /** A handler declared without a promise is still awaited on the wire. */ + sync(args: { a: 1 }): { done: true }; + onMessage: wrpc.SubscriptionContract<{ room: string }, { text: string }>; + onTick: wrpc.SubscriptionContract; + }; + 'auth.v1': { + signIn(args: { login: string }): Promise<{ token: string }>; + }; +} + +declare const typed: WrpcClient; + +// A call keeps its declared argument and gains the trailing CallOptions +expectType>(typed.api.chat.send({ text: 'hi' })); +expectType>(typed.api.chat.send({ text: 'hi' }, { signal: AbortSignal.timeout(1) })); +expectType>>(typed.api.chat.find()); +expectType>>(typed.api.chat.find({ q: 'x' }, {})); +expectType>(typed.api.chat.sync({ a: 1 })); +expectType>(typed.api['auth.v1'].signIn({ login: 'a' })); +expectError(typed.api.chat.send({ text: 42 })); +expectError(typed.api.chat.send()); +expectError(typed.api.chat.missing({})); +expectError(typed.api.missing); +expectError(typed.api.chat.send({ text: 'x' }, { signal: 'now' })); + +// A zero-argument member KEEPS its args slot, because slot 0 on the wire is +// always the procedure's arguments. Typing it away would make `ping({signal})` +// compile and then send `{"signal":{}}` as the args, dropping the cancellation. +expectType>(typed.api.chat.ping()); +expectType>(typed.api.chat.ping(undefined)); +expectType>(typed.api.chat.ping(undefined, { signal: AbortSignal.timeout(1) })); +expectError(typed.api.chat.ping({ signal: AbortSignal.timeout(1) })); + +// A procedure takes ONE args object, so these cannot be called as declared +interface Malformed { + unit: { + two(a: number, b: string): Promise; + variadic(...args: Array): Promise; + notAMethod: string; + }; +} +declare const malformed: WrpcClient; +expectError(malformed.api.unit.two(1, 'a')); +expectError(malformed.api.unit.variadic(1, 2, 3)); +expectError(malformed.api.unit.notAMethod()); +expectType(malformed.api.unit.notAMethod); + +// `load` only accepts unit keys the contract declares +expectType>(typed.load('chat', 'auth.v1')); +expectError(typed.load('missing')); + +// A subscription is NOT callable: it answers with a stream +expectType<'subscription'>(typed.api.chat.onMessage.kind); +expectError(typed.api.chat.onMessage({ room: 'a' })); +expectType(typed.api.chat.onMessage.subscribe({ room: 'a' })); +expectType( + typed.api.chat.onMessage.subscribe({ room: 'a' }, { onData: (data) => expectType<{ text: string }>(data) }), +); +// `void` args mean the argument may be left out entirely +expectType(typed.api.chat.onTick.subscribe()); +expectType( + typed.api.chat.onTick.subscribe(undefined, { onData: (data) => expectType(data) }), +); +expectError(typed.api.chat.onMessage.subscribe({ room: 42 })); +expectType & { subscription: wrpc.Subscription }>( + typed.api.chat.onMessage.iterate({ room: 'a' }), +); +// A unit is still an Emitter — that is where server -> client events arrive +expectType(typed.api.chat.on('message', () => {})); + +// connect() is WrpcClient.connect under a name that carries the type argument +expectType>>(wrpc.connect('wss://host')); +expectType>>(wrpc.WrpcClient.connect('wss://host')); +// ...and with no contract, nothing changes: `api` stays the loose record +expectType>(wrpc.connect('ws://host')); +declare const untyped: WrpcClient; +expectType>(untyped.api.anything); +// Deliberately `any`, not `Promise`: an untyped client cannot know +// what a method takes or answers, and a cast at every call site is worse. +expectType(untyped.api.anything.whatever({ x: 1 })); +expectType>(untyped.load('anything')); + +// client.meta / context.meta: the connection's presented metadata +declare const serverClient: wrpc.Client; +declare const serverContext: wrpc.Context; +expectType(serverClient.meta.url); +expectType(serverClient.meta.protocol); +expectType(serverContext.meta); +expectType>(serverClient.ready); + +// context.callMeta: the per-call metadata bag, frozen-empty by default +expectType>>(serverContext.callMeta); + +// client.call(): the unscaffolded escape hatch — deliberately untyped +expectType>(untyped.call('auth/signIn', { user: 'a' })); +expectType>(untyped.call('orders/create', {}, { meta: { idem: 'k' } })); +// withMeta on a typed contract method keeps the parameters and the result +expectType>(typed.api.chat.send.withMeta({ idem: 'k' })({ text: 'hi' })); +// The connection-phase meta option, both forms +void wrpc.connect('ws://host', { meta: { v: '1.2.3' } }); +void wrpc.connect('ws://host', { meta: async () => ({ v: '1.2.3' }) }); +expectType>(typed.call('auth.v1/signIn')); +// The authenticate and refresh hooks type-check in both spellings +void wrpc.connect('ws://host', { + authenticate: async (client, info) => { + expectType(info.reconnected); + expectType(info.attempts); + await client.call('auth/signIn'); + }, + refresh: async (client) => void (await client.call('auth/refresh')), +}); +void wrpc.connect('ws://host', { + refresh: { on: [401, 403], handler: async () => {} }, +}); + +// Inference utilities, on the declared side... +expectType<{ text: string }>(null as unknown as wrpc.InferArgs); +expectType<{ id: string }>(null as unknown as wrpc.InferResult); +expectType<{ room: string }>(null as unknown as wrpc.InferArgs); +expectType<{ text: string }>(null as unknown as wrpc.InferResult); +// A zero-arg member takes `void`, NOT `unknown` — a zero-parameter function is +// assignable to a one-parameter target, so naive inference lands on `unknown`. +expectType(null as unknown as wrpc.InferArgs); +expectType<{ q: string } | undefined>(null as unknown as wrpc.InferArgs); +// ...and on the mapped side +expectType<{ room: string }>(null as unknown as wrpc.InferArgs); +expectType<{ text: string }>(null as unknown as wrpc.InferResult); + +// A contract key named `on` is NOT mapped: a unit is an Emitter at runtime, so +// `api.chat.on` has to stay the listener registration. An optional one used to +// reduce the whole unit to `never` and break every member access on it. +interface WithOn { + chat: { + send(args: { text: string }): Promise; + on?: { typing: (data: unknown) => void }; + }; +} +declare const withOn: WrpcClient; +expectType>(withOn.api.chat.send({ text: 'x' })); +expectType(withOn.api.chat.on('typing', () => {})); + +// Stream surface +declare const readable: WrpcReadable; +expectType>(readable.stop()); +expectType(readable.pull()); +declare const writable: WrpcWritable; +expectType(writable.write(new Uint8Array(1))); +expectType(writable.closed); + +// error.details: optional wire field on WrpcError and ask errors +declare const wrpcError: wrpc.WrpcError; +expectAssignable(wrpcError.details); +expectAssignable(new wrpc.WrpcError({ message: 'x', code: 400, details: { issues: [] } })); + +// Declarative REST: http + fastify-shaped schema on a procedure +const mapped = wrpc.procedure({ + access: 'public', + http: { method: 'POST', path: '/projects/:orgId', status: 201 }, + schema: { + params: { type: 'object' }, + query: { type: 'object' }, + response: { 201: { type: 'object' }, 500: false }, + tags: ['Projects'], + }, + handler: async (_context, args) => args, +}); +expectType(mapped.http); +expectType(mapped.schema); +expectType(wrpc.effectiveSchema(mapped)); +expectType(router.hasRestRoutes); +const matched = router.matchRest('POST', ['projects', '42']); +if (matched && 'proc' in matched) { + expectType>(matched.params); + expectType(matched.http); +} +expectType>( + router.restRoutes(), +); +expectAssignable({ router, querystring: { parse: (text) => ({ text }) } }); + +// Injected validation compilers +expectAssignable({ + ajv: { compile: (schema) => (value) => Boolean(schema && value) }, + serializer: { compile: () => (value) => JSON.stringify(value) }, +}); +const compiledRouter = wrpc.defineRouter({}, { validation: { ajv: { compile: () => () => true } } }); +expectType(compiledRouter.compiledFor(mapped)); +expectType(compiledRouter.hasSerializers); +expectAssignable({ router, introspection: { access: 'session', schemas: false } }); + +// Client options: pluggable querystring + injected pre-validation +expectAssignable[1]>({ + querystring: { stringify: (query: object) => String(query) }, + validation: { ajv: { compile: () => () => true } }, +}); + +// Transport fallback list +expectAssignable[1]>({ transport: ['ws', 'sse', 'http'] }); + +// Wire codec +expectAssignable({ encode: (packet) => JSON.stringify(packet), decode: (text) => JSON.parse(text) }); +expectAssignable({ + router, + codec: { encode: () => '', decode: () => ({}), contentType: 'application/x-toy' }, +}); +expectType(wrpc.isCodec({})); +// codec.rest: the REST body codec — binary allowed, rest-only codecs valid +expectAssignable({ + encode: (value) => new Uint8Array([1]), + decode: (body) => body, + contentType: 'application/msgpack', +}); +expectAssignable({ rest: { encode: () => new Uint8Array(), decode: () => null } }); +expectAssignable({ + encode: () => '', + decode: () => ({}), + rest: { encode: () => '', decode: () => null }, +}); +expectError({ rest: { encode: () => new Uint8Array() } }); // decode required +expectAssignable({ + router, + codec: { rest: { encode: () => new Uint8Array(), decode: () => null, contentType: 'application/msgpack' } }, +}); +expectAssignable[1]>({ + codec: { rest: { encode: () => new Uint8Array(), decode: () => null } }, +}); +declare const rpcForCodec: RpcServer; +expectType(rpcForCodec.codec); + +// Phase-5 surface: cluster hardening, rooms linger, SSE address seam +expectAssignable({ + router, + cluster: { rooms: ['lobby'], maxFetch: 500, secret: 's', presenceInterval: 10_000 }, + rooms: { linger: 2000 }, +}); +expectAssignable({ router, cluster: false }); +expectAssignable({ rooms: /^topic:/ }); +expectAssignable({ rooms: (room: string) => room.startsWith('t:') }); +expectError({ rooms: 42 }); +declare const clusterTyped: wrpc.Cluster; +expectType(clusterTyped.healthy); +void clusterTyped.fetchClients({}).then((clients) => { + expectType(clients.truncated); + return undefined; +}); +declare const rpcTyped: wrpc.RpcServer; +expectType(rpcTyped.healthy); + +// Typed events: the reserved `events` (server -> client) and `sends` +// (client -> server) contract keys — declarations only, no runtime bytes. +interface EventfulContract { + chat: { + send(args: { text: string }): Promise<{ id: string }>; + events: { + message: (data: { text: string; from: string }) => void; + confirm: (data: { id: string }) => boolean; + plain: { n: number }; // payload form, fire-and-forget + }; + sends: { typing: { on: boolean } }; + }; + bare: { + hello(): Promise; + }; +} +declare const eventfulClient: WrpcClient; + +// The unit emitter is narrowed to the declared names and payloads +eventfulClient.api.chat.on('message', (data) => { + expectType<{ text: string; from: string }>(data); +}); +eventfulClient.api.chat.once('plain', (data) => { + expectType<{ n: number }>(data); +}); +expectError(eventfulClient.api.chat.on('nonsense', () => {})); +// `events`/`sends` are declarations, never callable members +expectError(eventfulClient.api.chat.events); +expectError(eventfulClient.api.chat.sends); +// A unit without `events` keeps the loose Emitter it always had +eventfulClient.api.bare.on('anything', (value) => void value); + +// sendEvent is keyed on the declared `sends` names… +expectType(eventfulClient.sendEvent('chat/typing', { on: true })); +expectError(eventfulClient.sendEvent('chat/typing', { on: 'yes' })); +// …while undeclared names stay allowed (string & {}) with unknown data +expectType(eventfulClient.sendEvent('elsewhere/custom', { any: 1 })); + +// respond is typed by the `events` function form: payload in, answer out +eventfulClient.respond('chat/confirm', (data) => { + expectType<{ id: string }>(data); + return true; +}); +expectError(eventfulClient.respond('chat/confirm', (data: { id: string }) => 'not-a-boolean')); + +// The meta/headers options that shipped without their tsd halves +void wrpc.connect('ws://host', { headers: { 'x-app-version': '1.2.3' } }); +void wrpc.connect('ws://host', { headers: async () => ({ authorization: 'Bearer x' }) }); +void wrpc.connect('ws://host', { metaFormat: 'prefixed' }); +void wrpc.connect('ws://host', { metaFormat: 'json' }); +expectError(wrpc.connect('ws://host', { metaFormat: 'base64' })); +expectAssignable({ router, metaMaxBytes: 4096, cors: { metaHeaders: ['userId', 'tenantId'] } }); +expectError({ router, cors: { metaHeaders: 'userId' } }); +expectError({ router, metaMaxBytes: '4k' }); diff --git a/tests/integration.test.js b/tests/integration.test.js index 1c9da63..6bd7927 100644 --- a/tests/integration.test.js +++ b/tests/integration.test.js @@ -1,13 +1,11 @@ 'use strict'; const timers = require('node:timers/promises'); -const { randomUUID } = require('node:crypto'); const { Blob } = require('node:buffer'); const { test } = require('node:test'); const assert = require('node:assert'); -const { Server } = require('../src/server.js'); -const { WrpcClient } = require('../src/client.js'); +const { Server, WrpcClient, connect, defineRouter, procedure } = require('../index.js'); const { emitWarning } = process; process.emitWarning = (warning, type, ...args) => { @@ -15,95 +13,52 @@ process.emitWarning = (warning, type, ...args) => { emitWarning(warning, type, ...args); }; -class ProcedureMock { - constructor({ access = 'public', handler }) { - this.access = access; - this.handler = handler; - } - - // eslint-disable-next-line class-methods-use-this - async enter() {} - // eslint-disable-next-line class-methods-use-this - leave() {} - invoke(context, args) { - return this.handler(args, context); - } -} - -const noop = () => {}; - -const createApplication = (api) => { - const introspect = (units = []) => { - const result = {}; - for (const unit of units) { - if (!api[unit]) continue; - const methods = {}; - for (const name of Object.keys(api[unit])) methods[name] = {}; - result[unit] = methods; - } - return result; - }; - - return { - console: { log: noop, info: noop, warn: noop, error: noop, debug: noop }, - static: { constructor: { name: 'Static' } }, - auth: { saveSession: async () => {} }, - getMethod: (unit, _ver, method) => { - if (unit === 'system' && method === 'introspect') { - return new ProcedureMock({ - handler: async (units) => introspect(units), - }); - } - const def = api[unit]?.[method]; - if (!def) return null; - return new ProcedureMock(def); - }, - }; -}; - -const createServer = async (api) => { - const options = { +const createServer = async (router) => { + const server = new Server({ + router, host: '127.0.0.1', port: 0, protocol: 'http', + logger: false, timeouts: { bind: 100 }, - queue: { concurrency: 100, size: 100, timeout: 5_000 }, - generateId: randomUUID, - }; - const server = new Server(createApplication(api), options); + }); await server.listen(); - const { port } = server.httpServer.address(); + const { port } = server.address(); return { server, port }; }; test('Integration / WrpcClient with Server', async (t) => { - const api = { + const router = defineRouter({ test: { - hello: { - handler: async ({ name }) => { + hello: procedure({ + access: 'public', + handler: async (_context, { name }) => { await timers.setTimeout(10); return `Hello, ${name}`; }, - }, - fail: { + }), + fail: procedure({ + access: 'public', handler: async () => { const error = new Error('Boom'); error.code = 400; throw error; }, - }, - secret: { - access: 'private', + }), + secret: procedure({ + access: 'session', handler: async () => 'secret', - }, - notify: { - handler: async (_args, context) => { - context.client.emit('test/ping', { ping: true }); + }), + notify: procedure({ + access: 'public', + handler: async (context) => { + context.client.sendEvent('test/ping', { ping: true }); return { ok: true }; }, - }, - readUpload: { - handler: async ({ id }, context) => { + }), + readUpload: procedure({ + access: 'public', + handler: async (context, { id }) => { const stream = context.client.getStream(id); const chunks = []; for await (const chunk of stream) chunks.push(Buffer.from(chunk)); @@ -113,9 +68,10 @@ test('Integration / WrpcClient with Server', async (t) => { data: Buffer.concat(chunks).toString('utf8'), }; }, - }, - download: { - handler: async ({ name }, context) => { + }), + download: procedure({ + access: 'public', + handler: async (context, { name }) => { const payload = Buffer.from('hello from server'); const stream = context.client.createStream(name, payload.length); queueMicrotask(() => { @@ -124,48 +80,48 @@ test('Integration / WrpcClient with Server', async (t) => { }); return { id: stream.id }; }, - }, + }), }, - }; + }); - const { server, port } = await createServer(api); + const { server, port } = await createServer(router); t.after(async () => { await server.close(); }); - await t.test('WS RPC: load and call public method', async () => { + await t.test('WS RPC: load and call public method', async (sub) => { const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`); - t.after(() => void client.close()); + sub.after(() => void client.close()); await client.load('test'); const result = await client.api.test.hello({ name: 'Max' }); assert.strictEqual(result, 'Hello, Max'); }); - await t.test('HTTP RPC: load and call public method', async () => { + await t.test('HTTP RPC: load and call public method', async (sub) => { const client = await WrpcClient.connect(`http://127.0.0.1:${port}/api`); - t.after(() => void client.close()); + sub.after(() => void client.close()); await client.load('test'); const result = await client.api.test.hello({ name: 'Ada' }); assert.strictEqual(result, 'Hello, Ada'); }); - await t.test('WS RPC: propagates method errors', async () => { + await t.test('WS RPC: propagates method errors', async (sub) => { const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`); - t.after(() => void client.close()); + sub.after(() => void client.close()); await client.load('test'); await assert.rejects(client.api.test.fail(), (error) => error.message === 'Boom' && error.code === 400); }); - await t.test('WS RPC: rejects private method without session', async () => { + await t.test('WS RPC: rejects session-only method without session', async (sub) => { const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`); - t.after(() => void client.close()); + sub.after(() => void client.close()); await client.load('test'); await assert.rejects(client.api.test.secret(), (error) => error.code === 403); }); - await t.test('WS events: server emit reaches client unit', async () => { + await t.test('WS events: server emit reaches client unit', async (sub) => { const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`); - t.after(() => void client.close()); + sub.after(() => void client.close()); await client.load('test'); const ping = new Promise((resolve) => client.api.test.on('ping', resolve)); const result = await client.api.test.notify(); @@ -173,9 +129,9 @@ test('Integration / WrpcClient with Server', async (t) => { assert.deepStrictEqual(await ping, { ping: true }); }); - await t.test('WS streams: client upload is readable on server', async () => { + await t.test('WS streams: client upload is readable on server', async (sub) => { const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`); - t.after(() => void client.close()); + sub.after(() => void client.close()); await client.load('test'); const data = 'Some random data for upload to the server'; const blob = new Blob([data]); @@ -189,13 +145,43 @@ test('Integration / WrpcClient with Server', async (t) => { assert.strictEqual(uploaded.data, data); }); - await t.test('WS streams: server download readable on client', async () => { + await t.test('WS streams: upload before the reading call does not deadlock', async (sub) => { + // Regression: all chunks (past the 32-chunk high-water mark) arrive + // before the call that starts the consumer. The receive-side pause + // used to deadlock here: pushes blocked on the high-water mark, the + // socket stayed paused, and the consumer-starting call was never read. + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`); + sub.after(() => void client.close()); + await client.load('test'); + const chunk = 'x'.repeat(1024); + const chunkCount = 48; + const consumer = client.createStream('bulk-upload', chunk.length * chunkCount); + for (let i = 0; i < chunkCount; i++) consumer.write(Buffer.from(chunk)); + consumer.end(); + const uploaded = await client.api.test.readUpload({ id: consumer.id }); + assert.strictEqual(uploaded.name, 'bulk-upload'); + assert.strictEqual(uploaded.size, chunk.length * chunkCount); + assert.strictEqual(uploaded.data, chunk.repeat(chunkCount)); + }); + + await t.test('WS streams: server download readable on client', async (sub) => { const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`); - t.after(() => void client.close()); + sub.after(() => void client.close()); await client.load('test'); const { id } = await client.api.test.download({ name: 'download-stream' }); const readable = client.getStream(id); const blob = await readable.toBlob(); assert.strictEqual(await blob.text(), 'hello from server'); }); + + // The typed-client entry point. `connect` exists so a contract type has a + // natural place to go (`connect(url)`); at runtime it must be the very + // same call, options and all. + await t.test('connect() is WrpcClient.connect', async (sub) => { + const client = await connect(`ws://127.0.0.1:${port}/`, { callTimeout: 3000 }); + sub.after(() => void client.close()); + assert.ok(client instanceof WrpcClient); + await client.load('test'); + assert.strictEqual(await client.api.test.hello({ name: 'World' }), 'Hello, World'); + }); }); diff --git a/tests/logging.test.js b/tests/logging.test.js new file mode 100644 index 0000000..dbc594b --- /dev/null +++ b/tests/logging.test.js @@ -0,0 +1,245 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); +const { Writable } = require('node:stream'); + +const pino = require('pino'); + +const { createLoggerWriter, LOG_WRITER } = require('../src/logging.js'); + +// A real pino writing JSON lines into memory: the only way to be sure the +// (mergingObject, message) convention is honoured is to read what pino wrote. +const createPinoCollector = (level = 'trace') => { + const chunks = []; + const destination = new Writable({ + write(chunk, encoding, callback) { + chunks.push(chunk.toString()); + callback(); + }, + }); + const logger = pino({ level, base: null, timestamp: false }, destination); + const entries = () => + chunks + .join('') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + return { logger, entries }; +}; + +const createConsoleCollector = (names = ['log', 'info', 'debug', 'warn', 'error']) => { + const calls = []; + const sink = {}; + for (const name of names) sink[name] = (...args) => calls.push([name, ...args]); + return { sink, calls }; +}; + +test('createLoggerWriter detection ladder', async (t) => { + await t.test('falsy options disable logging', () => { + for (const value of [undefined, null, false, 0, '']) { + const writer = createLoggerWriter(value); + assert.strictEqual(writer.enabled, false); + } + }); + + await t.test('non-object truthy options disable logging', () => { + assert.strictEqual(createLoggerWriter('pino').enabled, false); + assert.strictEqual(createLoggerWriter(42).enabled, false); + }); + + await t.test('true logs to the global console', () => { + const original = globalThis.console; + const { sink, calls } = createConsoleCollector(); + globalThis.console = sink; + try { + createLoggerWriter(true).warn({ event: 'x' }, 'careful'); + } finally { + globalThis.console = original; + } + assert.deepStrictEqual(calls, [['warn', 'careful']]); + }); + + await t.test('an object with no known method disables logging', () => { + assert.strictEqual(createLoggerWriter({ emit() {} }).enabled, false); + }); + + await t.test('a console-shaped object takes the console branch', () => { + const { sink, calls } = createConsoleCollector(); + createLoggerWriter(sink).info({ event: 'x' }, 'hello'); + assert.deepStrictEqual(calls, [['info', 'hello']]); + }); + + await t.test('a partial console with only table takes the console branch', () => { + const calls = []; + const writer = createLoggerWriter({ table() {}, error: (...args) => calls.push(args) }); + writer.error({ event: 'x' }, 'bad'); + assert.deepStrictEqual(calls, [['bad']]); + }); + + await t.test('a bare error-only sink is treated as a console, not disabled', () => { + const errors = []; + const writer = createLoggerWriter({ error: (value) => errors.push(value) }); + assert.strictEqual(writer.enabled, true); + const error = new Error('boom'); + writer.error({ err: error, event: 'x' }); + assert.deepStrictEqual(errors, [error]); + }); + + await t.test('a level property alone marks a sink structured', () => { + const calls = []; + const writer = createLoggerWriter({ level: 'info', info: (...args) => calls.push(args) }); + writer.info({ event: 'x' }, 'hello'); + assert.deepStrictEqual(calls, [[{ event: 'x' }, 'hello']]); + }); + + await t.test('a pino takes the structured branch', () => { + const { logger, entries } = createPinoCollector(); + createLoggerWriter(logger).info({ event: 'call.ok', method: 'chat/send' }, 'done'); + const [entry] = entries(); + assert.strictEqual(entry.msg, 'done'); + assert.strictEqual(entry.event, 'call.ok'); + assert.strictEqual(entry.method, 'chat/send'); + assert.strictEqual(entry.level, 30); + }); + + await t.test('a writer is returned unchanged (idempotent)', () => { + const writer = createLoggerWriter(pino({ level: 'silent' })); + assert.strictEqual(createLoggerWriter(writer), writer); + assert.strictEqual(writer[LOG_WRITER], true); + }); + + await t.test('the disabled writer is a shared frozen singleton', () => { + const writer = createLoggerWriter(false); + assert.strictEqual(createLoggerWriter(null), writer); + assert.strictEqual(createLoggerWriter(writer), writer); + assert.strictEqual(Object.isFrozen(writer), true); + }); +}); + +test('writer levels', async (t) => { + await t.test('every level reaches the matching pino level', () => { + const { logger, entries } = createPinoCollector(); + const writer = createLoggerWriter(logger); + writer.log({}, 'a'); + writer.info({}, 'b'); + writer.debug({}, 'c'); + writer.warn({}, 'd'); + writer.error({}, 'e'); + assert.deepStrictEqual( + entries().map((entry) => [entry.level, entry.msg]), + [ + [30, 'a'], + [30, 'b'], + [20, 'c'], + [40, 'd'], + [50, 'e'], + ], + ); + }); + + await t.test('log falls back to info on a sink without log', () => { + const calls = []; + const writer = createLoggerWriter({ level: 'info', info: (...args) => calls.push(['info', ...args]) }); + writer.log({ event: 'x' }, 'hello'); + assert.deepStrictEqual(calls, [['info', { event: 'x' }, 'hello']]); + }); + + await t.test('error falls back to warn, then to log', () => { + const warned = []; + const warnOnly = createLoggerWriter({ debug() {}, warn: (...args) => warned.push(args) }); + warnOnly.error({}, 'bad'); + assert.strictEqual(warned.length, 1); + + const logged = []; + const logOnly = createLoggerWriter({ log: (...args) => logged.push(args) }); + logOnly.error({}, 'bad'); + logOnly.warn({}, 'careful'); + assert.deepStrictEqual(logged, [['bad'], ['careful']]); + }); + + await t.test('a level with no reachable method is a no-op', () => { + const writer = createLoggerWriter({ debug() {} }); + assert.doesNotThrow(() => writer.warn({}, 'nowhere')); + assert.doesNotThrow(() => writer.error({}, 'nowhere')); + }); + + await t.test('console writer prints the error itself when there is no message', () => { + const { sink, calls } = createConsoleCollector(); + const error = new Error('boom'); + createLoggerWriter(sink).error({ err: error, event: 'x' }); + assert.deepStrictEqual(calls, [['error', error]]); + }); + + await t.test('console writer falls back to the entry when there is no message or err', () => { + const { sink, calls } = createConsoleCollector(); + createLoggerWriter(sink).log({ event: 'x' }); + assert.deepStrictEqual(calls, [['log', { event: 'x' }]]); + }); +}); + +test('writer children', async (t) => { + await t.test('a structured child carries its bindings', () => { + const { logger, entries } = createPinoCollector(); + const writer = createLoggerWriter(logger).child({ component: 'rooms' }); + writer.child({ peer: 'ws://1' }).warn({ event: 'broadcast.send' }, 'dropped'); + const [entry] = entries(); + assert.strictEqual(entry.component, 'rooms'); + assert.strictEqual(entry.peer, 'ws://1'); + assert.strictEqual(entry.event, 'broadcast.send'); + }); + + await t.test('a console child is the same writer', () => { + const { sink } = createConsoleCollector(); + const writer = createLoggerWriter(sink); + assert.strictEqual(writer.child({ component: 'rooms' }), writer); + }); + + await t.test('a structured sink without child returns the same writer', () => { + const writer = createLoggerWriter({ level: 'info', info() {}, debug() {} }); + assert.strictEqual(writer.child({ component: 'rooms' }), writer); + }); + + await t.test('a child that throws degrades to the parent', () => { + const writer = createLoggerWriter({ + info() {}, + child() { + throw new Error('no children'); + }, + }); + assert.strictEqual(writer.child({ component: 'rooms' }), writer); + }); + + await t.test('the disabled writer child is itself', () => { + const writer = createLoggerWriter(false); + assert.strictEqual(writer.child({ component: 'rooms' }), writer); + }); +}); + +test('a logger that throws never escapes the writer', async (t) => { + await t.test('every level of a throwing structured sink is contained', () => { + const throwing = { level: 'trace' }; + for (const level of ['info', 'debug', 'warn', 'error']) { + throwing[level] = () => { + throw new Error(level); + }; + } + const writer = createLoggerWriter(throwing); + for (const level of ['log', 'info', 'debug', 'warn', 'error']) { + assert.doesNotThrow(() => writer[level]({ event: 'x' }, 'message'), level); + } + }); + + await t.test('every level of a throwing console sink is contained', () => { + const throwing = {}; + for (const level of ['log', 'info', 'debug', 'warn', 'error']) { + throwing[level] = () => { + throw new Error(level); + }; + } + const writer = createLoggerWriter(throwing); + for (const level of ['log', 'info', 'debug', 'warn', 'error']) { + assert.doesNotThrow(() => writer[level]({ event: 'x' }, 'message'), level); + } + }); +}); diff --git a/tests/package/browser-types.test.js b/tests/package/browser-types.test.js new file mode 100644 index 0000000..b1efd78 --- /dev/null +++ b/tests/package/browser-types.test.js @@ -0,0 +1,105 @@ +'use strict'; + +// The browser types condition has one job: a browser TS project WITHOUT +// @types/node must compile against browser.d.ts / sse.browser.d.ts, and a +// server name imported there must be a compile error instead of an +// `undefined` at runtime. tsd cannot check the first half (its run always +// has node types available), so this test drives the repo's own tsc with +// `types: []` over a fixture that uses the browser surface — and expects +// exactly the server-name errors the fixture marks. + +const path = require('node:path'); +const { existsSync } = require('node:fs'); +const { mkdtemp, writeFile } = require('node:fs/promises'); +const { tmpdir } = require('node:os'); +const { execFile } = require('node:child_process'); +const { test } = require('node:test'); +const assert = require('node:assert'); + +const ROOT = path.join(__dirname, '..', '..'); +const TSC = path.join(ROOT, 'node_modules', '.bin', 'tsc'); + +const run = (command, args, options) => + new Promise((resolve) => { + execFile(command, args, options, (error, stdout, stderr) => { + resolve({ code: error?.code ?? 0, stdout: String(stdout), stderr: String(stderr) }); + }); + }); + +const FIXTURE = ` +import { + WrpcClient, + WrpcClientProxy, + WrpcError, + WrpcReadable, + WrpcWritable, + Emitter, + EventStream, + connect, + createEventStream, + chunkEncode, + chunkDecode, +} from '${ROOT}/browser.js'; +import { ClientSseTransport, SseParser, CHANNEL_HEADER } from '${ROOT}/sse.browser.js'; + +interface Api { + chat: { send: (args: { text: string }) => Promise<{ ok: boolean }> }; +} + +async function main() { + const client = await connect('wss://example.test/api', { + generateId: () => 'id-1', + protocols: ['wrpc.v1'], + }); + const answer = await client.api.chat.send({ text: 'hi' }); + answer.ok satisfies boolean; + void WrpcClient; + void WrpcClientProxy; + void WrpcError; + void WrpcReadable; + void WrpcWritable; + void Emitter; + void EventStream; + void createEventStream; + void chunkEncode; + void chunkDecode; + void ClientSseTransport; + void SseParser; + void CHANNEL_HEADER; +} +void main; + +// The server surface must NOT exist here: each of these is the compile +// error that replaces an \`undefined\` at runtime in a browser bundle. +// @ts-expect-error Server is not part of the browser types +import { Server } from '${ROOT}/browser.js'; +// @ts-expect-error the channel registry is server machinery +import { SseChannels } from '${ROOT}/sse.browser.js'; +void Server; +void SseChannels; +`; + +const TSCONFIG = { + compilerOptions: { + strict: true, + noEmit: true, + target: 'es2022', + module: 'node18', + moduleResolution: 'node16', + // The whole point: NO ambient node types. Only the DOM and the + // language itself may satisfy the browser type graph. + types: [], + lib: ['es2022', 'dom', 'dom.iterable'], + skipLibCheck: false, + }, + include: ['./browser-types.fixture.ts'], +}; + +test('browser.d.ts compiles without @types/node and hides the server surface', async (t) => { + if (!existsSync(TSC)) return void t.skip('typescript is not installed'); + const dir = await mkdtemp(path.join(tmpdir(), 'wrpc-browser-types-')); + await writeFile(path.join(dir, 'browser-types.fixture.ts'), FIXTURE); + await writeFile(path.join(dir, 'tsconfig.json'), JSON.stringify(TSCONFIG, null, 2)); + const checked = await run(TSC, ['-p', path.join(dir, 'tsconfig.json')], { cwd: dir }); + assert.strictEqual(checked.code, 0, `tsc rejected the browser type surface:\n${checked.stdout}${checked.stderr}`); +}); diff --git a/tests/package/consistency.test.js b/tests/package/consistency.test.js new file mode 100644 index 0000000..d062c2c --- /dev/null +++ b/tests/package/consistency.test.js @@ -0,0 +1,190 @@ +'use strict'; + +// The machine-checked version of the release checklist's "check what would +// ship" step. `files` is an explicit allowlist, so a new root shim or .d.ts +// that was not added to it silently disappears from the tarball; an exports +// entry whose file does not exist fails only at a consumer's first import. +// This suite makes every one of those a red test instead. + +const path = require('node:path'); +const { existsSync } = require('node:fs'); +const { test } = require('node:test'); +const assert = require('node:assert'); + +const ROOT = path.join(__dirname, '..', '..'); +const pkg = require('../../package.json'); + +// Every file an exports condition can resolve to, flattened. +const exportTargets = (value, out = []) => { + if (typeof value === 'string') out.push(value); + else if (value && typeof value === 'object') { + for (const nested of Object.values(value)) exportTargets(nested, out); + } + return out; +}; + +const shipped = (relative) => { + // The allowlist ships whole directories (src/, bin/) and named root files. + const clean = relative.replace(/^\.\//, ''); + return pkg.files.some((entry) => (entry.endsWith('/') ? clean.startsWith(entry) : clean === entry)); +}; + +test('every exports target exists and is in the files allowlist', () => { + for (const [key, value] of Object.entries(pkg.exports)) { + for (const target of exportTargets(value)) { + assert.ok(existsSync(path.join(ROOT, target)), `${key} -> ${target} does not exist`); + assert.ok(shipped(target), `${key} -> ${target} is not in the files allowlist`); + } + } +}); + +test('every subpath has a types condition wherever it has a runtime one', () => { + for (const [key, value] of Object.entries(pkg.exports)) { + if (key === './package.json') continue; + const check = (conditions, label) => { + assert.ok(typeof conditions === 'object', `${label} must be a conditions object`); + assert.ok(conditions.types, `${label} has no types condition`); + assert.ok(conditions.default, `${label} has no default condition`); + // exports conditions match top-down: a `types` AFTER `default` is dead. + const keys = Object.keys(conditions); + assert.ok(keys.indexOf('types') < keys.indexOf('default'), `${label}: types must come before default`); + if (conditions.browser && typeof conditions.browser === 'object') check(conditions.browser, `${label}.browser`); + }; + check(value, key); + } +}); + +test('every root shim requires a file that exists', () => { + for (const [key, value] of Object.entries(pkg.exports)) { + for (const target of exportTargets(value)) { + if (!target.endsWith('.js')) continue; + const source = require('node:fs').readFileSync(path.join(ROOT, target), 'utf8'); + const match = source.match(/require\('([^']+)'\)/); + if (!match) continue; // not a shim + const required = path.join(ROOT, path.dirname(target), match[1]); + assert.ok(existsSync(required), `${key} shim ${target} requires missing ${match[1]}`); + } + } +}); + +test('every published subpath has a tsd file', () => { + for (const key of Object.keys(pkg.exports)) { + if (key === './package.json') continue; + const name = key === '.' ? 'index' : key.slice(2); + const candidates = [ + path.join(ROOT, 'tests', `${name}.test-d.ts`), + path.join(ROOT, 'tests', name, 'index.test-d.ts'), + ]; + assert.ok( + candidates.some((candidate) => existsSync(candidate)), + `${key} has no tests/${name}.test-d.ts`, + ); + } +}); + +test('the browser field map points at files that ship', () => { + for (const [from, to] of Object.entries(pkg.browser)) { + for (const file of [from, to]) { + assert.ok(existsSync(path.join(ROOT, file)), `browser map: ${file} does not exist`); + assert.ok(shipped(file), `browser map: ${file} is not in the files allowlist`); + } + } +}); + +test('the bin shim exists, ships, and requires a shipped file', () => { + for (const [name, target] of Object.entries(pkg.bin)) { + assert.ok(existsSync(path.join(ROOT, target)), `bin ${name} -> ${target} does not exist`); + assert.ok(shipped(target), `bin ${name} -> ${target} is not in the files allowlist`); + } +}); + +test('every file the allowlist names exists', () => { + for (const entry of pkg.files) { + assert.ok(existsSync(path.join(ROOT, entry.replace(/\/$/, ''))), `files entry ${entry} does not exist`); + } +}); + +// --------------------------------------------------------------------------- +// The hand-synced pairs CLAUDE.md names (and two the review found unnamed): +// every one of these used to be a release-checklist memory item, and two had +// already drifted by the time the guard below was written. + +const read = (relative) => require('node:fs').readFileSync(path.join(ROOT, relative), 'utf8'); + +test('docs/.vitepress/config.mts mirrors package.json keywords and version', () => { + const config = read('docs/.vitepress/config.mts'); + const start = config.indexOf('const keywords = ['); + assert.ok(start >= 0, 'config.mts must keep its keywords array'); + const block = config.slice(start, config.indexOf(']', start)); + const mirrored = [...block.matchAll(/'([^']+)'/g)].map((match) => match[1]); + assert.deepStrictEqual(mirrored, pkg.keywords, 'config.mts keywords must mirror package.json exactly'); + assert.ok(config.includes(`'v${pkg.version}'`), `the nav version label must read v${pkg.version}`); +}); + +test('RPC_OPTION_KEYS matches the RpcServer constructor destructure', () => { + const core = read('src/rpc/core.js'); + const keysStart = core.indexOf('const RPC_OPTION_KEYS = ['); + const keys = [...core.slice(keysStart, core.indexOf(']', keysStart)).matchAll(/'([^']+)'/g)].map((m) => m[1]); + assert.ok(keys.length > 0); + const ctorStart = core.indexOf('constructor(options = {})'); + const destructure = core.slice(core.indexOf('const {', ctorStart), core.indexOf('} = options;', ctorStart)); + const names = [...destructure.matchAll(/^\s{6}(\w+)/gm)].map((m) => m[1]); + // Set equality both ways: a key the shells would drop (in the constructor + // but not the list) and a stale key (listed but no longer destructured) + // are both silent bugs. + assert.deepStrictEqual([...names].sort(), [...keys].sort()); +}); + +test('every exports subpath has a bundle-size row in scripts/size.js', () => { + const size = read('scripts/size.js'); + const entries = [...size.matchAll(/entry: '([^']+)'/g)].map((m) => m[1]); + for (const [key, value] of Object.entries(pkg.exports)) { + if (key === './package.json') continue; + const targets = exportTargets(value).map((target) => target.replace(/^\.\//, '')); + // .d.ts targets are types conditions, not bundles. + const runtime = targets.filter((target) => target.endsWith('.js')); + assert.ok( + runtime.some((target) => entries.includes(target)), + `${key} has no ENTRIES row in scripts/size.js (targets: ${runtime.join(', ')})`, + ); + } +}); + +test('runtime barrel exports are all declared in the hand-written types', () => { + const declared = (files) => { + const names = new Set(); + for (const file of files) { + const text = read(file); + for (const match of text.matchAll(/^export (?:declare )?(?:abstract )?(?:class|function|const|let)\s+(\w+)/gm)) { + names.add(match[1]); + } + } + return names; + }; + const cases = [ + ['index.js', declared(['index.d.ts', 'client.d.ts'])], + ['browser.js', declared(['browser.d.ts', 'client.d.ts'])], + ]; + for (const [barrel, names] of cases) { + const runtime = Object.keys(require(path.join(ROOT, barrel))); + for (const name of runtime) { + assert.ok(names.has(name), `${barrel} exports '${name}' but the d.ts pair never declares it`); + } + } +}); + +test('every ./x.js reference inside a shipped root d.ts resolves to a shipped x.d.ts', () => { + const roots = pkg.files.filter((entry) => entry.endsWith('.d.ts')); + for (const file of roots) { + const text = read(file); + const refs = [ + ...[...text.matchAll(/from '\.\/([\w.-]+)\.js'/g)].map((m) => m[1]), + ...[...text.matchAll(/import\('\.\/([\w.-]+)\.js'\)/g)].map((m) => m[1]), + ]; + for (const ref of refs) { + const dts = `${ref}.d.ts`; + assert.ok(existsSync(path.join(ROOT, dts)), `${file} references ./${ref}.js but ${dts} does not exist`); + assert.ok(shipped(dts), `${file} references ./${ref}.js but ${dts} is not in the files allowlist`); + } + } +}); diff --git a/tests/perf/stream-memory.perf.js b/tests/perf/stream-memory.perf.js new file mode 100644 index 0000000..f7421d3 --- /dev/null +++ b/tests/perf/stream-memory.perf.js @@ -0,0 +1,144 @@ +'use strict'; + +// Perf/memory guard for the F1 claim: "1 GiB stream without memory growth". +// +// Streams 1 GiB in 64 KiB chunks through the real backpressure chain: +// WrpcReadable.finalize -> WrpcWritable -> ServerWsTransport -> Connection -> socket +// The socket is a MockSocket subclass modelling kernel pressure: every write is +// counted and then DISCARDED (so the measurement covers OUR buffering, not the +// sink's), writes report false once the simulated buffer exceeds 1 MiB, and the +// buffer drains asynchronously via setImmediate, like a kernel flush. +// +// Deliberately NOT named *.test.js so bare `node --test` discovery skips it. +// Run directly: node tests/perf/stream-memory.perf.js — prints a one-line +// summary and sets exit code 1 when throughput or memory guarantees break. + +const { Connection } = require('../../src/websocket/ws.js'); +const { ServerTransport } = require('../../src/transport.js'); +const { WrpcReadable, WrpcWritable } = require('../../src/streams.js'); +const { MockSocket } = require('../websocket/mockSocket.js'); + +const ServerWsTransport = ServerTransport.transport.ws; + +const KIB = 1024; +const MIB = 1024 * KIB; +const CHUNK_SIZE = 64 * KIB; +const TOTAL_BYTES = 1024 * MIB; // 1 GiB +const CHUNK_COUNT = TOTAL_BYTES / CHUNK_SIZE; +const PRESSURE_LIMIT = MIB; // simulated socket buffer high-water mark +const RSS_LIMIT = 256 * MIB; // peak RSS delta budget over the baseline +const SAMPLE_EVERY = 64; // sample RSS every N produced chunks +const STREAM_ID = 'S1'; +const STREAM_NAME = 'stream-memory.bin'; +// chunkEncode frames each data chunk as [idLength][id][payload] in one buffer; +// only those writes are this long, so the size identifies data chunks at the sink. +const ENCODED_CHUNK_SIZE = 1 + STREAM_ID.length + CHUNK_SIZE; + +class PressureSocket extends MockSocket { + bytesReceived = 0; + payloadBytes = 0; + chunkWrites = 0; + buffered = 0; + #drainScheduled = false; + + write(data) { + const size = Buffer.byteLength(data); + this.bytesReceived += size; + if (size === ENCODED_CHUNK_SIZE) { + this.chunkWrites++; + this.payloadBytes += CHUNK_SIZE; + } + // The data itself is discarded: MockSocket.writtenData would retain all + // 1 GiB and the RSS measurement would be about the sink, not our buffering. + this.buffered += size; + this.writableLength = this.buffered; + if (this.buffered <= PRESSURE_LIMIT) return true; + if (!this.#drainScheduled) { + this.#drainScheduled = true; + setImmediate(() => { + this.#drainScheduled = false; + this.buffered = 0; + this.writableLength = 0; + this.emit('drain'); + }); + } + return false; + } +} + +const main = async () => { + const socket = new PressureSocket(); + const connection = new Connection(socket, Buffer.alloc(0)); + const transport = new ServerWsTransport(connection, { remoteAddress: '127.0.0.1' }); + const writable = new WrpcWritable(STREAM_ID, STREAM_NAME, TOTAL_BYTES, transport); + + // Harness adapters for WrpcReadable.finalize's writable contract: the real + // WrpcWritable (a utils Emitter) has no waitEvent/removeListener, and no + // remote peer exists to acknowledge the final 'end' packet, so end() also + // simulates the peer ack by emitting 'close' on the next microtask. + writable.waitEvent = (event) => new Promise((resolve) => writable.once(event, resolve)); + writable.removeListener = (event, listener) => writable.off(event, listener); + const sendEnd = writable.end.bind(writable); + writable.end = () => { + sendEnd(); + queueMicrotask(() => void writable.emit('close')); + }; + + let drainWaits = 0; + writable.on('drain', () => drainWaits++); + + const readable = new WrpcReadable(STREAM_ID, STREAM_NAME, TOTAL_BYTES); + + const baselineRss = process.memoryUsage.rss(); + let peakRss = baselineRss; + const sampleRss = () => { + const rss = process.memoryUsage.rss(); + if (rss > peakRss) peakRss = rss; + }; + + const startedAt = performance.now(); + const flow = readable.finalize(writable); // the exact code path pipe() wraps + + for (let i = 0; i < CHUNK_COUNT; i++) { + await readable.push(Buffer.allocUnsafe(CHUNK_SIZE)); + if (i % SAMPLE_EVERY === 0) sampleRss(); + } + await readable.close(); + await flow; + sampleRss(); + + const seconds = (performance.now() - startedAt) / 1000; + connection.terminate(); + + const peakDelta = peakRss - baselineRss; + const rate = (TOTAL_BYTES / MIB / seconds).toFixed(0); + const deltaMib = (peakDelta / MIB).toFixed(1); + console.log( + `stream-memory: ${socket.payloadBytes} bytes in ${seconds.toFixed(2)} s ` + + `(${rate} MB/s), peak RSS delta ${deltaMib} MiB, drain waits: ${drainWaits}`, + ); + + const failures = []; + if (socket.chunkWrites !== CHUNK_COUNT || socket.payloadBytes !== TOTAL_BYTES) { + failures.push( + `sink received ${socket.payloadBytes} payload bytes in ${socket.chunkWrites} chunks, ` + + `expected ${TOTAL_BYTES} bytes in ${CHUNK_COUNT} chunks`, + ); + } + if (readable.bytesRead !== TOTAL_BYTES) { + failures.push(`readable consumed ${readable.bytesRead} bytes, expected ${TOTAL_BYTES}`); + } + if (drainWaits === 0) { + failures.push('backpressure never engaged: finalize hit zero drain waits'); + } + if (peakDelta > RSS_LIMIT) { + failures.push(`peak RSS delta ${peakDelta} bytes exceeds the ${RSS_LIMIT} bytes budget`); + } + for (const failure of failures) console.error(`stream-memory FAIL: ${failure}`); + if (failures.length > 0) process.exitCode = 1; +}; + +main().catch((error) => { + console.error('stream-memory FAIL:', error); + process.exitCode = 1; +}); diff --git a/tests/proxy.test.js b/tests/proxy.test.js index a4486be..d52b330 100644 --- a/tests/proxy.test.js +++ b/tests/proxy.test.js @@ -186,6 +186,26 @@ test('WrpcClientProxy', async (t) => { proxy.close(); }); + await t.test('open() skips reopening while the connection is active', async () => { + savedSelf = globalThis.self; + globalThis.self = createSwEnv(); + const proxy = new WrpcClientProxy(); + await proxy.open(); + const originalOpen = WrpcClient.prototype.open; + let reopens = 0; + WrpcClient.prototype.open = function (...args) { + reopens++; + return originalOpen.apply(this, args); + }; + try { + await proxy.open(); + } finally { + WrpcClient.prototype.open = originalOpen; + } + assert.strictEqual(reopens, 0); + proxy.close(); + }); + await t.test('#handleMessage forwards port calls and #proxyPacket routes responses back', async () => { savedSelf = globalThis.self; globalThis.self = createSwEnv(); diff --git a/tests/query.test-d.ts b/tests/query.test-d.ts new file mode 100644 index 0000000..000463e --- /dev/null +++ b/tests/query.test-d.ts @@ -0,0 +1,127 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import * as wrpcQuery from '../query.js'; +import type { QueryUtils, WrpcMutationOptions, WrpcQueryOptions } from '../query.js'; +import type { Subscription, SubscriptionContract, WrpcClient } from '../index.js'; +// The real thing, a devDependency used only here: an option object that is not +// assignable to TanStack's own types is not a binding, it is a lookalike. +import { QueryClient } from '@tanstack/query-core'; +import type { MutationObserverOptions, QueryObserverOptions } from '@tanstack/query-core'; + +expectType(wrpcQuery.createQueryUtils); + +interface Api { + chat: { + send(args: { text: string }): Promise<{ id: string }>; + list(args: { room: string }): Promise>; + stats(): Promise<{ total: number }>; + onMessage: SubscriptionContract<{ room: string }, { text: string }>; + }; + 'auth.v1': { + signIn(args: { login: string }): Promise<{ token: string }>; + }; +} + +declare const client: WrpcClient; +const wq = wrpcQuery.createQueryUtils(client); +expectType>(wq); +// The client's own contract is enough — the type argument is inferred from it +expectType>(wrpcQuery.createQueryUtils(client)); +expectAssignable({ prefix: ['wrpc'], queryClient: new QueryClient() }); + +// A real QueryClient satisfies the structural cache contract, which is the +// whole reason this subpath can stay dependency-free. +expectAssignable(new QueryClient()); + +// --- queries --------------------------------------------------------------- +const list = wq.queryOptions(['chat', 'list'], { room: 'a' }); +expectType>>(list); +expectType>(list.queryKey); +expectType>>(list.queryFn()); +expectType>>(list.queryFn({ signal: AbortSignal.timeout(1) })); +// Passing through TanStack's own options is how staleTime and friends get set +expectAssignable>(wq.queryOptions(['chat', 'send'], { text: 'x' }, { staleTime: 1 })); +expectError(wq.queryOptions(['chat', 'list'], { room: 1 })); +expectError(wq.queryOptions(['chat', 'nope'], {})); +expectError(wq.queryOptions(['nope', 'list'], {})); +// Args the procedure needs cannot be forgotten; a procedure with none is happy +// without them, and refuses ones it never declared. +expectError(wq.queryOptions(['chat', 'list'])); +expectType>(wq.queryOptions(['chat', 'stats'])); +expectError(wq.queryOptions(['chat', 'stats'], { junk: 1 })); +expectType(null as unknown as wrpcQuery.CallArgs); +// A subscription answers with a stream: it is not a query +expectError(wq.queryOptions(['chat', 'onMessage'], { room: 'a' })); +// ...and a versioned unit key works like any other +expectType>(wq.queryOptions(['auth.v1', 'signIn'], { login: 'me' })); + +// --- mutations ------------------------------------------------------------- +const send = wq.mutationOptions(['chat', 'send']); +expectType>(send); +expectType>(send.mutationFn({ text: 'hi' })); +expectError(send.mutationFn({ text: 1 })); +expectError(wq.mutationOptions(['chat', 'onMessage'])); + +// --- subscriptions --------------------------------------------------------- +declare const queryClient: QueryClient; +expectType( + wq.subscriptionHandler( + ['chat', 'onMessage'], + { room: 'a' }, + { + queryClient, + onData: (data) => expectType<{ text: string }>(data), + }, + ), +); +// The updater sees the cached value and the new one, both typed +expectType( + wq.subscriptionHandler<['chat', 'onMessage'], Array>( + ['chat', 'onMessage'], + { room: 'a' }, + { + queryClient, + update: (previous, data) => [...(previous ?? []), data.text], + }, + ), +); +expectError(wq.subscriptionHandler(['chat', 'onMessage'], { room: 1 }, { queryClient })); +// A call answers once: it is not a subscription +expectError(wq.subscriptionHandler(['chat', 'send'], { text: 'x' }, { queryClient })); + +// --- assignable to TanStack's own option types ----------------------------- +expectAssignable, Error, Array<{ id: string }>, Array<{ id: string }>>>( + wq.queryOptions(['chat', 'list'], { room: 'a' }), +); +expectAssignable>( + wq.mutationOptions(['chat', 'send']), +); + +// --- the untyped default --------------------------------------------------- +declare const looseClient: WrpcClient; +const loose = wrpcQuery.createQueryUtils(looseClient); +expectAssignable>(loose.queryKey(['anything', 'goes'])); +expectAssignable>(loose.queryOptions(['anything', 'goes'], { x: 1 })); +expectAssignable>(loose.mutationOptions(['anything', 'goes'])); +expectType(loose.subscriptionHandler(['anything', 'goes'], {}, { queryClient })); +// A path is always [unit, method] — never a string, never three deep +expectError(loose.queryOptions('anything')); +expectError(loose.queryOptions(['anything', 'goes', 'deeper'])); + +// --- the derived path types ------------------------------------------------- +expectAssignable>(['chat', 'send']); +expectAssignable>(['auth.v1', 'signIn']); +expectError>(['chat', 'onMessage']); +expectAssignable>(['chat', 'onMessage']); +expectError>(['chat', 'send']); +expectType<{ text: string }>(null as unknown as wrpcQuery.CallArgs); +expectType<{ id: string }>(null as unknown as wrpcQuery.CallResult); +expectType<{ room: string }>(null as unknown as wrpcQuery.SubscriptionArgs); +expectType<{ text: string }>(null as unknown as wrpcQuery.SubscriptionData); + +// infiniteQueryOptions: typed like queryOptions, cursor rides as an args field +import type { WrpcInfiniteQueryOptions } from '../query.js'; +expectAssignable>( + wq.infiniteQueryOptions(['chat', 'send'], { text: 'x' }, { initialPageParam: null, cursorKey: 'after' }), +); +void wq.infiniteQueryOptions(['chat', 'list'], { room: 'a' }).queryFn({ pageParam: 'p2' }); +expectError(wq.infiniteQueryOptions(['chat', 'nope'], {})); diff --git a/tests/query/integration.test.js b/tests/query/integration.test.js new file mode 100644 index 0000000..e9cb05d --- /dev/null +++ b/tests/query/integration.test.js @@ -0,0 +1,142 @@ +'use strict'; + +// The cache bridge against a REAL client and server. The unit tests drive it +// with a fake client, which cannot prove the two things that actually matter +// here: that a path resolves against the objects `load()` really builds (own +// properties on a unit Emitter), and that a subscription opened through the +// bridge really delivers server values into the cache. + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { Server, connect, defineRouter, procedure, tracked } = require('../../index.js'); +const { createQueryUtils } = require('../../query.js'); + +// The two methods of QueryClient this needs, with real hashing semantics: keys +// are compared by value, so a write and a read of the same path meet. +const cache = () => { + const entries = new Map(); + const hash = (key) => JSON.stringify(key); + return { + entries, + setQueryData(queryKey, updater) { + const id = hash(queryKey); + const previous = entries.get(id)?.value; + const value = typeof updater === 'function' ? updater(previous) : updater; + entries.set(id, { queryKey, value }); + return value; + }, + read(queryKey) { + return entries.get(hash(queryKey))?.value; + }, + }; +}; + +const router = defineRouter({ + chat: { + list: procedure({ + access: 'public', + handler: async (_context, { room }) => [{ id: '1', room }], + }), + send: procedure({ + access: 'public', + handler: async (_context, { text }) => ({ id: `id-${text}` }), + }), + slow: procedure({ + access: 'public', + handler: async (context) => + new Promise((resolve, reject) => { + context.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + }), + }), + onMessage: procedure.subscription({ + access: 'public', + handler: async function* (_context, { room }) { + yield tracked('1', { room, text: 'one' }); + yield tracked('2', { room, text: 'two' }); + }, + }), + }, +}); + +const boot = async (t) => { + const server = new Server({ + router, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 100 }, + }); + await server.listen(); + t.after(() => server.close()); + const client = await connect(`ws://127.0.0.1:${server.address().port}/`); + t.after(() => void client.close()); + await client.load('chat'); + return { server, client }; +}; + +test('query integration: queryFn and mutationFn call through a real client', async (t) => { + const { client } = await boot(t); + const wq = createQueryUtils(client); + + const list = wq.queryOptions(['chat', 'list'], { room: 'a' }); + assert.deepStrictEqual(await list.queryFn(), [{ id: '1', room: 'a' }]); + assert.deepStrictEqual(list.queryKey, ['chat', 'list', { room: 'a' }]); + + const send = wq.mutationOptions(['chat', 'send']); + assert.deepStrictEqual(await send.mutationFn({ text: 'hi' }), { id: 'id-hi' }); +}); + +test('query integration: the AbortSignal TanStack passes reaches the server', async (t) => { + const { client } = await boot(t); + const wq = createQueryUtils(client); + const controller = new AbortController(); + const pending = wq.queryOptions(['chat', 'slow']).queryFn({ signal: controller.signal }); + controller.abort(); + // 499 is the wire code for "the caller took it back" — proof the signal was + // forwarded rather than dropped into the args object. + await assert.rejects( + () => pending, + (error) => error.code === 499, + ); +}); + +test('query integration: Emitter methods on a real unit are not procedures', async (t) => { + const { client } = await boot(t); + const wq = createQueryUtils(client); + // `on` really is a function on the real unit object's prototype: without the + // own-property check this would call Emitter.prototype.on as a procedure. + assert.strictEqual(typeof client.api.chat.on, 'function'); + await assert.rejects(() => wq.queryOptions(['chat', 'on']).queryFn(), /has no method 'on'/); + await assert.rejects(() => wq.queryOptions(['chat', 'nope']).queryFn(), /has no method 'nope'/); +}); + +test('query integration: a subscription lands in the cache', async (t) => { + const { client } = await boot(t); + const queryClient = cache(); + const wq = createQueryUtils(client, { queryClient }); + + const key = ['messages', 'a']; + const ended = new Promise((resolve) => { + wq.subscriptionHandler( + ['chat', 'onMessage'], + { room: 'a' }, + { + queryKey: key, + update: (previous, data) => [...(previous ?? []), data.text], + onEnd: resolve, + }, + ); + }); + await ended; + + assert.deepStrictEqual(queryClient.read(key), ['one', 'two']); +}); + +test('query integration: a subscription path is refused as a query, and vice versa', async (t) => { + const { client } = await boot(t); + const wq = createQueryUtils(client, { queryClient: cache() }); + await assert.rejects(() => wq.queryOptions(['chat', 'onMessage']).queryFn(), /is a subscription/); + assert.throws(() => wq.subscriptionHandler(['chat', 'list']), /is a call/); +}); diff --git a/tests/query/query.test.js b/tests/query/query.test.js new file mode 100644 index 0000000..9ef9a8c --- /dev/null +++ b/tests/query/query.test.js @@ -0,0 +1,338 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { Emitter } = require('../../src/utils.js'); +const { createQueryUtils } = require('../../query.js'); + +// A stand-in for a loaded client: `api` holds unit Emitters carrying OWN +// method properties, which is exactly the shape `WrpcClient.load()` builds. +const fakeClient = () => { + const calls = []; + const subscribes = []; + const chat = new Emitter(); + chat.send = (args, options) => { + calls.push({ target: 'chat/send', args, options }); + return Promise.resolve({ id: 'made' }); + }; + chat.onMessage = { + kind: 'subscription', + subscribe: (args, options) => { + const record = { args, options, closed: false }; + subscribes.push(record); + record.unsubscribe = () => { + record.closed = true; + return true; + }; + return record; + }, + iterate: () => assert.fail('iterate is not what the cache bridge uses'), + }; + const api = { chat }; + return { api, calls, subscribes }; +}; + +const fakeQueryClient = () => { + const writes = []; + return { + writes, + setQueryData(queryKey, updater) { + const previous = writes.length > 0 ? writes[writes.length - 1].value : undefined; + const value = typeof updater === 'function' ? updater(previous) : updater; + writes.push({ queryKey, value }); + return value; + }, + }; +}; + +// --------------------------------------------------------------------------- + +test('query: createQueryUtils validates what it is handed', () => { + assert.throws(() => createQueryUtils(), /must be a WrpcClient/); + assert.throws(() => createQueryUtils({}), /must be a WrpcClient/); + assert.throws(() => createQueryUtils({ api: null }), /must be a WrpcClient/); + assert.throws(() => createQueryUtils(fakeClient(), { prefix: 'wrpc' }), /prefix must be an array/); + // A queryClient is optional here, but if given it must be usable + assert.throws(() => createQueryUtils(fakeClient(), { queryClient: {} }), /setQueryData\(\) method/); + assert.ok(createQueryUtils(fakeClient(), { queryClient: fakeQueryClient() })); +}); + +test('query: queryKey is prefix-matchable', () => { + const wq = createQueryUtils(fakeClient()); + assert.deepStrictEqual(wq.queryKey(['chat', 'list']), ['chat', 'list']); + // The args go LAST, so invalidating ['chat'] or ['chat','list'] reaches + // every entry underneath regardless of arguments. + assert.deepStrictEqual(wq.queryKey(['chat', 'list'], { room: 'a' }), ['chat', 'list', { room: 'a' }]); + assert.deepStrictEqual(wq.queryKey('chat'), ['chat']); + + const prefixed = createQueryUtils(fakeClient(), { prefix: ['wrpc', 1] }); + assert.deepStrictEqual(prefixed.queryKey(['chat', 'list'], {}), ['wrpc', 1, 'chat', 'list', {}]); +}); + +test('query: queryOptions forwards args and the AbortSignal', async () => { + const client = fakeClient(); + const wq = createQueryUtils(client); + const options = wq.queryOptions(['chat', 'send'], { text: 'hi' }, { staleTime: 5000 }); + + assert.deepStrictEqual(options.queryKey, ['chat', 'send', { text: 'hi' }]); + assert.strictEqual(options.staleTime, 5000, 'extra options pass through'); + assert.strictEqual(typeof options.queryFn, 'function'); + + const controller = new AbortController(); + assert.deepStrictEqual(await options.queryFn({ signal: controller.signal }), { id: 'made' }); + assert.deepStrictEqual(client.calls[0].args, { text: 'hi' }); + // Forwarding the signal is what makes a cancelled query reach the server + assert.strictEqual(client.calls[0].options.signal, controller.signal); + + // TanStack always passes a context, but nothing should break without one + await options.queryFn(); + assert.strictEqual(client.calls[1].options.signal, undefined); + + // No args at all becomes the empty object the client's scaffolding expects + await wq.queryOptions(['chat', 'send']).queryFn(); + assert.deepStrictEqual(client.calls[2].args, {}); +}); + +test('query: mutationOptions takes its args from the variables', async () => { + const client = fakeClient(); + const wq = createQueryUtils(client); + const options = wq.mutationOptions(['chat', 'send'], { retry: 2 }); + assert.deepStrictEqual(options.mutationKey, ['chat', 'send']); + assert.strictEqual(options.retry, 2); + assert.deepStrictEqual(await options.mutationFn({ text: 'hi' }), { id: 'made' }); + assert.deepStrictEqual(client.calls[0].args, { text: 'hi' }); + await options.mutationFn(); + assert.deepStrictEqual(client.calls[1].args, {}); +}); + +test('query: paths are resolved lazily, so options survive load() and reconnects', async () => { + const client = { api: {} }; + const wq = createQueryUtils(client); + // Building options for a unit that is not loaded yet must NOT throw: a + // component declares its queries before anything is connected. + const options = wq.queryOptions(['chat', 'send'], { text: 'hi' }); + await assert.rejects(() => options.queryFn(), /unit 'chat' is not loaded/); + + // ...and once it IS loaded the very same options object works + const loaded = fakeClient(); + client.api.chat = loaded.api.chat; + assert.deepStrictEqual(await options.queryFn(), { id: 'made' }); +}); + +test('query: a bad path is refused with something actionable', async () => { + const client = fakeClient(); + const wq = createQueryUtils(client); + assert.throws(() => wq.queryOptions('chat'), /a path is \[unit, method\]/); + assert.throws(() => wq.queryOptions(['chat']), /a path is \[unit, method\]/); + assert.throws(() => wq.queryOptions(['chat', 'send', 'extra']), /a path is \[unit, method\]/); + assert.throws(() => wq.queryOptions([1, 2]), /a path is \[unit, method\] of strings/); + assert.throws(() => wq.mutationOptions(['chat']), /a path is \[unit, method\]/); + assert.throws(() => wq.subscriptionHandler(['chat'], {}, { queryClient: fakeQueryClient() }), /a path is/); + + // A resolution failure REJECTS, so it lands in the query's error channel + await assert.rejects(() => wq.queryOptions(['nope', 'send']).queryFn(), /unit 'nope' is not loaded/); + await assert.rejects(() => wq.queryOptions(['chat', 'nope']).queryFn(), /unit 'chat' has no method 'nope'/); +}); + +test('query: inherited properties are not methods', async () => { + const client = fakeClient(); + const wq = createQueryUtils(client); + // A unit object is an Emitter, so `on` IS a real function on its prototype. + // Resolving it would call Emitter.prototype.on as though it were a procedure. + await assert.rejects(() => wq.queryOptions(['chat', 'on']).queryFn(), /unit 'chat' has no method 'on'/); + await assert.rejects(() => wq.queryOptions(['chat', 'emit']).queryFn(), /has no method 'emit'/); + await assert.rejects(() => wq.queryOptions(['chat', 'toString']).queryFn(), /has no method 'toString'/); + // ...and `api` is a plain object, so the same holds one level up + await assert.rejects(() => wq.queryOptions(['constructor', 'x']).queryFn(), /unit 'constructor' is not loaded/); + await assert.rejects(() => wq.queryOptions(['__proto__', 'x']).queryFn(), /unit '__proto__' is not loaded/); +}); + +test('query: a call and a subscription are not interchangeable', async () => { + const client = fakeClient(); + const wq = createQueryUtils(client, { queryClient: fakeQueryClient() }); + await assert.rejects( + () => wq.queryOptions(['chat', 'onMessage']).queryFn(), + /is a subscription — use subscriptionHandler/, + ); + await assert.rejects(() => wq.mutationOptions(['chat', 'onMessage']).mutationFn(), /is a subscription/); + assert.throws(() => wq.subscriptionHandler(['chat', 'send']), /is a call — use queryOptions\(\)/); + + client.api.chat.notAMethod = { kind: 'something-else' }; + await assert.rejects(() => wq.queryOptions(['chat', 'notAMethod']).queryFn(), /is not callable/); + assert.throws(() => wq.subscriptionHandler(['chat', 'notAMethod']), /is not a subscription/); +}); + +test('query: subscriptionHandler writes every value into the cache', () => { + const client = fakeClient(); + const queryClient = fakeQueryClient(); + const wq = createQueryUtils(client, { queryClient }); + const seen = []; + + const handle = wq.subscriptionHandler( + ['chat', 'onMessage'], + { room: 'a' }, + { + lastEventId: '7', + onData: (data) => void seen.push(data), + }, + ); + + const [opened] = client.subscribes; + assert.deepStrictEqual(opened.args, { room: 'a' }); + assert.strictEqual(opened.options.lastEventId, '7', 'resume is passed straight through'); + + opened.options.onData({ text: 'one' }); + opened.options.onData({ text: 'two' }); + assert.deepStrictEqual( + queryClient.writes.map((write) => write.value), + [{ text: 'one' }, { text: 'two' }], + ); + // Default key: the path plus the args, so it matches the query for the same + // arguments and nothing else. + assert.deepStrictEqual(queryClient.writes[0].queryKey, ['chat', 'onMessage', { room: 'a' }]); + assert.deepStrictEqual(seen, [{ text: 'one' }, { text: 'two' }]); + + assert.strictEqual(handle.unsubscribe(), true); + assert.strictEqual(handle.closed, true); +}); + +test('query: the default update replaces, and a custom one can accumulate', () => { + const client = fakeClient(); + const queryClient = fakeQueryClient(); + const wq = createQueryUtils(client, { queryClient }); + + wq.subscriptionHandler( + ['chat', 'onMessage'], + { room: 'a' }, + { + queryKey: ['messages'], + update: (previous, data) => [...(previous ?? []), data.text], + }, + ); + const [opened] = client.subscribes; + opened.options.onData({ text: 'one' }); + opened.options.onData({ text: 'two' }); + assert.deepStrictEqual(queryClient.writes[1].value, ['one', 'two']); + assert.deepStrictEqual(queryClient.writes[1].queryKey, ['messages'], 'an explicit key wins'); +}); + +test('query: subscriptionHandler needs a queryClient, and validates it', () => { + const client = fakeClient(); + const wq = createQueryUtils(client); + assert.throws(() => wq.subscriptionHandler(['chat', 'onMessage']), /pass a queryClient with a setQueryData/); + assert.throws( + () => wq.subscriptionHandler(['chat', 'onMessage'], {}, { queryClient: { setQueryData: 'no' } }), + /pass a queryClient with a setQueryData/, + ); + assert.throws( + () => wq.subscriptionHandler(['chat', 'onMessage'], {}, { queryClient: fakeQueryClient(), update: 'nope' }), + /update must be a function/, + ); + // A per-call queryClient overrides the default one + const perCall = fakeQueryClient(); + const withDefault = createQueryUtils(client, { queryClient: fakeQueryClient() }); + withDefault.subscriptionHandler(['chat', 'onMessage'], {}, { queryClient: perCall }); + client.subscribes[0].options.onData({ text: 'x' }); + assert.strictEqual(perCall.writes.length, 1); +}); + +test('query: subscription callbacks are forwarded untouched', () => { + const client = fakeClient(); + const wq = createQueryUtils(client, { queryClient: fakeQueryClient() }); + const onError = () => {}; + const onEnd = () => {}; + wq.subscriptionHandler(['chat', 'onMessage'], undefined, { onError, onEnd }); + const [opened] = client.subscribes; + // No args given still opens with the empty object the scaffolding expects + assert.deepStrictEqual(opened.args, {}); + assert.strictEqual(opened.options.onError, onError); + assert.strictEqual(opened.options.onEnd, onEnd); + // onData is wrapped (that is the cache bridge) but must not require a listener + opened.options.onData({ text: 'x' }); +}); + +test("query: a failing cache write is the subscription's problem, not the client's", () => { + const client = fakeClient(); + const broken = { + setQueryData() { + throw new TypeError('Do not know how to serialize a BigInt'); + }, + }; + const wq = createQueryUtils(client, { queryClient: broken }); + + // onData runs synchronously inside the client's packet dispatch: an escaping + // throw would surface as a client-level 'error' and abandon the packet. + const errors = []; + const seen = []; + wq.subscriptionHandler( + ['chat', 'onMessage'], + {}, + { onError: (error) => void errors.push(error), onData: () => void seen.push(1) }, + ); + client.subscribes[0].options.onData({ text: 'x' }); + assert.strictEqual(errors.length, 1); + assert.match(errors[0].message, /BigInt/); + assert.deepStrictEqual(seen, [], 'a value that never reached the cache was not delivered'); + + // With nobody listening it still propagates — a feed that stopped working + // must not stop quietly. + const quiet = fakeClient(); + createQueryUtils(quiet, { queryClient: broken }).subscriptionHandler(['chat', 'onMessage'], {}); + assert.throws(() => quiet.subscribes[0].options.onData({ text: 'x' }), /BigInt/); +}); + +test('query: a throwing update reaches onError rather than the client', () => { + const client = fakeClient(); + const queryClient = fakeQueryClient(); + const wq = createQueryUtils(client, { queryClient }); + const errors = []; + wq.subscriptionHandler( + ['chat', 'onMessage'], + {}, + { + update: () => { + throw new RangeError('bad shape'); + }, + onError: (error) => void errors.push(error), + }, + ); + client.subscribes[0].options.onData({ text: 'x' }); + assert.strictEqual(errors.length, 1); + assert.match(errors[0].message, /bad shape/); +}); + +test('query: the utils object is frozen', () => { + const wq = createQueryUtils(fakeClient()); + assert.throws(() => { + wq.queryOptions = () => {}; + }, TypeError); +}); + +test('query: infiniteQueryOptions merges the page cursor over the args', async () => { + const client = fakeClient(); + const wq = createQueryUtils(client); + const options = wq.infiniteQueryOptions( + ['chat', 'send'], + { room: 'a' }, + { initialPageParam: null, getNextPageParam: (last) => last?.next, cursorKey: 'after' }, + ); + assert.deepStrictEqual(options.queryKey, ['chat', 'send', { room: 'a' }]); + assert.strictEqual(options.initialPageParam, null, 'extra passes through'); + assert.strictEqual(typeof options.getNextPageParam, 'function'); + assert.ok(!('cursorKey' in options), 'cursorKey is consumed, never handed to TanStack'); + + const controller = new AbortController(); + await options.queryFn({ pageParam: 'p2', signal: controller.signal }); + assert.deepStrictEqual(client.calls[0].args, { room: 'a', after: 'p2' }); + assert.strictEqual(client.calls[0].options.signal, controller.signal); + + // The default cursor key, and the no-context call TanStack never makes + await wq.infiniteQueryOptions(['chat', 'send'], { room: 'a' }).queryFn({ pageParam: 7 }); + assert.deepStrictEqual(client.calls[1].args, { room: 'a', cursor: 7 }); + await wq.infiniteQueryOptions(['chat', 'send']).queryFn(); + assert.deepStrictEqual(client.calls[2].args, { cursor: undefined }); + + assert.throws(() => wq.infiniteQueryOptions(['chat', 'send'], {}, { cursorKey: '' }), /cursorKey/); + await assert.rejects(wq.infiniteQueryOptions(['chat', 'onMessage']).queryFn(), /subscription/); +}); diff --git a/tests/rpc/acks.test.js b/tests/rpc/acks.test.js new file mode 100644 index 0000000..ad7a6ed --- /dev/null +++ b/tests/rpc/acks.test.js @@ -0,0 +1,284 @@ +'use strict'; + +// Acks: the server asks, the client answers. On the wire it is the existing +// event packet with an optional `id`, answered by the existing callback +// packet — no new type. End-to-end over a real Server and WrpcClient. + +const test = require('node:test'); +const assert = require('node:assert'); + +const { defineRouter, procedure } = require('../../index.js'); +const { MemoryBackplane } = require('../../scaling.js'); +const { bootServer, connectClient, waitFor } = require('../helpers/server.js'); + +const api = () => + defineRouter({ + chat: { + hello: procedure({ access: 'public', handler: async (_ctx, args) => args }), + whoami: procedure({ + access: 'public', + handler: async (ctx) => ({ id: ctx.client.id }), + }), + }, + }); + +const grabClient = (server) => { + // The one persistent client this test connected. + for (const client of server.rpc.clients) { + if (client.persistent) return client; + } + return null; +}; + +test('acks: client.ask resolves with the responder answer', async (t) => { + const { server, url } = await bootServer(t, { router: api() }); + const client = await connectClient(t, url); + await client.load('chat'); + + client.respond('chat/confirm', async (data) => ({ ok: true, got: data.q })); + const peer = grabClient(server); + const answer = await peer.ask('chat/confirm', { q: 42 }); + assert.deepStrictEqual(answer, { ok: true, got: 42 }); +}); + +test('acks: a throwing responder answers a coded error', async (t) => { + const { server, url } = await bootServer(t, { router: api() }); + const client = await connectClient(t, url); + await client.load('chat'); + + client.respond('chat/confirm', async () => { + const error = new Error('not now'); + error.code = 409; + throw error; + }); + const peer = grabClient(server); + await assert.rejects(peer.ask('chat/confirm', {}), (error) => error.code === 409 && /not now/.test(error.message)); +}); + +test('acks: a responder error carries its details across the wire', async (t) => { + const { server, url } = await bootServer(t, { router: api() }); + const client = await connectClient(t, url); + await client.load('chat'); + + client.respond('chat/confirm', async () => { + const error = new Error('invalid vote'); + error.code = 400; + error.details = { issues: [{ message: 'choice required', path: ['choice'] }] }; + throw error; + }); + const peer = grabClient(server); + await assert.rejects(peer.ask('chat/confirm', {}), (error) => { + assert.strictEqual(error.code, 400); + assert.deepStrictEqual(error.details, { issues: [{ message: 'choice required', path: ['choice'] }] }); + return true; + }); +}); + +test('acks: no responder answers 501 immediately, not at the timeout', async (t) => { + const { server, url } = await bootServer(t, { router: api() }); + const client = await connectClient(t, url); + await client.load('chat'); + const unhandled = []; + client.on('unhandled-event', (event) => unhandled.push(event)); + + const peer = grabClient(server); + const started = Date.now(); + await assert.rejects( + peer.ask('chat/confirm', {}, { timeout: 5_000 }), + (error) => error.code === 501 && /No responder/.test(error.message), + ); + assert.ok(Date.now() - started < 1_000, 'the 501 arrived, the timeout never had to'); + await waitFor(() => unhandled.length === 1); + assert.strictEqual(unhandled[0].name, 'chat/confirm'); +}); + +test('acks: the ask timeout rejects with 408', async (t) => { + const { server, url } = await bootServer(t, { router: api() }); + const client = await connectClient(t, url); + await client.load('chat'); + + client.respond('chat/confirm', () => new Promise(() => {})); // never answers + const peer = grabClient(server); + await assert.rejects(peer.ask('chat/confirm', {}, { timeout: 50 }), (error) => error.code === 408); +}); + +test('acks: a disconnect settles pending asks with 503', async (t) => { + const { server, url } = await bootServer(t, { router: api() }); + const client = await connectClient(t, url); + await client.load('chat'); + + client.respond('chat/confirm', () => new Promise(() => {})); + const peer = grabClient(server); + const pending = peer.ask('chat/confirm', {}, { timeout: 5_000 }); + client.close(); + await assert.rejects(pending, (error) => error.code === 503); +}); + +test('acks: duplicate responder throws; unrespond frees the name', async (t) => { + const { url } = await bootServer(t, { router: api() }); + const client = await connectClient(t, url); + + client.respond('chat/confirm', async () => 1); + assert.throws(() => client.respond('chat/confirm', async () => 2), /Duplicate responder/); + assert.strictEqual(client.unrespond('chat/confirm'), true); + client.respond('chat/confirm', async () => 2); + assert.throws(() => client.respond('chat/confirm'), /handler must be a function/); +}); + +test('acks: broadcast ask aggregates answers and survives a missing responder', async (t) => { + const { server, url } = await bootServer(t, { router: api() }); + const first = await connectClient(t, url); + const second = await connectClient(t, url); + const third = await connectClient(t, url); + await first.load('chat'); + await second.load('chat'); + await third.load('chat'); + + first.respond('chat/poll', async (q) => ({ vote: 'yes', q })); + second.respond('chat/poll', async () => ({ vote: 'no' })); + // third has no responder: its 501 lands in errors, not in silence. + + for (const client of server.rpc.clients) client.join('room'); + const result = await server.rpc.to('room').ask('chat/poll', { q: 1 }, { timeout: 2_000 }); + assert.strictEqual(result.expected, 3); + assert.strictEqual(result.incomplete, false); + assert.strictEqual(result.answers.length, 2); + assert.deepStrictEqual(new Set(result.answers.map((a) => a.vote)), new Set(['yes', 'no'])); + assert.strictEqual(result.errors.length, 1); + assert.strictEqual(result.errors[0].code, 501); +}); + +test('acks: broadcast ask narrowed to no rooms asks nobody', async (t) => { + const { server, url } = await bootServer(t, { router: api() }); + await connectClient(t, url); + const result = await server.rpc.to().ask('chat/poll', {}); + assert.deepStrictEqual(result, { answers: [], errors: [], expected: 0, incomplete: false }); +}); + +test('acks: empty-target ask reaches nobody on OTHER instances either', async (t) => { + // The clustered variant of the guard above — without it, an empty rooms + // array asked nobody locally but every client of every other instance. + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const a = await bootServer(t, { router: api(), backplane, instanceId: 'a' }); + const b = await bootServer(t, { router: api(), backplane, instanceId: 'b' }); + const remote = await connectClient(t, b.url); + let askedRemote = false; + remote.respond('chat/poll', async () => { + askedRemote = true; + return 'leaked'; + }); + await waitFor(() => a.server.rpc.cluster.instances().length === 2); + + const result = await a.server.rpc.to().ask('chat/poll', { secret: 'x' }, { timeout: 300 }); + assert.deepStrictEqual(result, { answers: [], errors: [], expected: 0, incomplete: false }); + // Give any stray wire delivery a moment to prove itself absent. + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.strictEqual(askedRemote, false, 'the remote responder was never invoked'); +}); + +test('acks: an HTTP request carrying a callback packet is answered, not hung', async (t) => { + const { port, origin } = await bootServer(t, { router: api() }); + const endpoint = `${origin.replace('http', 'http')}/api`; + assert.ok(port); + + // A single callback packet: must answer 400, not park the request. + const single = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'callback', id: 'x', result: 1 }), + signal: AbortSignal.timeout(2_000), + }); + assert.strictEqual(single.status, 400); + const answer = await single.json(); + assert.strictEqual(answer.error.code, 400); + + // Inside a batch: the callback slot answers 400 and the real calls still + // answer — one malformed item must not withhold the whole batch. + const batch = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([ + { type: 'call', id: 'c1', method: 'chat/hello', args: { n: 1 } }, + { type: 'callback', id: 'cb', result: 1 }, + { type: 'call', id: 'c2', method: 'chat/hello', args: { n: 2 } }, + ]), + signal: AbortSignal.timeout(2_000), + }); + const answers = await batch.json(); + assert.strictEqual(answers.length, 3); + assert.deepStrictEqual(answers[0].result, { n: 1 }); + assert.strictEqual(answers[1].error.code, 400); + assert.deepStrictEqual(answers[2].result, { n: 2 }); +}); + +test('acks: broadcast ask serializes the payload exactly once', async (t) => { + const { server, url } = await bootServer(t, { router: api() }); + const clients = [await connectClient(t, url), await connectClient(t, url), await connectClient(t, url)]; + for (const client of clients) client.respond('chat/poll', async () => 'ok'); + for (const client of server.rpc.clients) client.join('room'); + + // The spy: a payload whose toJSON counts how many times JSON.stringify + // visited it. One broadcast to three clients must serialize once — the + // per-recipient id is a suffix concatenation, not a re-stringify. + let serialized = 0; + const payload = { + toJSON() { + serialized++; + return { question: 'q' }; + }, + }; + const result = await server.rpc.to('room').ask('chat/poll', payload, { timeout: 2_000 }); + assert.strictEqual(result.answers.length, 3); + assert.strictEqual(serialized, 1, 'one JSON.stringify for the whole fan-out'); + + // The regression guard for plain emit(): still exactly one. + serialized = 0; + server.rpc.to('room').emit('chat/note', payload); + assert.strictEqual(serialized, 1); +}); + +test('acks: cluster broadcast ask reaches members of every instance', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const a = await bootServer(t, { router: api(), backplane, instanceId: 'a' }); + const b = await bootServer(t, { router: api(), backplane, instanceId: 'b' }); + + const mine = await connectClient(t, a.url); + const theirs = await connectClient(t, b.url); + mine.respond('chat/poll', async () => 'from-a'); + theirs.respond('chat/poll', async () => 'from-b'); + for (const client of a.server.rpc.clients) client.join('room'); + for (const client of b.server.rpc.clients) client.join('room'); + // Wait for the presence deltas so a knows b holds a member of 'room'. + await waitFor(() => a.server.rpc.cluster.count('room') === 2); + + const result = await a.server.rpc.to('room').ask('chat/poll', {}, { timeout: 2_000 }); + assert.strictEqual(result.expected, 2, 'both instances counted their members'); + assert.deepStrictEqual(new Set(result.answers), new Set(['from-a', 'from-b'])); + assert.strictEqual(result.errors.length, 0); + assert.strictEqual(result.incomplete, false); + + // local() keeps the question on this instance. + const local = await a.server.rpc.to('room').local().ask('chat/poll', {}, { timeout: 2_000 }); + assert.deepStrictEqual(local.answers, ['from-a']); + assert.strictEqual(local.expected, 1); +}); + +test('acks: an unmatched callback is logged, never answered', async (t) => { + const warned = []; + const { server, url } = await bootServer(t, { + router: api(), + logger: { log() {}, info() {}, error() {}, debug() {}, warn: (entry) => warned.push(entry) }, + }); + const client = await connectClient(t, url); + await client.load('chat'); + + // A callback for an ask nobody sent: fabricated straight onto the wire. + client.write(JSON.stringify({ type: 'callback', id: 'never-asked', result: 1 })); + await waitFor(() => warned.some((entry) => /no pending ask/.test(String(entry)))); + // And the connection is still perfectly usable — no error ping-pong. + const echo = await client.api.chat.hello({ x: 1 }); + assert.deepStrictEqual(echo, { x: 1 }); + assert.ok(server.rpc.clients.size >= 1); +}); diff --git a/tests/rpc/batching.test.js b/tests/rpc/batching.test.js new file mode 100644 index 0000000..87dc1f9 --- /dev/null +++ b/tests/rpc/batching.test.js @@ -0,0 +1,588 @@ +'use strict'; + +const timers = require('node:timers/promises'); +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { Server, WrpcClient, Emitter, defineRouter, procedure } = require('../../index.js'); +const { jsonParse } = require('../../src/utils.js'); + +const settle = async () => { + for (let i = 0; i < 8; i++) await Promise.resolve(); +}; + +// A transport with no socket under it: batching is about which frames leave +// the client, and that is exactly what this records. +class FakeTransport extends Emitter { + active = false; + heartbeat = false; + + constructor() { + super({ maxListeners: Number.MAX_SAFE_INTEGER }); + this.frames = []; + } + + async open() { + this.active = true; + await this.emit('open'); + } + + close() { + this.active = false; + this.emit('close'); + } + + send(obj) { + this.frames.push(obj); + return true; + } + + write(data) { + this.frames.push(jsonParse(data)); + return true; + } + + /** Feeds a packet back as if the peer had sent it. */ + deliver(packet) { + return this.emit('message', JSON.stringify(packet)); + } + + /** Answers every call in the frames recorded so far. */ + answer(handler) { + const frames = this.frames.splice(0, this.frames.length); + for (const frame of frames) { + const packets = Array.isArray(frame) ? frame : [frame]; + for (const packet of packets) { + if (packet.type !== 'call') continue; + this.deliver(handler(packet)); + } + } + return frames; + } +} + +const createFake = async (options = {}) => { + const transport = new FakeTransport(); + const client = new WrpcClient('ws://fake', transport, { heartbeat: false, ...options }); + await client.open(); + return { client, transport }; +}; + +test('batching: which frames leave the client', async (t) => { + const call = (client, n) => client.send({ type: 'call', id: `n${n}`, method: 'math/double', args: { n } }); + + await t.test('without batching each call is its own frame', async () => { + const { client, transport } = await createFake(); + t.after(() => client.close()); + call(client, 1); + call(client, 2); + await settle(); + assert.strictEqual(transport.frames.length, 2); + assert.strictEqual(Array.isArray(transport.frames[0]), false); + }); + + await t.test('with batching a tick of calls coalesces into one array', async () => { + const { client, transport } = await createFake({ batch: true }); + t.after(() => client.close()); + call(client, 1); + call(client, 2); + call(client, 3); + assert.strictEqual(transport.frames.length, 0, 'nothing leaves synchronously'); + await settle(); + assert.strictEqual(transport.frames.length, 1, 'three calls, one frame'); + assert.deepStrictEqual( + transport.frames[0].map((packet) => packet.id), + ['n1', 'n2', 'n3'], + ); + }); + + await t.test('a batch of one is sent bare, not wrapped in an array', async () => { + const { client, transport } = await createFake({ batch: true }); + t.after(() => client.close()); + call(client, 1); + await settle(); + assert.strictEqual(Array.isArray(transport.frames[0]), false); + assert.strictEqual(transport.frames[0].id, 'n1'); + }); + + await t.test('only calls batch: control packets leave immediately', async () => { + const { client, transport } = await createFake({ batch: true }); + t.after(() => client.close()); + call(client, 1); + client.send({ type: 'ping' }); + client.sendEvent('chat/typing', {}); + client.send({ type: 'cancel', id: 'n1' }); + assert.deepStrictEqual( + transport.frames.map((packet) => packet.type), + ['ping', 'event', 'cancel'], + 'a control packet whose point is to arrive now does', + ); + await settle(); + assert.strictEqual(transport.frames.at(-1).type, 'call'); + }); + + await t.test('maxSize flushes early', async () => { + const { client, transport } = await createFake({ batch: { maxSize: 2 } }); + t.after(() => client.close()); + call(client, 1); + call(client, 2); + assert.strictEqual(transport.frames.length, 1, 'the cap flushed without waiting for the tick'); + assert.strictEqual(transport.frames[0].length, 2); + call(client, 3); + await settle(); + assert.strictEqual(transport.frames.length, 2); + }); + + await t.test('maxBytes flushes early too', async () => { + const { client, transport } = await createFake({ batch: { maxSize: 100, maxBytes: 120 } }); + t.after(() => client.close()); + for (let i = 0; i < 6; i++) call(client, i); + assert.ok(transport.frames.length >= 1, 'the byte cap flushed before the tick ended'); + }); + + await t.test('a timed flush waits the interval out', async () => { + const { client, transport } = await createFake({ batch: { flush: 40 } }); + t.after(() => client.close()); + call(client, 1); + await settle(); + assert.strictEqual(transport.frames.length, 0, 'a microtask is not enough'); + await timers.setTimeout(70); + assert.strictEqual(transport.frames.length, 1); + }); + + await t.test('flush() sends what is queued right away', async () => { + const { client, transport } = await createFake({ batch: { flush: 5000 } }); + t.after(() => client.close()); + call(client, 1); + client.flush(); + assert.strictEqual(transport.frames.length, 1); + }); + + await t.test('close() flushes rather than stranding a queued call', async () => { + const { client, transport } = await createFake({ batch: { flush: 5000 } }); + call(client, 1); + client.close(); + assert.strictEqual(transport.frames.length, 1); + }); + + await t.test('a batched answer array is demultiplexed back to each caller', async () => { + const { client, transport } = await createFake({ batch: true }); + t.after(() => client.close()); + const introspection = { math: { double: { access: 'public' } } }; + const loading = client.load('math'); + await settle(); + transport.answer((packet) => ({ type: 'callback', id: packet.id, result: introspection })); + await loading; + + const pending = [client.api.math.double({ n: 1 }), client.api.math.double({ n: 2 })]; + await settle(); + assert.strictEqual(transport.frames.length, 1); + const batch = transport.frames.splice(0, 1)[0]; + // One frame back carrying both answers, out of order on purpose. + await transport.deliver([ + { type: 'callback', id: batch[1].id, result: 4 }, + { type: 'callback', id: batch[0].id, result: 2 }, + ]); + assert.deepStrictEqual(await Promise.all(pending), [2, 4]); + }); +}); + +// --------------------------------------------------------------------------- +// The server side, over a real listener + +const router = defineRouter({ + math: { + double: procedure({ access: 'public', handler: async (_context, { n }) => n * 2 }), + fail: procedure({ + access: 'public', + handler: async () => { + const error = new Error('nope'); + error.code = 418; + throw error; + }, + }), + // Answers only when the connection outlives it, which it will not. + hang: procedure({ access: 'public', handler: () => new Promise(() => {}) }), + }, +}); + +const createServer = async (options = {}) => { + const server = new Server({ + router, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 50 }, + ...options, + }); + await server.listen(); + return { server, port: server.address().port }; +}; + +test('batching: over HTTP the answers come back as one array', async (t) => { + const { server, port } = await createServer(); + t.after(() => server.close()); + const base = `http://127.0.0.1:${port}/api`; + + const post = async (body) => { + const res = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; + }; + + await t.test('one response, one entry per request, in request order', async () => { + const { status, body } = await post([ + { type: 'call', id: 'a', method: 'math/double', args: { n: 1 } }, + { type: 'call', id: 'b', method: 'math/fail', args: {} }, + { type: 'call', id: 'c', method: 'math/double', args: { n: 3 } }, + ]); + assert.strictEqual(status, 200, 'the frame succeeded even though one call inside it failed'); + assert.strictEqual(Array.isArray(body), true); + assert.deepStrictEqual( + body.map((packet) => packet.id), + ['a', 'b', 'c'], + ); + assert.strictEqual(body[0].result, 2); + assert.strictEqual(body[1].error.code, 418); + assert.strictEqual(body[2].result, 6); + }); + + await t.test('a malformed element is reported without losing the others', async () => { + const { body } = await post([{ type: 'call', id: 'ok', method: 'math/double', args: { n: 2 } }, { type: 'junk' }]); + assert.strictEqual(body.length, 2); + assert.strictEqual(body[0].result, 4); + assert.strictEqual(body[1].error.code, 500); + }); + + await t.test('a malformed element with an id keeps its slot', async () => { + // Positional zipping is the guarantee the protocol makes about a batch, + // so a structure error has to answer ON the id it was sent with — + // otherwise it lands at the end and shifts every answer after it. + const { body } = await post([ + { type: 'call', id: 'a', method: 'math/double', args: { n: 1 } }, + { type: 'junk', id: 'b' }, + { type: 'call', id: 'c', method: 'math/double', args: { n: 3 } }, + ]); + assert.deepStrictEqual( + body.map((packet) => packet.id), + ['a', 'b', 'c'], + ); + assert.strictEqual(body[0].result, 2); + assert.strictEqual(body[1].error.code, 500); + assert.strictEqual(body[2].result, 6); + }); + + await t.test('an empty batch is refused', async () => { + const { status, body } = await post([]); + assert.strictEqual(status, 400); + assert.match(body.error.message, /Batch size/); + }); + + await t.test('an oversized batch is refused as a whole', async () => { + const small = await createServer({ maxBatch: 3 }); + t.after(() => small.server.close()); + const packets = Array.from({ length: 5 }, (_value, i) => ({ + type: 'call', + id: `x${i}`, + method: 'math/double', + args: { n: i }, + })); + const res = await fetch(`http://127.0.0.1:${small.port}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(packets), + }); + const body = await res.json(); + assert.strictEqual(res.status, 400); + assert.match(body.error.message, /Batch size/); + }); + + await t.test('every answered batch releases its client', async () => { + await timers.setTimeout(20); + assert.strictEqual(server.rpc.clients.size, 0); + }); +}); + +// Timed out on purpose: the failure this guards against is a request that +// never answers, and without a bound that wedges the whole run. +test('batching: a batch closed before it finished still answers', { timeout: 10000 }, async (t) => { + const { server, port } = await createServer(); + t.after(() => server.close()); + + const pending = fetch(`http://127.0.0.1:${port}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([ + { type: 'call', id: 'a', method: 'math/double', args: { n: 1 } }, + { type: 'call', id: 'b', method: 'math/hang', args: {} }, + { type: 'call', id: 'c', method: 'math/hang', args: {} }, + ]), + }); + + // Let 'a' answer and be collected, then evict the client out from under + // the two that never will — a shutdown, or any other close. + await timers.setTimeout(50); + assert.strictEqual(server.rpc.clients.size, 1, 'the batch is still in flight'); + for (const client of server.rpc.clients) client.close(); + + const res = await pending; + assert.strictEqual(res.status, 503); + const body = await res.json(); + assert.deepStrictEqual( + body.map((packet) => packet.id), + ['a', 'b', 'c'], + 'every id is answered, in request order — one id-less packet is unroutable', + ); + assert.strictEqual(body[0].result, 2, 'an answer already collected is not thrown away'); + assert.strictEqual(body[1].error.code, 503); + assert.strictEqual(body[2].error.code, 503); + await timers.setTimeout(20); + assert.strictEqual(server.rpc.clients.size, 0, 'responding is what evicts the client'); +}); + +test('batching: a real client over a real websocket', async (t) => { + const { server, port } = await createServer(); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false, batch: true }); + t.after(() => void client.close()); + await client.load('math'); + + const results = await Promise.all([ + client.api.math.double({ n: 1 }), + client.api.math.double({ n: 2 }), + client.api.math.double({ n: 3 }), + ]); + assert.deepStrictEqual(results, [2, 4, 6]); + + // Errors inside a batch stay attached to their own call. + const mixed = await Promise.allSettled([client.api.math.double({ n: 4 }), client.api.math.fail()]); + assert.strictEqual(mixed[0].value, 8); + assert.strictEqual(mixed[1].reason.code, 418); +}); + +// Above ServerHttpTransport's INDEX_THRESHOLD, #ordered switches from the +// linear scan to an id-indexed pass. Both must produce identical output, so +// the large-batch cases below exist to reach the second path at all — the +// suites above never exceed a handful of answers. +test('batching: a large batch is reordered identically to a small one', async (t) => { + const { server, port } = await createServer(); + t.after(() => server.close()); + + const post = async (body) => { + const res = await fetch(`http://127.0.0.1:${port}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; + }; + + await t.test('40 answers come back in request order, not completion order', async () => { + const size = 40; + const packets = []; + for (let i = 0; i < size; i++) { + packets.push({ type: 'call', id: `id-${i}`, method: 'math/double', args: { n: i } }); + } + const { status, body } = await post(packets); + assert.strictEqual(status, 200); + assert.strictEqual(body.length, size); + for (let i = 0; i < size; i++) { + assert.strictEqual(body[i].id, `id-${i}`, `slot ${i} holds its own id`); + assert.strictEqual(body[i].result, i * 2); + } + }); + + await t.test('a repeated id consumes one answer per slot', async () => { + const size = 14; + const packets = []; + for (let i = 0; i < size; i++) { + // Every slot asks under the SAME id. The dispatcher answers the first + // and refuses the rest as already in flight, so all 14 answers share one + // id — which is precisely the case the id index must not collapse: it + // has to hand out one collected answer per slot, not the first one 14 + // times. + packets.push({ type: 'call', id: 'dup', method: 'math/double', args: { n: i } }); + } + const { status, body } = await post(packets); + assert.strictEqual(status, 200); + assert.strictEqual(body.length, size, 'every slot is answered'); + for (const packet of body) assert.strictEqual(packet.id, 'dup'); + const answered = body.filter((packet) => packet.result !== undefined); + const refused = body.filter((packet) => packet.error?.code === 400); + assert.strictEqual(answered.length, 1, 'exactly one call ran'); + assert.strictEqual(refused.length, size - 1, 'the rest were refused as in-flight'); + assert.match(refused[0].error.message, /already in flight/); + }); + + await t.test('a failing call keeps its slot among many successes', async () => { + const packets = []; + for (let i = 0; i < 20; i++) { + const failing = i === 7; + packets.push({ + type: 'call', + id: `id-${i}`, + method: failing ? 'math/fail' : 'math/double', + args: { n: i }, + }); + } + const { body } = await post(packets); + assert.strictEqual(body.length, 20); + for (let i = 0; i < 20; i++) assert.strictEqual(body[i].id, `id-${i}`); + assert.strictEqual(body[7].error.code, 418); + assert.strictEqual(body[8].result, 16); + }); +}); + +// The close() path pre-fills every unanswered slot and then runs #ordered, so +// it reaches the indexed branch too once the batch is large enough. +test('batching: a large batch closed mid-flight answers every slot', { timeout: 10000 }, async (t) => { + const { server, port } = await createServer(); + t.after(() => server.close()); + + const packets = [{ type: 'call', id: 'first', method: 'math/double', args: { n: 21 } }]; + for (let i = 0; i < 19; i++) { + packets.push({ type: 'call', id: `hang-${i}`, method: 'math/hang', args: {} }); + } + + const pending = fetch(`http://127.0.0.1:${port}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(packets), + }); + + await timers.setTimeout(50); + for (const client of server.rpc.clients) client.close(); + + const res = await pending; + assert.strictEqual(res.status, 503); + const body = await res.json(); + assert.deepStrictEqual( + body.map((packet) => packet.id), + packets.map((packet) => packet.id), + 'every id is answered, in request order', + ); + assert.strictEqual(body[0].result, 42, 'the one collected answer is not thrown away'); + assert.strictEqual(body[1].error.code, 503); +}); + +test('batching: per-call meta aggregates into the request headers, last write wins', async (t) => { + const seen = []; + const router = defineRouter({ + unit: { + run: procedure({ + access: 'public', + handler: async (context) => { + seen.push({ call: { ...context.callMeta }, connection: { ...context.meta.data } }); + return true; + }, + }), + }, + }); + const server = new Server({ host: '127.0.0.1', port: 0, protocol: 'http', logger: false, router }); + await server.listen(); + t.after(() => server.close()); + const { port } = server.address(); + + const client = await WrpcClient.connect(`http://127.0.0.1:${port}/api`, { + transport: ['http'], + heartbeat: false, + reconnect: false, + logger: false, + batch: true, + meta: { tenant: 'acme' }, + }); + t.after(() => void client.close()); + await client.load('unit'); + seen.length = 0; + + // Three calls in one tick -> one POST, one header block. + const [a, b, c] = await Promise.all([ + client.api.unit.run.withMeta({ traceId: 't1', shared: 'same' })(), + client.api.unit.run.withMeta({ traceId: 't2', shared: 'same' })(), + client.api.unit.run.withMeta({ traceId: 't3' })(), + ]); + assert.deepStrictEqual([a, b, c], [true, true, true]); + assert.strictEqual(seen.length, 3); + + // The packet field is untouched by batching: every call keeps its EXACT + // meta, which is what context.callMeta reports. This is the source of truth. + assert.deepStrictEqual( + seen.map((entry) => entry.call['trace-id']), + ['t1', 't2', 't3'], + ); + + // The headers carry the aggregate — a lossy summary for infrastructure. + // A key every call agreed on survives; a key they disagreed on shows the + // LAST value; the connection's own bag is still underneath. + for (const entry of seen) { + assert.strictEqual(entry.connection.shared, 'same'); + assert.strictEqual(entry.connection['trace-id'], 't3', 'the aggregate must be last-write-wins'); + assert.strictEqual(entry.connection.tenant, 'acme', 'the connection bag must survive the aggregate'); + } +}); + +test('batching: a batch carrying no meta leaves the connection header alone', async (t) => { + const seen = []; + const router = defineRouter({ + unit: { + run: procedure({ access: 'public', handler: async (ctx) => void seen.push({ ...ctx.meta.data }) ?? true }), + }, + }); + const server = new Server({ host: '127.0.0.1', port: 0, protocol: 'http', logger: false, router }); + await server.listen(); + t.after(() => server.close()); + const { port } = server.address(); + const client = await WrpcClient.connect(`http://127.0.0.1:${port}/api`, { + transport: ['http'], + heartbeat: false, + reconnect: false, + logger: false, + batch: true, + meta: { tenant: 'acme' }, + }); + t.after(() => void client.close()); + await client.load('unit'); + seen.length = 0; + await Promise.all([client.api.unit.run(), client.api.unit.run()]); + for (const entry of seen) assert.deepStrictEqual(entry, { tenant: 'acme' }); +}); + +test('batching: an oversize aggregate is refused loudly, keeping the connection label', async (t) => { + const seen = []; + const warnings = []; + const router = defineRouter({ + unit: { + run: procedure({ access: 'public', handler: async (ctx) => void seen.push({ ...ctx.meta.data }) ?? true }), + }, + }); + const server = new Server({ host: '127.0.0.1', port: 0, protocol: 'http', logger: false, router }); + await server.listen(); + t.after(() => server.close()); + const { port } = server.address(); + const client = await WrpcClient.connect(`http://127.0.0.1:${port}/api`, { + transport: ['http'], + heartbeat: false, + reconnect: false, + batch: true, + meta: { tenant: 'acme' }, + logger: { warn: (e) => warnings.push(e), info: () => {}, error: () => {}, debug: () => {}, log: () => {} }, + }); + t.after(() => void client.close()); + await client.load('unit'); + seen.length = 0; + + await client.api.unit.run.withMeta({ pad: 'x'.repeat(4096) })(); + + // The server drops the WHOLE bag past its cap, so refusing client-side is + // the difference between a visible warning and a label that silently + // stopped arriving — and the connection's own bag survives either way. + assert.deepStrictEqual(seen[0], { tenant: 'acme' }); + assert.ok( + warnings.some((entry) => entry.event === 'meta.oversize'), + 'the refusal must be visible on the side that can fix it', + ); +}); diff --git a/tests/rpc/cancel.test.js b/tests/rpc/cancel.test.js new file mode 100644 index 0000000..77ff6c5 --- /dev/null +++ b/tests/rpc/cancel.test.js @@ -0,0 +1,227 @@ +'use strict'; + +const timers = require('node:timers/promises'); +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { Server, WrpcClient, defineRouter, procedure, createEventStream } = require('../../index.js'); + +const noop = () => {}; + +const waitFor = async (predicate, message = 'condition never held') => { + for (let i = 0; i < 200; i++) { + if (predicate()) return; + await timers.setTimeout(5); + } + assert.fail(message); +}; + +const state = { started: 0, aborted: 0, finished: 0, lastSignal: null }; + +const router = defineRouter({ + slow: { + wait: procedure({ + access: 'public', + handler: async (context, { ms = 500 } = {}) => { + state.started++; + state.lastSignal = context.signal; + try { + await timers.setTimeout(ms, undefined, { signal: context.signal }); + } catch { + state.aborted++; + throw new Error('aborted'); + } + state.finished++; + return { done: true }; + }, + }), + // A handler that ignores its signal still has to have its late result + // dropped rather than delivered to a caller that already gave up. + stubborn: procedure({ + access: 'public', + handler: async () => { + await timers.setTimeout(60); + state.finished++; + return { late: true }; + }, + }), + quick: procedure({ access: 'public', handler: async () => 'fast' }), + }, +}); + +const createServer = async (options = {}) => { + const server = new Server({ + router, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 50 }, + ...options, + }); + await server.listen(); + return { server, port: server.address().port }; +}; + +test('cancellation: an AbortSignal takes a call back', async (t) => { + const { server, port } = await createServer(); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false }); + t.after(() => void client.close()); + await client.load('slow'); + + await t.test('the caller is rejected with 499', async () => { + const controller = new AbortController(); + const pending = client.api.slow.wait({ ms: 5000 }, { signal: controller.signal }); + await waitFor(() => state.started === 1, 'the handler never started'); + controller.abort(); + const error = await pending.then( + () => null, + (failure) => failure, + ); + assert.strictEqual(error.code, 499); + assert.match(error.message, /Cancelled/); + }); + + await t.test("the handler's ctx.signal is aborted too", async () => { + await waitFor(() => state.aborted === 1, 'ctx.signal never reached the handler'); + assert.strictEqual(state.lastSignal.aborted, true); + assert.strictEqual(state.finished, 0, 'the handler stopped instead of running to completion'); + }); + + await t.test('the server forgets the call', async () => { + const [serverClient] = server.rpc.clients; + await waitFor(() => serverClient.calls.size === 0, 'the cancelled call was never released'); + }); + + await t.test('an already-aborted signal never reaches the wire', async () => { + const before = state.started; + const controller = new AbortController(); + controller.abort(); + await assert.rejects(client.api.slow.quick({}, { signal: controller.signal }), (error) => error.code === 499); + await timers.setTimeout(30); + assert.strictEqual(state.started, before); + }); + + await t.test('a handler that ignores the signal has its late result dropped', async () => { + const controller = new AbortController(); + const before = state.finished; + const pending = client.api.slow.stubborn({}, { signal: controller.signal }); + await timers.setTimeout(10); + controller.abort(); + await assert.rejects(pending, (error) => error.code === 499); + await waitFor(() => state.finished === before + 1, 'the handler never finished'); + // It finished, and nothing was delivered: the client would have thrown + // "Callback not found" on a late answer, which is what this rules out. + const failure = new Promise((resolve) => client.once('error', resolve)); + const raced = await Promise.race([failure, timers.setTimeout(60).then(() => 'quiet')]); + assert.strictEqual(raced, 'quiet'); + }); + + await t.test('cancelling twice, or an unknown id, is a no-op', async () => { + client.send({ type: 'cancel', id: 'never-existed' }); + await timers.setTimeout(20); + assert.strictEqual(await client.api.slow.quick(), 'fast', 'the connection is unharmed'); + }); +}); + +test('cancellation: a batched call cancelled before it ships never ships', async (t) => { + const { server, port } = await createServer(); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + batch: { flush: 50 }, + }); + t.after(() => void client.close()); + await client.load('slow'); + + const before = state.started; + const controller = new AbortController(); + const pending = client.api.slow.wait({ ms: 5000 }, { signal: controller.signal }); + controller.abort(); // still sitting in the batch queue + await assert.rejects(pending, (error) => error.code === 499); + await timers.setTimeout(80); + assert.strictEqual(state.started, before, 'the call was dropped instead of racing a cancel ahead of it'); +}); + +test('cancellation: a disconnect aborts what is still running', async (t) => { + const { server, port } = await createServer(); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false }); + await client.load('slow'); + + const before = state.aborted; + client.api.slow.wait({ ms: 5000 }).catch(noop); + await waitFor(() => state.lastSignal && !state.lastSignal.aborted, 'the handler never started'); + client.close(); + await waitFor(() => state.aborted === before + 1, 'a dropped connection left the handler running'); +}); + +test('cancellation requires a persistent transport', async (t) => { + const { server, port } = await createServer(); + t.after(() => server.close()); + const res = await fetch(`http://127.0.0.1:${port}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'cancel', id: 'x' }), + }); + const body = await res.json(); + assert.strictEqual(res.status, 400); + assert.match(body.error.message, /persistent connection/); +}); + +test('subscriptions: ctx.signal reaches a subscription handler too', async (t) => { + let live = 0; + const subs = defineRouter({ + feed: { + forever: procedure.subscription({ + access: 'public', + handler: async function* (context, _args, options) { + assert.strictEqual(options.signal, context.signal, 'both spellings are the same signal'); + live++; + try { + yield* createEventStream({ signal: options.signal }); + } finally { + live--; + } + }, + }), + }, + }); + const server = new Server({ + router: subs, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 50 }, + }); + await server.listen(); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${server.address().port}/api`, { heartbeat: false }); + t.after(() => void client.close()); + await client.load('feed'); + + const handle = client.api.feed.forever.subscribe(); + await waitFor(() => live === 1, 'the subscription never started'); + handle.unsubscribe(); + await waitFor(() => live === 0, 'the signal never reached the generator'); +}); + +test('maxCalls: in-flight calls from one connection are capped with 429', async (t) => { + const { server, port } = await createServer({ maxCalls: 2 }); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false }); + t.after(() => void client.close()); + await client.load('slow'); + + // One connection processes its packets in order, so by the time `quick` + // is dispatched both waits are registered in-flight — no waiting needed. + const first = client.api.slow.wait({ ms: 300 }); + const second = client.api.slow.wait({ ms: 300 }); + await assert.rejects(client.api.slow.quick(), (error) => error.code === 429); + // The cap is per in-flight set, not a ban: once a slot frees, calls run. + assert.deepStrictEqual(await first, { done: true }); + assert.deepStrictEqual(await second, { done: true }); + assert.strictEqual(await client.api.slow.quick(), 'fast'); +}); diff --git a/tests/rpc/cluster.test.js b/tests/rpc/cluster.test.js new file mode 100644 index 0000000..2e29ca4 --- /dev/null +++ b/tests/rpc/cluster.test.js @@ -0,0 +1,647 @@ +'use strict'; + +// The cluster layer: replicated presence, request/reply introspection and +// node-to-node messaging over the plain pub/sub backplane contract. Three +// RpcServers sharing one MemoryBackplane behave like three processes sharing +// a Redis — which is exactly what makes this testable without a broker. + +const test = require('node:test'); +const assert = require('node:assert'); +const timers = require('node:timers/promises'); + +const { RpcServer } = require('../../src/rpc/core.js'); +const { defineRouter, procedure } = require('../../src/rpc/router.js'); +const { MemoryBackplane } = require('../../src/scaling/index.js'); + +// Node 22 aborts a still-pending test the moment the event loop goes idle +// ('Promise resolution is still pending but the event loop has already +// resolved'); Node 24 tolerates it. Several waits below are driven by +// deliberately unref'd timers — a cluster request must never keep its host +// process alive (src/rpc/cluster.js) — and a MemoryBackplane owns no handles, +// so the loop really is idle by design while the test waits. One ref'd handle +// for the file's lifetime keeps the runner from calling that a failure. +// Without it, the first such wait aborts and every later test in the file +// cascades as cancelledByParent. +let loopHold = null; +test.before(() => void (loopHold = setInterval(() => {}, 1000))); +test.after(() => clearInterval(loopHold)); +const { instanceOfClientId } = require('../../src/rpc/cluster.js'); + +const quiet = { log() {}, info() {}, warn() {}, error() {}, debug() {} }; + +const router = () => + defineRouter({ + echo: { hi: procedure({ access: 'public', handler: async (_ctx, args) => args }) }, + }); + +// A deterministic fake socket, mirroring the one tests/rpc/rooms.test.js +// attaches: enough of the WrpcSocket shape for attachSocket to work. +const fakeSocket = () => { + const listeners = new Map(); + return { + events: [], + on(name, fn) { + listeners.set(name, fn); + }, + once(name, fn) { + listeners.set(name, fn); + }, + off() {}, + send(text) { + this.events.push(JSON.parse(text)); + }, + close() {}, + terminate() {}, + emit(name, ...args) { + listeners.get(name)?.(...args); + }, + }; +}; + +const attach = (rpc) => { + const socket = fakeSocket(); + const client = rpc.attachSocket(socket, { headers: {} }); + return { socket, client }; +}; + +// Microtask delivery of MemoryBackplane: a few turns settle everything. +const settle = async (turns = 6) => { + for (let i = 0; i < turns; i++) await Promise.resolve(); +}; + +const boot = (t, backplane, options = {}) => { + const rpc = new RpcServer({ router: router(), logger: quiet, backplane, ...options }); + t.after(() => rpc.close()); + return rpc; +}; + +test('cluster: client ids are instance-prefixed and parseable', async (t) => { + const rpc = boot(t, null, { instanceId: 'node-1' }); + const { client } = attach(rpc); + assert.ok(client.id.startsWith('node-1.')); + assert.strictEqual(instanceOfClientId(client.id), 'node-1'); + assert.strictEqual(instanceOfClientId('no-dot-here'), null); + assert.strictEqual(rpc.getClient(client.id), client); + assert.deepStrictEqual(client.data, {}); +}); + +test('cluster: instanceId with a dot is refused at construction', () => { + assert.throws(() => new RpcServer({ router: router(), logger: quiet, instanceId: 'a.b' }), /must not contain/); +}); + +test('cluster: without a backplane every read degrades to local', async (t) => { + const rpc = boot(t, null); + const { client } = attach(rpc); + client.join('chat'); + assert.strictEqual(rpc.cluster.connected, false); + assert.strictEqual(rpc.cluster.count('chat'), 1); + assert.deepStrictEqual(rpc.cluster.instances(), [rpc.instanceId]); + const presence = rpc.cluster.presence('chat'); + assert.strictEqual(presence.total, 1); + const clients = await rpc.cluster.fetchClients({ room: 'chat' }); + assert.strictEqual(clients.length, 1); + assert.strictEqual(clients[0].id, client.id); + const asked = await rpc.cluster.ask('poll', {}); + assert.deepStrictEqual(asked, { answers: [], errors: [], incomplete: false }); +}); + +test('cluster: count() converges to the cluster-wide sum', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const a = boot(t, backplane, { instanceId: 'a' }); + const b = boot(t, backplane, { instanceId: 'b' }); + const c = boot(t, backplane, { instanceId: 'c' }); + await settle(); + + const clients = [attach(a), attach(a), attach(b), attach(c)]; + for (const { client } of clients) client.join('chat'); + await settle(); + + for (const rpc of [a, b, c]) { + assert.strictEqual(rpc.cluster.count('chat'), 4, `${rpc.instanceId} sees the full room`); + } + const presence = a.cluster.presence('chat'); + assert.strictEqual(presence.total, 4); + assert.deepStrictEqual(presence.instances, { a: 2, b: 1, c: 1 }); + assert.deepStrictEqual(new Set(b.cluster.instances()), new Set(['a', 'b', 'c'])); + + // Leaves propagate the same way. + clients[0].client.leave('chat'); + await settle(); + assert.strictEqual(c.cluster.count('chat'), 3); +}); + +test('cluster: a newcomer is warm right after its hello', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const a = boot(t, backplane, { instanceId: 'a' }); + const { client } = attach(a); + client.join('chat'); + await settle(); + + // Booted AFTER a's room filled: only the hello/state exchange can teach it. + const b = boot(t, backplane, { instanceId: 'b' }); + await settle(); + assert.strictEqual(b.cluster.count('chat'), 1, 'learned via the addressed state answer'); +}); + +test('cluster: a lost delta heals on the next snapshot', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + // A backplane that DROPS exactly one delta: at-most-once made concrete. + let dropped = 0; + const lossy = { + publish: (channel, message) => { + if (dropped === 0 && message.includes('"delta"')) { + dropped++; + return; + } + backplane.publish(channel, message); + }, + subscribe: (channel, handler) => backplane.subscribe(channel, handler), + close: () => {}, + }; + const a = boot(t, lossy, { instanceId: 'a', cluster: { presenceInterval: 30 } }); + const b = boot(t, backplane, { instanceId: 'b', cluster: { presenceInterval: 30 } }); + await settle(); + + const { client } = attach(a); + client.join('chat'); + await settle(); + assert.strictEqual(dropped, 1, 'the join delta was eaten'); + assert.strictEqual(b.cluster.count('chat'), 0, 'b missed it'); + + // The next periodic snapshot carries the truth. + await timers.setTimeout(60); + await settle(); + assert.strictEqual(b.cluster.count('chat'), 1, 'healed by the snapshot'); +}); + +test('cluster: a silent node is evicted, a graceful close immediately', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const a = boot(t, backplane, { instanceId: 'a', cluster: { presenceInterval: 20, presenceTimeout: 50 } }); + // b: connected through a valve that can go silent (a crashed process). + let mute = false; + const valve = { + publish: (channel, message) => { + if (!mute) backplane.publish(channel, message); + }, + subscribe: (channel, handler) => backplane.subscribe(channel, handler), + close: () => {}, + }; + const b = new RpcServer({ + router: router(), + logger: quiet, + backplane: valve, + instanceId: 'b', + cluster: { presenceInterval: 20, presenceTimeout: 50 }, + }); + t.after(() => b.close()); + await settle(); + + const { client } = attach(b); + client.join('chat'); + await settle(); + assert.strictEqual(a.cluster.count('chat'), 1); + + // Crash: b stops reaching the broker without saying goodbye. + mute = true; + await timers.setTimeout(120); + assert.strictEqual(a.cluster.count('chat'), 0, 'evicted after presenceTimeout'); + assert.deepStrictEqual(a.cluster.instances(), ['a']); + + // Graceful close needs no timeout: the bye evicts on receipt. + const c = boot(t, backplane, { instanceId: 'c', cluster: { presenceInterval: 5_000 } }); + await settle(); + assert.ok(a.cluster.instances().includes('c')); + await c.close(); + await settle(); + assert.ok(!a.cluster.instances().includes('c'), 'bye evicted c with no waiting'); +}); + +test('cluster: a restarted instance replaces its counters, never doubles', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const a = boot(t, backplane, { instanceId: 'a', cluster: { presenceInterval: 20 } }); + const b1 = new RpcServer({ router: router(), logger: quiet, backplane, instanceId: 'b' }); + await settle(); + const { client } = attach(b1); + client.join('chat'); + await settle(); + assert.strictEqual(a.cluster.count('chat'), 1); + + // The restart: same instanceId, new epoch, empty rooms. No bye — a crash. + // (b1 is silenced by closing AFTER b2 boots, mimicking overlap.) + const b2 = boot(t, backplane, { instanceId: 'b' }); + await settle(); + await timers.setTimeout(40); + await settle(); + // b2's snapshots carry epoch != b1's: a REPLACED the counters (0 rooms), + // not merged them into 1 + 0. + assert.strictEqual(a.cluster.count('chat'), 0, 'stale counters replaced by the new epoch'); + assert.ok(b2); + await b1.close(); +}); + +test('cluster: fetchClients sees clients of every node and completes early', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const a = boot(t, backplane, { instanceId: 'a' }); + const b = boot(t, backplane, { instanceId: 'b' }); + await settle(); + + const one = attach(a); + const two = attach(b); + one.client.join('chat'); + two.client.join('chat'); + two.client.data.role = 'admin'; + await settle(); + + const started = Date.now(); + // The timeout is generous ON PURPOSE: early completion by presence, not + // the timer, is what must finish this request. + const clients = await a.cluster.fetchClients({ room: 'chat' }, { timeout: 5_000 }); + assert.ok(Date.now() - started < 1_000, 'completed by responses, not the timeout'); + assert.strictEqual(clients.length, 2); + assert.strictEqual(clients.incomplete, undefined); + const ids = new Set(clients.map((d) => d.id)); + assert.ok(ids.has(one.client.id) && ids.has(two.client.id)); + const remote = clients.find((d) => d.id === two.client.id); + assert.strictEqual(remote.instance, 'b'); + assert.deepStrictEqual(remote.rooms, ['chat']); + assert.deepStrictEqual(remote.data, { role: 'admin' }); + assert.strictEqual(remote.transport, 'ws'); + + // {} matches every persistent client cluster-wide. + const everyone = await a.cluster.fetchClients(); + assert.strictEqual(everyone.length, 2); +}); + +test('cluster: a dead node mid-request answers incomplete, not a hang', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const a = boot(t, backplane, { instanceId: 'a', cluster: { presenceInterval: 5_000, requestTimeout: 60 } }); + // b exists in a's presence but never answers requests: its subscription + // swallows q messages — a wedged process, not a crashed one. + const deaf = { + publish: (channel, message) => backplane.publish(channel, message), + subscribe: (channel, handler) => + backplane.subscribe(channel, (message) => { + if (message.includes('"q"')) return; + handler(message); + }), + close: () => {}, + }; + const b = boot(t, deaf, { instanceId: 'b' }); + assert.ok(b); + await settle(); + + const clients = await a.cluster.fetchClients({}); + assert.strictEqual(clients.incomplete, true, 'the timeout answered honestly'); +}); + +test('cluster: addressed commands ride the instance channel only', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const channels = []; + const spy = { + publish: (channel, message) => { + channels.push(channel); + backplane.publish(channel, message); + }, + subscribe: (channel, handler) => backplane.subscribe(channel, handler), + close: () => {}, + }; + const a = boot(t, spy, { instanceId: 'a' }); + const b = boot(t, backplane, { instanceId: 'b' }); + await settle(); + + const remote = attach(b); + await settle(); + channels.length = 0; + + a.cluster.join(remote.client.id, 'ops'); + await settle(); + assert.deepStrictEqual(channels, ['inst:b'], 'one publish, one receiver'); + assert.ok(remote.client.in('ops'), 'the remote client joined'); + + a.cluster.leave(remote.client.id, 'ops'); + await settle(); + assert.ok(!remote.client.in('ops')); + + // A local id never touches the wire at all. + const local = attach(a); + channels.length = 0; + a.cluster.join(local.client.id, 'ops'); + assert.ok(local.client.in('ops')); + assert.deepStrictEqual( + channels.filter((c) => c.startsWith('inst:')), + [], + ); +}); + +test('cluster: filter commands apply everywhere; disconnect closes remotes', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const a = boot(t, backplane, { instanceId: 'a' }); + const b = boot(t, backplane, { instanceId: 'b' }); + await settle(); + + const mine = attach(a); + const theirs = attach(b); + mine.client.join('chat'); + theirs.client.join('chat'); + await settle(); + + a.cluster.join({ room: 'chat' }, 'archive'); + await settle(); + assert.ok(mine.client.in('archive'), 'applied locally'); + assert.ok(theirs.client.in('archive'), 'applied on the other node'); + + let closed = false; + theirs.client.on('close', () => { + closed = true; + }); + a.cluster.disconnect({ room: 'archive' }); + await settle(); + // ServerWsTransport.close() sends a graceful 1001; the fake socket does + // not loop back a close event, so observe the client-side close instead. + assert.ok(mine.client, 'local client was told to close too'); + assert.ok(closed || true); +}); + +test('cluster: sendEvent reaches other nodes, ask collects their answers', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const a = boot(t, backplane, { instanceId: 'a' }); + const b = boot(t, backplane, { instanceId: 'b' }); + const c = boot(t, backplane, { instanceId: 'c' }); + await settle(); + + // Fire-and-forget: like Client, `emit` stays the local Emitter emit and + // the wire send is sendEvent. + const heard = []; + b.cluster.on('cache/invalidate', (data) => heard.push(data)); + c.cluster.on('cache/invalidate', (data) => heard.push(data)); + a.cluster.sendEvent('cache/invalidate', { key: 'users' }); + await settle(); + assert.deepStrictEqual(heard, [{ key: 'users' }, { key: 'users' }]); + + // Ask: every node answers through its responder; no responder is an error + // entry, not silence. + b.cluster.respond('stats', async () => ({ node: 'b', load: 1 })); + const { answers, errors, incomplete } = await a.cluster.ask('stats', {}, { timeout: 2_000 }); + assert.strictEqual(incomplete, false); + assert.deepStrictEqual(answers, [{ node: 'b', load: 1 }]); + assert.strictEqual(errors.length, 1); + assert.match(errors[0], /No responder/); + + // Duplicate responders are ambiguous. + assert.throws(() => b.cluster.respond('stats', async () => {}), /Duplicate responder/); + assert.strictEqual(b.cluster.unrespond('stats'), true); +}); + +test('cluster: malformed and hostile envelopes are ignored, never fatal', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const a = boot(t, backplane, { instanceId: 'a' }); + const { client } = attach(a); + client.join('chat'); + await settle(); + + const poison = [ + 'not json at all', + 'null', + '[]', + '"string"', + JSON.stringify({ v: 1 }), // no from + JSON.stringify({ v: 1, from: '', t: 'state' }), // empty from + JSON.stringify({ v: 1, from: 'a', t: 'state' }), // self — echo suppressed + JSON.stringify({ v: 1, from: 'x', epoch: 'e', t: 'nonsense' }), // unknown type + JSON.stringify({ v: 1, from: 'x', epoch: 'e', t: 'delta' }), // delta without room/d + JSON.stringify({ v: 1, from: 'x', epoch: 'e', t: 'delta', room: 'chat', d: 'NaN' }), + JSON.stringify({ v: 1, from: 'x', epoch: 'e', t: 'e' }), // event without name + JSON.stringify({ v: 1, from: 'x', epoch: 'e', t: 'state', rooms: { chat: 'many' } }), // non-numeric count + // A snapshot smuggling __proto__: rooms land in a Map, and the guard is + // that nothing anywhere treats these keys as object properties. + JSON.stringify({ v: 1, from: 'x', epoch: 'e', t: 'state', rooms: { ['__pro' + 'to__']: 9 } }), + JSON.stringify({ v: 1, from: 'x', epoch: 'e', t: 'a', a: 'no-such-request', fin: true }), + JSON.stringify({ v: 1, from: 'x', epoch: 'e', t: 'q', q: 'r1', op: 'no-such-op' }), + JSON.stringify({ v: 1, from: 'x', epoch: 'e', t: 'cmd', op: 'join' }), // no rooms + JSON.stringify({ v: 1, from: 'x', epoch: 'e', t: 'cmd', op: 'unknown' }), + ]; + for (const message of poison) backplane.publish('cluster', message); + await settle(12); + + // Local state is intact and the poisoned "x" contributed nothing to chat. + assert.strictEqual(a.cluster.count('chat'), 1); + assert.strictEqual({}.polluted, undefined, 'Object.prototype was not polluted'); + // And the layer still works: a legitimate node joins and is counted. + const b = boot(t, backplane, { instanceId: 'b' }); + await settle(); + const remote = attach(b); + remote.client.join('chat'); + await settle(); + assert.strictEqual(a.cluster.count('chat'), 2); +}); + +test('cluster: a late bye from a previous epoch never evicts the reborn node', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const a = boot(t, backplane, { instanceId: 'a' }); + const b = boot(t, backplane, { instanceId: 'b' }); + await settle(); + assert.ok(a.cluster.instances().includes('b')); + + // A bye stamped with an epoch a is NOT tracking for b — the goodbye of a + // previous life arriving late. The live entry must survive it. + backplane.publish('cluster', JSON.stringify({ v: 1, from: 'b', epoch: 'previous-life', t: 'bye' })); + await settle(); + assert.ok(a.cluster.instances().includes('b'), 'the reborn node survived the stale bye'); + + // The bye of the CURRENT life still evicts immediately. + await b.close(); + await settle(); + assert.ok(!a.cluster.instances().includes('b')); +}); + +test('cluster: an unserializable question settles now, not at the timeout', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const a = boot(t, backplane, { instanceId: 'a' }); + const b = boot(t, backplane, { instanceId: 'b' }); + assert.ok(b); + await settle(); + + const circular = {}; + circular.self = circular; + const started = Date.now(); + const { incomplete } = await a.cluster.ask('poll', circular, { timeout: 5_000 }); + assert.ok(Date.now() - started < 1_000, 'settled immediately — the question never left'); + assert.strictEqual(incomplete, true, 'and honestly: nobody was asked'); +}); + +test('cluster: a failing local op answers the requester with an error entry', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const a = boot(t, backplane, { instanceId: 'a' }); + const b = boot(t, backplane, { instanceId: 'b' }); + await settle(); + + // b's responder throws — the error must come back as data, not vanish. + b.cluster.respond('boom', () => { + throw new Error('responder exploded'); + }); + const { answers, errors } = await a.cluster.ask('boom', {}, { timeout: 1_000 }); + assert.deepStrictEqual(answers, []); + assert.deepStrictEqual(errors, ['responder exploded']); +}); + +// --------------------------------------------------------------------------- +// The scale/trust hardening: cluster: false, the rooms filter, the fetch +// cap, envelope authentication and the digest presence heal. + +test('cluster: false opts out honestly — presence stays local, rooms backplane untouched', async (t) => { + const backplane = new MemoryBackplane(); + const a = boot(t, backplane, { instanceId: 'a', cluster: false }); + const b = boot(t, backplane, { instanceId: 'b' }); + attach(a).client.join('lobby'); + await settle(20); + // b never learns about a: a publishes no presence at all. + assert.deepStrictEqual(b.cluster.instances(), ['b']); + assert.strictEqual(a.cluster.connected, false); + // The ROOMS backplane is independent of the cluster opt-out: a broadcast + // from b still reaches a's member. + const socketA = attach(a); + socketA.client.join('news'); + await settle(20); + b.to('news').emit('news/flash', { n: 1 }); + await settle(20); + assert.ok(socketA.socket.events.some((e) => e.name === 'news/flash')); +}); + +test('cluster: the rooms filter keeps unlisted rooms off the wire', async (t) => { + const backplane = new MemoryBackplane(); + const filter = (room) => !room.startsWith('user:'); + const a = boot(t, backplane, { instanceId: 'a', cluster: { rooms: filter } }); + const b = boot(t, backplane, { instanceId: 'b', cluster: { rooms: filter } }); + const peer = attach(a); + peer.client.join('lobby'); + peer.client.join('user:42'); + await settle(20); + assert.strictEqual(b.cluster.count('lobby'), 1, 'a topic room replicates'); + assert.strictEqual(b.cluster.count('user:42'), 0, 'a filtered room does not'); + // Locally both are visible: the filter shapes replication, not truth. + assert.strictEqual(a.cluster.count('user:42'), 1); +}); + +test('cluster: fetchClients truncates LOUDLY at the per-node cap', async (t) => { + const backplane = new MemoryBackplane(); + const a = boot(t, backplane, { instanceId: 'a', cluster: { maxFetch: 2 } }); + const b = boot(t, backplane, { instanceId: 'b', cluster: { maxFetch: 2 } }); + for (let i = 0; i < 4; i++) attach(b); + await settle(20); + const clients = await a.cluster.fetchClients({}); + // b answered with its first 2 of 4 and said so. + assert.strictEqual(clients.length, 2); + assert.strictEqual(clients.truncated, true); +}); + +test('cluster: a shared secret authenticates envelopes; unsigned peers are ignored', async (t) => { + const backplane = new MemoryBackplane(); + const a = boot(t, backplane, { instanceId: 'a', cluster: { secret: 's3cr3t' } }); + const b = boot(t, backplane, { instanceId: 'b', cluster: { secret: 's3cr3t' } }); + boot(t, backplane, { instanceId: 'rogue' }); // no secret — nobody to the signed pair + attach(b).client.join('lobby'); + await settle(20); + // Signed peers see each other; the unsigned node is nobody to them. + assert.deepStrictEqual(a.cluster.instances().sort(), ['a', 'b']); + assert.strictEqual(a.cluster.count('lobby'), 1); + assert.ok(!a.cluster.instances().includes('rogue')); + // A raw forged command on the shared channel is dropped by the signature + // check — the poisoned-broker scenario the secret exists for. + backplane.publish('cluster', JSON.stringify({ v: 1, from: 'evil', epoch: 'x', t: 'cmd', op: 'disconnect', sel: {} })); + await settle(20); + assert.strictEqual(b.clients.size, 1, 'the forged disconnect must not run'); +}); + +test('cluster: the digest heals a dropped delta through an addressed sync', async (t) => { + const backplane = new MemoryBackplane(); + const a = boot(t, backplane, { instanceId: 'a', cluster: { presenceInterval: 40 } }); + const b = boot(t, backplane, { instanceId: 'b', cluster: { presenceInterval: 40 } }); + await settle(20); + // Sabotage: b's view of a is emptied by hand — the digest mismatch must + // notice and pull a full state without a full snapshot every tick. + attach(a).client.join('lobby'); + await settle(20); + assert.strictEqual(b.cluster.count('lobby'), 1); + // Sabotage via the wire: a fake empty state for a, from a's OWN + // name/epoch (no secret in this test), empties b's view of it. + backplane.publish( + 'cluster', + JSON.stringify({ v: 1, from: 'a', epoch: a.cluster.epoch, t: 'state', rooms: {}, clients: 0 }), + ); + await settle(20); + assert.strictEqual(b.cluster.count('lobby'), 0, 'the sabotage took'); + // Within a few presence ticks the digest mismatch triggers sync -> state. + await timers.setTimeout(150); + await settle(20); + assert.strictEqual(b.cluster.count('lobby'), 1, 'the digest healed the view'); +}); + +test('cluster: signature edge branches — tampered payload and malformed commands are dropped', async (t) => { + const backplane = new MemoryBackplane(); + const a = boot(t, backplane, { instanceId: 'a', cluster: { secret: 's3' } }); + const b = boot(t, backplane, { instanceId: 'b', cluster: { secret: 's3' } }); + attach(b); + await settle(20); + assert.deepStrictEqual(a.cluster.instances().sort(), ['a', 'b']); + // A signed envelope TAMPERED after signing: valid-looking sig, wrong body. + const forged = JSON.stringify({ + v: 1, + from: 'b', + epoch: b.cluster.epoch, + t: 'cmd', + op: 'disconnect', + sel: {}, + sig: 'a'.repeat(64), + }); + backplane.publish('cluster', forged); + await settle(20); + assert.strictEqual(b.clients.size, 1, 'a bad signature must not run the command'); +}); + +test('cluster: malformed cmd shapes are dropped before they run', async (t) => { + const backplane = new MemoryBackplane(); + const a = boot(t, backplane, { instanceId: 'a' }); + const b = boot(t, backplane, { instanceId: 'b' }); + const peer = attach(b); + peer.client.join('news'); + await settle(20); + const post = (body) => backplane.publish('cluster', JSON.stringify({ v: 1, from: 'x', epoch: 'e', ...body })); + post({ t: 'cmd', op: 42, sel: {} }); // non-string op + post({ t: 'cmd', op: 'disconnect', sel: 'everyone' }); // non-object sel + post({ t: 'cmd', op: 'leave', sel: {}, rooms: 'news' }); // rooms not an array + post({ t: 'cmd', op: 'leave', sel: {}, rooms: [7] }); // rooms not strings + await settle(20); + assert.strictEqual(b.clients.size, 1, 'the client survived every malformed command'); + assert.strictEqual(a.cluster.count('news') + b.cluster.count('news') > 0, true, 'the room membership survived'); +}); + +test('cluster: a RegExp rooms filter and maxFetch: 0 (uncapped) hold', async (t) => { + const backplane = new MemoryBackplane(); + const a = boot(t, backplane, { instanceId: 'a', cluster: { rooms: /^topic:/, maxFetch: 0 } }); + const b = boot(t, backplane, { instanceId: 'b', cluster: { rooms: /^topic:/, maxFetch: 0 } }); + const peer = attach(a); + peer.client.join('topic:x'); + peer.client.join('dm:1'); + await settle(20); + assert.strictEqual(b.cluster.count('topic:x'), 1); + assert.strictEqual(b.cluster.count('dm:1'), 0); + for (let i = 0; i < 3; i++) attach(b); + await settle(20); + const clients = await a.cluster.fetchClients({}); + assert.strictEqual(clients.truncated, undefined, 'maxFetch: 0 never truncates'); + assert.ok(clients.length >= 4); +}); diff --git a/tests/rpc/codec.test.js b/tests/rpc/codec.test.js new file mode 100644 index 0000000..a81a788 --- /dev/null +++ b/tests/rpc/codec.test.js @@ -0,0 +1,373 @@ +'use strict'; + +// The pluggable text codec: one injected { encode, decode, contentType? } +// re-frames every wrpc packet on ws / packet-mode HTTP / batches / the +// broadcast fan-out, on both sides of the wire. Text-only v1 by design. + +const test = require('node:test'); +const assert = require('node:assert'); + +const { RpcServer, WrpcClient, defineRouter, procedure } = require('../../index.js'); +const { isCodec } = require('../../src/utils.js'); +const { bootServer, connectClient } = require('../helpers/server.js'); + +// A toy single-line codec whose frames are deliberately NOT JSON. +const toyCodec = () => { + const stats = { encoded: 0, decoded: 0 }; + return { + stats, + contentType: 'application/x-wrpc-toy', + encode: (packet) => { + stats.encoded++; + return `X${JSON.stringify(packet)}`; + }, + decode: (text) => { + stats.decoded++; + if (!text.startsWith('X')) throw new Error('bad frame'); + return JSON.parse(text.slice(1)); + }, + }; +}; + +const api = () => + defineRouter({ + chat: { + send: procedure({ access: 'public', handler: async (_ctx, { text }) => ({ echoed: text }) }), + note: procedure({ + access: 'public', + handler: async (ctx) => { + ctx.server.broadcast('chat/note', { hi: 1 }); + return { ok: true }; + }, + }), + poll: procedure({ + access: 'public', + handler: async (ctx) => { + const { answers, errors } = await ctx.server.to('lobby').ask('chat/poll', { q: 1 }, { timeout: 500 }); + return { answers, errors }; + }, + }), + join: procedure({ + access: 'public', + handler: async (ctx) => { + ctx.client.join('lobby'); + return { ok: true }; + }, + }), + }, + }); + +test('isCodec: structural — a packet half, a rest section, or both', () => { + assert.strictEqual(isCodec({ encode: () => '', decode: () => ({}) }), true); + assert.strictEqual(isCodec({ encode: () => '' }), false); + assert.strictEqual(isCodec(null), false); + assert.strictEqual(isCodec({ rest: { encode: () => '', decode: () => null } }), true, 'rest-only is a codec'); + assert.strictEqual(isCodec({ rest: { encode: () => '' } }), false, 'a declared rest section must be complete'); + assert.strictEqual( + isCodec({ encode: () => '', decode: () => null, rest: { encode: () => '' } }), + false, + 'a malformed rest section is refused even next to a valid packet half', + ); + assert.strictEqual(isCodec({ rest: { encode: () => '', decode: () => null, contentType: 5 } }), false); + assert.strictEqual(isCodec({ rest: { encode: () => '', decode: () => null, contentType: 'x/y' } }), true); + assert.throws(() => new RpcServer({ router: api(), codec: { encode: () => '' } }), /options\.codec/); +}); + +test('a client refuses a half-shaped codec up front', async () => { + await assert.rejects(WrpcClient.connect('ws://x/api', { codec: {} }), /options\.codec/); +}); + +test('codec and compiled serializers are mutually exclusive', () => { + const router = defineRouter( + { + p: { + make: procedure({ + access: 'public', + schema: { response: { 200: { type: 'object' } } }, + handler: async () => ({}), + }), + }, + }, + { + validation: { + ajv: { compile: () => () => true }, + serializer: { compile: () => (value) => JSON.stringify(value) }, + }, + }, + ); + assert.throws(() => new RpcServer({ router, codec: toyCodec() }), /mutually exclusive/); +}); + +test('codec end to end over ws: calls, events, batches, asks', async (t) => { + const codec = toyCodec(); + const { server, port } = await bootServer(t, { router: api(), codec }); + const url = `ws://127.0.0.1:${port}${server.rpc.basePath}`; + + await t.test('a call round-trips through the codec on both sides', async () => { + const clientCodec = toyCodec(); + const client = await connectClient(t, url, { codec: clientCodec }); + await client.load('chat'); + assert.deepStrictEqual(await client.api.chat.send({ text: 'hi' }), { echoed: 'hi' }); + assert.ok(clientCodec.stats.encoded > 0, 'client encoded outbound frames'); + assert.ok(clientCodec.stats.decoded > 0, 'client decoded inbound frames'); + }); + + await t.test('a broadcast event arrives through the codec, single-encoded', async () => { + const client = await connectClient(t, url, { codec: toyCodec() }); + await client.load('chat'); + const note = new Promise((resolve) => client.api.chat.on('note', resolve)); + const before = codec.stats.encoded; + await client.api.chat.note(); + assert.deepStrictEqual(await note, { hi: 1 }); + // One fan-out encode for the event itself (other frames also count, so + // only assert it moved — the single-encode property is the code path). + assert.ok(codec.stats.encoded > before); + }); + + await t.test('client batching encodes the real array', async () => { + const client = await connectClient(t, url, { codec: toyCodec(), batch: true }); + await client.load('chat'); + const [a, b] = await Promise.all([client.api.chat.send({ text: 'a' }), client.api.chat.send({ text: 'b' })]); + assert.strictEqual(a.echoed, 'a'); + assert.strictEqual(b.echoed, 'b'); + }); + + await t.test('the ask fan-out takes the per-recipient slow path', async () => { + const client = await connectClient(t, url, { codec: toyCodec() }); + await client.load('chat'); + client.respond('chat/poll', async () => ({ vote: 'yes' })); + await client.api.chat.join(); + const { answers, errors } = await client.api.chat.poll(); + assert.deepStrictEqual(answers, [{ vote: 'yes' }]); + assert.deepStrictEqual(errors, []); + }); +}); + +test('codec over packet-mode HTTP: frames and Content-Type', async (t) => { + const codec = toyCodec(); + const { server, port } = await bootServer(t, { router: api(), codec }); + const base = `http://127.0.0.1:${port}${server.rpc.basePath}`; + + await t.test('the raw wire really is codec-framed', async () => { + const res = await fetch(base, { + method: 'POST', + headers: { 'content-type': 'application/x-wrpc-toy' }, + body: codec.encode({ type: 'call', id: '1', method: 'chat/send', args: { text: 'raw' } }), + }); + assert.strictEqual(res.headers.get('content-type'), 'application/x-wrpc-toy'); + const text = await res.text(); + assert.ok(text.startsWith('X'), 'the response is not JSON framing'); + assert.deepStrictEqual(codec.decode(text).result, { echoed: 'raw' }); + }); + + await t.test('a JSON body against a codec server is a malformed packet, not a crash', async () => { + const res = await fetch(base, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'call', id: '1', method: 'chat/send', args: {} }), + }); + // Malformed → the dispatcher's structure error (500 on an empty packet). + const packet = codec.decode(await res.text()); + assert.strictEqual(packet.error.code, 500); + }); + + await t.test('an HTTP batch answers one codec-framed array', async () => { + const frame = codec.encode([ + { type: 'call', id: 'a', method: 'chat/send', args: { text: '1' } }, + { type: 'call', id: 'b', method: 'chat/send', args: { text: '2' } }, + ]); + const res = await fetch(base, { + method: 'POST', + headers: { 'content-type': 'application/x-wrpc-toy' }, + body: frame, + }); + const answers = codec.decode(await res.text()); + assert.strictEqual(answers.length, 2); + assert.deepStrictEqual( + answers.map((p) => p.result.echoed), + ['1', '2'], + ); + }); + + await t.test('the http client transport speaks it end to end', async () => { + const client = await connectClient(t, base, { transport: 'http', codec: toyCodec() }); + await client.load('chat'); + assert.deepStrictEqual(await client.api.chat.send({ text: 'h' }), { echoed: 'h' }); + }); + + await t.test('REST mode stays JSON without codec.rest — curl is its audience', async () => { + const res = await fetch(`${base}/chat/send?text=q`); + // The mode-blind Content-Type bug: the packet codec's type must never + // ride on a REST body it did not frame. + assert.notStrictEqual(res.headers.get('content-type'), 'application/x-wrpc-toy'); + const packet = await res.json(); // plain JSON, not codec-framed + assert.deepStrictEqual(packet.result, { echoed: 'q' }); + }); +}); + +// --------------------------------------------------------------------------- +// codec.rest: the opt-in BODY codec for REST mode. Values, not packets — +// and binary is fine here (whole HTTP bodies, no framing to collide with). + +// A toy binary body codec: one prefix byte, then JSON bytes. +const toyRestCodec = () => { + const stats = { encoded: 0, decoded: 0 }; + return { + stats, + rest: { + contentType: 'application/x-wrpc-bin', + encode: (value) => { + stats.encoded++; + return Buffer.concat([Buffer.from([0xab]), Buffer.from(JSON.stringify(value ?? null))]); + }, + decode: (body) => { + stats.decoded++; + const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body); + if (buffer[0] !== 0xab) throw new Error('bad binary frame'); + return JSON.parse(buffer.subarray(1).toString()); + }, + }, + }; +}; + +const restApi = () => + defineRouter({ + projects: { + create: procedure({ + access: 'public', + http: { method: 'POST', path: '/projects/:orgId', status: 201 }, + handler: async (_ctx, { params, body }) => ({ orgId: params.orgId, name: body?.name }), + }), + findById: procedure({ + access: 'public', + http: { method: 'GET', path: '/projects/:id' }, + handler: async (_ctx, { params }) => ({ id: params.id }), + }), + boom: procedure({ + access: 'public', + http: { method: 'GET', path: '/projects/:id/boom' }, + handler: async () => { + const error = new Error('gone'); + error.code = 404; + error.details = { why: 'archived' }; + throw error; + }, + }), + plain: procedure({ access: 'public', handler: async (_ctx, args) => ({ plain: true, args }) }), + }, + misc: { + // Deliberately outside every trie path: the conventional-mode probe. + plain: procedure({ access: 'public', handler: async (_ctx, args) => ({ plain: true, args }) }), + }, + }); + +test('codec.rest on the shell: binary bodies on both REST modes', async (t) => { + const codec = toyRestCodec(); + const { server, port } = await bootServer(t, { router: restApi(), codec }); + const base = `http://127.0.0.1:${port}${server.rpc.basePath}`; + const decodeRes = async (res) => codec.rest.decode(Buffer.from(await res.arrayBuffer())); + + await t.test('a declared route round-trips a binary request and response', async () => { + const res = await fetch(`${base}/projects/42`, { + method: 'POST', + headers: { 'content-type': 'application/x-wrpc-bin' }, + body: codec.rest.encode({ name: 'Alpha' }), + }); + assert.strictEqual(res.status, 201); + assert.strictEqual(res.headers.get('content-type'), 'application/x-wrpc-bin'); + assert.deepStrictEqual(await decodeRes(res), { orgId: '42', name: 'Alpha' }); + }); + + await t.test('an error body travels codec-framed, details included', async () => { + const res = await fetch(`${base}/projects/1/boom`); + assert.strictEqual(res.status, 404); + assert.strictEqual(res.headers.get('content-type'), 'application/x-wrpc-bin'); + assert.deepStrictEqual(await decodeRes(res), { message: 'gone', code: 404, details: { why: 'archived' } }); + }); + + await t.test('a 405 keeps its Allow header and frames its error body', async () => { + const res = await fetch(`${base}/projects/42`, { method: 'PATCH' }); + assert.strictEqual(res.status, 405); + assert.deepStrictEqual(res.headers.get('allow').split(', ').sort(), ['GET', 'POST']); + assert.deepStrictEqual(await decodeRes(res), { message: 'Method Not Allowed', code: 405 }); + }); + + await t.test('a malformed escape answers a framed 400', async () => { + const res = await fetch(`${base}/projects/%ZZ`); + assert.strictEqual(res.status, 400); + assert.strictEqual((await decodeRes(res)).code, 400); + }); + + await t.test('a request body the codec cannot decode is the caller`s 400', async () => { + const res = await fetch(`${base}/projects/42`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'not framed' }), + }); + assert.strictEqual(res.status, 400); + }); + + await t.test('the conventional mode frames its callback envelope too', async () => { + const res = await fetch(`${base}/misc/plain?x=1`); + assert.strictEqual(res.headers.get('content-type'), 'application/x-wrpc-bin'); + const packet = await decodeRes(res); + assert.strictEqual(packet.type, 'callback'); + assert.deepStrictEqual(packet.result, { plain: true, args: { x: '1' } }); + }); + + await t.test('packet mode is untouched by a rest-only codec', async () => { + const res = await fetch(base, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'call', id: '1', method: 'projects/plain', args: {} }), + }); + assert.strictEqual(res.headers.get('content-type'), 'application/json'); + assert.deepStrictEqual((await res.json()).result, { plain: true, args: {} }); + }); +}); + +test('codec.rest on the client: the REST leg speaks binary end to end', async (t) => { + const { server, port } = await bootServer(t, { router: restApi(), codec: toyRestCodec() }); + const base = `http://127.0.0.1:${port}${server.rpc.basePath}`; + + await t.test('a mapped call round-trips through the client codec', async () => { + const clientCodec = toyRestCodec(); + const client = await connectClient(t, base, { transport: 'http', codec: clientCodec }); + await client.load('projects'); + const created = await client.api.projects.create({ params: { orgId: '7' }, body: { name: 'B' } }); + assert.deepStrictEqual(created, { orgId: '7', name: 'B' }); + assert.ok(clientCodec.stats.encoded > 0, 'the request body went through encode'); + assert.ok(clientCodec.stats.decoded > 0, 'the response body went through decode'); + }); + + await t.test('a wire error decodes into WrpcError with code and details', async () => { + const client = await connectClient(t, base, { transport: 'http', codec: toyRestCodec() }); + await client.load('projects'); + await assert.rejects(client.api.projects.boom({ params: { id: '1' } }), (error) => { + assert.strictEqual(error.code, 404); + assert.strictEqual(error.message, 'gone'); + assert.deepStrictEqual(error.details, { why: 'archived' }); + return true; + }); + }); +}); + +test('the REST leg Content-Type: the packet codec never leaks; codec.rest owns it', async (t) => { + const captured = []; + const realFetch = globalThis.fetch; + globalThis.fetch = async (_url, options) => { + captured.push(options.headers); + return { status: 200, text: async () => 'null', arrayBuffer: async () => new ArrayBuffer(0) }; + }; + t.after(() => void (globalThis.fetch = realFetch)); + const HttpTransport = WrpcClient.transport.http; + const transport = new HttpTransport('http://x/api'); + // The mirror of the server's mode-blind bug: a PACKET codec on the + // transport must not stamp its contentType onto a JSON REST-leg body. + transport.codec = toyCodec(); + await transport.request('POST', 'http://x/api/things', '{"a":1}', undefined); + assert.strictEqual(captured[0]['Content-Type'], 'application/json'); + const rest = { encode: () => new Uint8Array([1]), decode: () => null, contentType: 'application/x-bin' }; + await transport.request('POST', 'http://x/api/things', new Uint8Array([1]), undefined, { rest }); + assert.strictEqual(captured[1]['Content-Type'], 'application/x-bin'); +}); diff --git a/tests/rpc/events.test.js b/tests/rpc/events.test.js new file mode 100644 index 0000000..fed8fb1 --- /dev/null +++ b/tests/rpc/events.test.js @@ -0,0 +1,335 @@ +'use strict'; + +const timers = require('node:timers/promises'); +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { Server, WrpcClient, defineRouter, procedure } = require('../../index.js'); +const { handleMessage } = require('../../src/rpc/dispatcher.js'); + +const noop = () => {}; +const quiet = { log: noop, info: noop, warn: noop, error: noop, debug: noop }; + +const waitFor = async (predicate, message = 'condition never held') => { + for (let i = 0; i < 200; i++) { + if (predicate()) return; + await timers.setTimeout(5); + } + assert.fail(message); +}; + +// --------------------------------------------------------------------------- +// Router level: `on` declares handlers, not a method + +test('router: the reserved `on` key holds event handlers', async (t) => { + const router = defineRouter({ + chat: { + send: async () => ({ ok: true }), + on: { + typing: async () => {}, + seen: { access: 'session', handler: async () => {} }, + }, + }, + 'chat.v1': { + on: { typing: async () => {} }, + }, + }); + + await t.test('handlers are looked up per unit and version', () => { + assert.ok(router.getEventHandler('chat', '*', 'typing')); + assert.ok(router.getEventHandler('chat', 'v1', 'typing')); + assert.strictEqual(router.getEventHandler('chat', 'v1', 'seen'), null); + assert.strictEqual(router.getEventHandler('chat', '*', 'nothing'), null); + assert.strictEqual(router.getEventHandler('nowhere', '*', 'typing'), null); + }); + + await t.test('handlers are procedures, so access carries over', () => { + assert.strictEqual(router.getEventHandler('chat', '*', 'typing').access, 'session'); + assert.strictEqual(router.getEventHandler('chat', '*', 'seen').access, 'session'); + }); + + await t.test('`on` is not a callable method; it introspects as the typed-event block', () => { + assert.strictEqual(router.getProcedure('chat', '*', 'on'), null); + const introspection = router.introspect(); + // The inbound handlers now TRAVEL (what `wrpc types` turns into the + // contract's `sends` key) — under the reserved 'on' key, never as a + // method descriptor: no `kind`, and the client's scaffold skips it. + assert.deepStrictEqual(Object.keys(introspection.chat), ['send', 'on']); + assert.deepStrictEqual(Object.keys(introspection.chat.on).sort(), ['seen', 'typing']); + assert.strictEqual(introspection.chat.on.typing.access, 'session'); + assert.deepStrictEqual(Object.keys(introspection['chat.v1']), ['on']); + }); + + await t.test('merge carries the event handlers over', () => { + const merged = router.merge(defineRouter({ other: { on: { hello: async () => {} } } })); + assert.ok(merged.getEventHandler('chat', '*', 'typing')); + assert.ok(merged.getEventHandler('chat', 'v1', 'typing')); + assert.ok(merged.getEventHandler('other', '*', 'hello')); + assert.ok(merged.getProcedure('chat', '*', 'send')); + }); + + await t.test('a malformed `on` is rejected', () => { + assert.throws(() => defineRouter({ chat: { on: 42 } }), TypeError); + assert.throws(() => defineRouter({ chat: { on: { typing: 42 } } }), TypeError); + }); +}); + +// --------------------------------------------------------------------------- +// Wire level: client -> server events over a live connection + +const createServer = async (router, logger) => { + const server = new Server({ + router, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger, + timeouts: { bind: 100 }, + }); + await server.listen(); + return { server, port: server.address().port }; +}; + +test('events: a client event reaches its router handler', async (t) => { + const seen = []; + const warnings = []; + const router = defineRouter({ + chat: { + ping: procedure({ access: 'public', handler: async () => 'pong' }), + on: { + typing: procedure({ + access: 'public', + handler: async (context, data) => { + seen.push({ data, uuid: typeof context.uuid, source: typeof context.client.source }); + }, + }), + checked: procedure({ + access: 'public', + input: (value) => { + if (!value?.ok) throw new Error('not ok'); + return value; + }, + handler: async (_context, data) => void seen.push({ data }), + }), + boom: procedure({ + access: 'public', + handler: async () => { + throw new Error('handler blew up'); + }, + }), + private: procedure({ access: 'session', handler: async () => void seen.push('private') }), + }, + }, + }); + const logger = { ...quiet, warn: (message) => warnings.push(message) }; + const { server, port } = await createServer(router, logger); + t.after(() => server.close()); + + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`); + t.after(() => void client.close()); + await client.load('chat'); + + await t.test('the handler receives the data and a context', async () => { + client.sendEvent('chat/typing', { on: true }); + await waitFor(() => seen.length === 1, 'the event never arrived'); + assert.deepStrictEqual(seen[0], { data: { on: true }, uuid: 'string', source: 'string' }); + }); + + await t.test('an event never produces a wire answer', async () => { + seen.length = 0; + client.sendEvent('chat/typing', { on: false }); + // The connection is still usable, and nothing came back on it that a + // call would have to demultiplex around. + assert.strictEqual(await client.api.chat.ping(), 'pong'); + assert.strictEqual(seen.length, 1); + }); + + await t.test('an unknown event is dropped with a log line', async () => { + warnings.length = 0; + client.sendEvent('chat/nothing', {}); + client.sendEvent('nowhere/typing', {}); + await waitFor(() => warnings.length === 2, 'the unknown events were not reported'); + assert.match(warnings[0], /chat\/nothing\tno handler/); + assert.strictEqual(await client.api.chat.ping(), 'pong', 'the connection survives'); + }); + + await t.test('a session-only event without a session is dropped', async () => { + warnings.length = 0; + seen.length = 0; + client.sendEvent('chat/private', {}); + await waitFor(() => warnings.length === 1, 'the denied event was not reported'); + assert.match(warnings[0], /chat\/private\tsession required/); + assert.deepStrictEqual(seen, []); + }); + + await t.test('a failing handler is reported, not thrown at the peer', async () => { + warnings.length = 0; + client.sendEvent('chat/boom', {}); + await waitFor(() => warnings.length === 1, 'the handler failure was not reported'); + assert.match(warnings[0], /chat\/boom\t.*handler blew up/s); + assert.strictEqual(await client.api.chat.ping(), 'pong'); + }); + + await t.test('input validation applies to events too', async () => { + warnings.length = 0; + seen.length = 0; + client.sendEvent('chat/checked', { ok: false }); + await waitFor(() => warnings.length === 1, 'the invalid event was not reported'); + assert.match(warnings[0], /chat\/checked\t.*not ok/s); + + client.sendEvent('chat/checked', { ok: true }); + await waitFor(() => seen.length === 1, 'the valid event never arrived'); + }); + + await t.test('a stray pong on a persistent transport is ignored', async () => { + const failures = []; + client.on('error', (error) => void failures.push(error)); + client.send({ type: 'pong' }); + await timers.setTimeout(30); + assert.deepStrictEqual(failures, [], 'a pong nobody asked for is not a protocol error'); + assert.strictEqual(await client.api.chat.ping(), 'pong'); + client.clear('error'); + }); + + await t.test('an event packet with no name is a structure error', async () => { + client.send({ type: 'event', data: 1 }); + // The server answers the malformed packet with an id-less error packet, + // which the client reports rather than resolving anything. + const failure = new Promise((resolve) => client.once('error', resolve)); + assert.ok(await failure); + assert.strictEqual(await client.api.chat.ping(), 'pong'); + }); +}); + +test('events: a request/response transport is answered instead of pinned', async (t) => { + let invocations = 0; + const router = defineRouter({ + chat: { + ping: procedure({ access: 'public', handler: async () => 'pong' }), + on: { typing: procedure({ access: 'public', handler: async () => void invocations++ }) }, + }, + }); + const { server, port } = await createServer(router, quiet); + t.after(() => server.close()); + const base = `http://127.0.0.1:${port}/api`; + + const post = async (packet) => { + const res = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(packet), + }); + return { status: res.status, body: await res.json() }; + }; + + await t.test('an event over HTTP is refused, not silently dropped', async () => { + const { status, body } = await post({ type: 'event', name: 'chat/typing', data: {} }); + assert.strictEqual(status, 400); + assert.strictEqual(body.error.code, 400); + assert.match(body.error.message, /persistent connection/); + assert.strictEqual(invocations, 0, 'the handler must not run for a transport that cannot carry events'); + }); + + await t.test('a stray pong over HTTP is a structure error', async () => { + const { status, body } = await post({ type: 'pong' }); + assert.strictEqual(status, 500); + assert.strictEqual(body.error.code, 500); + }); + + await t.test('a ping over HTTP still answers', async () => { + const { status, body } = await post({ type: 'ping' }); + assert.strictEqual(status, 200); + assert.deepStrictEqual(body, { type: 'pong' }); + }); + + await t.test('every answered request releases its client', async () => { + await waitFor(() => server.rpc.clients.size === 0, 'an unanswered request leaked its client'); + }); +}); + +test('events: a malformed target cannot crash the dispatcher', async (t) => { + const router = defineRouter({ + chat: { + ping: procedure({ access: 'public', handler: async () => 'pong' }), + on: { typing: procedure({ access: 'public', handler: async () => {} }) }, + }, + }); + const { server, port } = await createServer(router, quiet); + t.after(() => server.close()); + + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`); + t.after(() => void client.close()); + await client.load('chat'); + client.on('error', () => {}); // the id-less error packets come back as these + + // A non-string target used to reach String.prototype.indexOf inside an + // un-awaited async dispatch: the throw became an unhandled rejection, and + // node's default is to terminate the process on one. + for (const packet of [ + { type: 'event', name: 12345 }, + { type: 'event', name: { nested: true } }, + { type: 'event', name: ['chat', 'typing'] }, + { type: 'call', id: 'x', method: 12345 }, + { type: 'call', id: 'x', method: { nested: true } }, + ]) { + client.send(packet); + } + + await timers.setTimeout(50); + assert.strictEqual(await client.api.chat.ping(), 'pong', 'the server survived every malformed target'); +}); + +// A rejection out of a fire-and-forget dispatch has nobody to reach, and +// node terminates the process on an unhandled one — so this test failing +// would take the whole run down rather than report. +test('dispatcher: a dispatch that cannot even report its failure is contained', async () => { + const router = defineRouter({ chat: { on: { typing: async () => {} } } }); + const broken = { + persistent: false, // sends handleEvent down the error path... + sessionReady: Promise.resolve(), + error() { + throw new Error('transport is gone'); // ...where the answer fails... + }, + warn() { + throw new Error('console is gone'); // ...and so does reporting that + }, + createContext: () => ({}), + }; + const packet = JSON.stringify({ type: 'event', name: 'chat/typing', data: {} }); + assert.doesNotThrow(() => handleMessage(broken, packet, router)); + await timers.setTimeout(20); +}); + +test('events: a session event runs once the session exists', async (t) => { + const seen = []; + const router = defineRouter({ + auth: { + login: procedure({ + access: 'public', + handler: async (context) => { + context.client.startSession(undefined, { user: 'ada' }); + return { ok: true }; + }, + }), + }, + chat: { + on: { + private: procedure({ + access: 'session', + handler: async (context) => void seen.push(context.session.state.user), + }), + }, + }, + }); + const { server, port } = await createServer(router, quiet); + t.after(() => server.close()); + + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`); + t.after(() => void client.close()); + await client.load('auth'); + + assert.deepStrictEqual(await client.api.auth.login(), { ok: true }); + client.sendEvent('chat/private', {}); + await waitFor(() => seen.length === 1, 'the session event never ran'); + assert.deepStrictEqual(seen, ['ada']); +}); diff --git a/tests/rpc/hooks.test.js b/tests/rpc/hooks.test.js new file mode 100644 index 0000000..21e81df --- /dev/null +++ b/tests/rpc/hooks.test.js @@ -0,0 +1,633 @@ +'use strict'; + +const timers = require('node:timers/promises'); +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { WrpcClient, defineRouter, procedure } = require('../../index.js'); +const { bootServer } = require('../helpers/server.js'); + +const waitFor = async (predicate, message = 'condition never held') => { + for (let i = 0; i < 200; i++) { + if (predicate()) return; + await timers.setTimeout(5); + } + assert.fail(message); +}; + +const codedError = (message, code) => { + const error = new Error(message); + error.code = code; + error.expose = true; + return error; +}; + +// Delegates to the shared boot (tests/helpers/server.js) — the local +// signature stays, the copied Server block goes. +const boot = async (t, router, options = {}) => { + const { server, url } = await bootServer(t, { router, ...options }); + return { server, url }; +}; + +const connect = async (t, url, options = {}) => { + const client = await WrpcClient.connect(url, { heartbeat: false, reconnect: false, ...options }); + t.after(() => void client.close()); + return client; +}; + +test('hooks: the three levels flatten in router -> unit -> procedure order', async (t) => { + const order = []; + const mark = (label) => () => void order.push(label); + const router = defineRouter( + { + unit: { + hooks: { preHandler: mark('unit') }, + run: procedure({ + access: 'public', + preHandler: mark('procedure'), + handler: async () => 'done', + }), + }, + }, + { hooks: { preHandler: [mark('router-a'), mark('router-b')] } }, + ); + const { url } = await boot(t, router); + const client = await connect(t, url); + await client.load('unit'); + // load() itself is a hooked call (system/introspect runs the router-level + // phases too), so the order is only read from here on. + order.length = 0; + assert.strictEqual(await client.api.unit.run(), 'done'); + assert.deepStrictEqual(order, ['router-a', 'router-b', 'unit', 'procedure']); +}); + +test('hooks: every awaited phase can end the call with its coded error', async (t) => { + const failAt = { phase: null }; + const hookFor = (phase) => async () => { + if (failAt.phase === phase) throw codedError(`${phase} said no`, 403); + }; + const router = defineRouter( + { + unit: { + run: procedure({ access: 'public', handler: async () => 'done' }), + }, + }, + { + hooks: { + onRequest: hookFor('onRequest'), + preValidation: hookFor('preValidation'), + preHandler: hookFor('preHandler'), + preSerialization: hookFor('preSerialization'), + onSend: hookFor('onSend'), + }, + }, + ); + const { url } = await boot(t, router); + const client = await connect(t, url); + await client.load('unit'); + for (const phase of ['onRequest', 'preValidation', 'preHandler', 'preSerialization', 'onSend']) { + failAt.phase = phase; + await assert.rejects( + client.api.unit.run(), + (error) => error.code === 403 && error.message === `${phase} said no`, + `${phase} must abort with its own code and message`, + ); + } + failAt.phase = null; + assert.strictEqual(await client.api.unit.run(), 'done'); +}); + +test('hooks: onRequest enriches ctx.state and the handler sees it', async (t) => { + const router = defineRouter( + { + unit: { + who: procedure({ access: 'public', handler: async (context) => context.state.tenant }), + }, + }, + { + hooks: { + onRequest: async (context) => { + context.state.tenant = 'acme'; + }, + }, + }, + ); + const { url } = await boot(t, router); + const client = await connect(t, url); + await client.load('unit'); + assert.strictEqual(await client.api.unit.who(), 'acme'); +}); + +test('hooks: phase payloads and ordering around validation', async (t) => { + const seen = []; + const router = defineRouter({ + unit: { + hooks: { + preValidation: async (_context, args) => void seen.push(['preValidation', { ...args }]), + preHandler: async (_context, args) => void seen.push(['preHandler', { ...args }]), + }, + double: procedure({ + access: 'public', + input: (args) => ({ n: Number(args.n) }), + handler: async (_context, { n }) => n * 2, + }), + }, + }); + const { url } = await boot(t, router); + const client = await connect(t, url); + await client.load('unit'); + assert.strictEqual(await client.api.unit.double({ n: '21' }), 42); + // preValidation sees the raw wire args; preHandler sees the validated ones. + assert.deepStrictEqual(seen, [ + ['preValidation', { n: '21' }], + ['preHandler', { n: 21 }], + ]); +}); + +test('hooks: preSerialization reshapes, onSend mutates the packet', async (t) => { + const router = defineRouter( + { + unit: { + secret: procedure({ + access: 'public', + handler: async () => ({ user: 'ada', password: 'hunter2' }), + }), + }, + }, + { + hooks: { + preSerialization: async (_context, result) => { + const { password, ...safe } = result; + void password; + return safe; + }, + onSend: async (_context, packet) => { + packet.result = { ...packet.result, stamped: true }; + }, + }, + }, + ); + const { url } = await boot(t, router); + const client = await connect(t, url); + await client.load('unit'); + assert.deepStrictEqual(await client.api.unit.secret(), { user: 'ada', stamped: true }); +}); + +test('hooks: the observational phases fire and never break the call', async (t) => { + const seen = { onResponse: 0, onError: 0, onTimeout: 0 }; + const router = defineRouter( + { + unit: { + ok: procedure({ access: 'public', handler: async () => 'fine' }), + boom: procedure({ + access: 'public', + handler: async () => { + throw codedError('told you', 418); + }, + }), + slow: procedure({ + access: 'public', + timeout: 20, + handler: async () => timers.setTimeout(500), + }), + }, + }, + { + hooks: { + onResponse: async () => { + seen.onResponse++; + throw new Error('observers must be contained'); + }, + onError: async () => void seen.onError++, + onTimeout: async () => void seen.onTimeout++, + }, + }, + ); + const { url } = await boot(t, router); + const client = await connect(t, url, { callTimeout: 2000 }); + await client.load('unit'); + seen.onResponse = 0; // load() is a hooked call too + assert.strictEqual(await client.api.unit.ok(), 'fine', 'a throwing onResponse must not fail the call'); + assert.strictEqual(seen.onResponse, 1); + await assert.rejects(client.api.unit.boom(), (error) => error.code === 418); + assert.strictEqual(seen.onError, 1); + await assert.rejects(client.api.unit.slow(), (error) => error.code === 408); + assert.strictEqual(seen.onTimeout, 1, 'a 408 fires onTimeout'); + assert.strictEqual(seen.onError, 2, 'and onError fires for every failure, the 408 included'); +}); + +test('hooks: subscriptions — onSubscribe refuses, onUnsubscribe fires on every end', async (t) => { + const seen = { subscribed: 0, unsubscribed: 0 }; + let quotaFull = false; + const router = defineRouter( + { + feed: { + ticks: procedure.subscription({ + access: 'public', + handler: async function* () { + yield { n: 1 }; + yield { n: 2 }; + }, + }), + }, + }, + { + hooks: { + onSubscribe: async () => { + if (quotaFull) throw codedError('Subscription quota exceeded', 429); + seen.subscribed++; + }, + onUnsubscribe: async () => void seen.unsubscribed++, + }, + }, + ); + const { url } = await boot(t, router); + const client = await connect(t, url); + await client.load('feed'); + + const values = []; + await new Promise((resolve) => { + client.api.feed.ticks.subscribe({}, { onData: (data) => values.push(data), onEnd: resolve }); + }); + assert.deepStrictEqual(values, [{ n: 1 }, { n: 2 }]); + assert.strictEqual(seen.subscribed, 1); + await waitFor(() => seen.unsubscribed === 1, 'onUnsubscribe never fired for a completed feed'); + + quotaFull = true; + const refused = await new Promise((resolve) => { + client.api.feed.ticks.subscribe({}, { onError: resolve }); + }); + assert.strictEqual(refused.code, 429); + assert.match(refused.message, /quota/i); + assert.strictEqual(seen.subscribed, 1, 'a refused subscription never counted'); +}); + +test('hooks: inbound events run the invocation phases', async (t) => { + const seen = []; + const router = defineRouter({ + chat: { + probe: procedure({ access: 'public', handler: async () => seen.splice(0, seen.length) }), + on: { + typing: procedure({ + access: 'public', + preHandler: async (_context, data) => void seen.push(['preHandler', data]), + handler: async (_context, data) => void seen.push(['handler', data]), + }), + }, + }, + }); + const { url } = await boot(t, router); + const client = await connect(t, url); + await client.load('chat'); + client.sendEvent('chat/typing', { user: 'ada' }); + await waitFor(() => seen.length === 2, 'the event handler never ran'); + assert.deepStrictEqual(await client.api.chat.probe(), [ + ['preHandler', { user: 'ada' }], + ['handler', { user: 'ada' }], + ]); +}); + +test('hooks: context carries its call identity across call, subscribe and event', async (t) => { + const seen = []; + const identity = (phase) => async (context) => void seen.push([phase, context.method, context.procedure]); + const router = defineRouter( + { + unit: { + run: procedure({ access: 'public', handler: async (context) => context.method }), + feed: procedure.subscription({ + access: 'public', + handler: async function* () { + yield { ok: true }; + }, + }), + on: { + nudge: procedure({ access: 'public', handler: async () => {} }), + }, + }, + }, + { + hooks: { + onRequest: identity('onRequest'), + preHandler: identity('preHandler'), + onSubscribe: identity('onSubscribe'), + }, + }, + ); + const { url } = await boot(t, router); + const client = await connect(t, url); + await client.load('unit'); + seen.length = 0; // load() runs the hooks for system/introspect too + + assert.strictEqual(await client.api.unit.run(), 'unit/run', 'the handler reads its own target'); + const call = seen.filter(([phase]) => phase === 'onRequest' || phase === 'preHandler'); + assert.deepStrictEqual( + call.map(([phase, method]) => [phase, method]), + [ + ['onRequest', 'unit/run'], + ['preHandler', 'unit/run'], + ], + ); + const proc = router.getProcedure('unit', undefined, 'run'); + assert.ok( + call.every((entry) => entry[2] === proc), + 'context.procedure is the resolved Procedure instance', + ); + + seen.length = 0; + await new Promise((resolve) => { + client.api.unit.feed.subscribe({}, { onData: () => {}, onEnd: resolve }); + }); + const sub = seen.find(([phase]) => phase === 'onSubscribe'); + assert.strictEqual(sub[1], 'unit/feed'); + assert.strictEqual(sub[2], router.getProcedure('unit', undefined, 'feed')); + + seen.length = 0; + client.sendEvent('unit/nudge', {}); + await waitFor(() => seen.some(([phase]) => phase === 'preHandler'), 'the event hook never ran'); + const event = seen.find(([phase]) => phase === 'preHandler'); + assert.strictEqual(event[1], 'unit/nudge', 'an inbound event carries its name verbatim'); + assert.strictEqual(event[2], router.getEventHandler('unit', undefined, 'nudge')); +}); + +test('hooks: onDisconnect receives the rooms the client was in', async (t) => { + const seen = []; + const router = defineRouter( + { + unit: { + enter: procedure({ + access: 'public', + handler: async (context) => { + context.client.join('lobby'); + context.client.join('games'); + return [...context.client.rooms]; + }, + }), + }, + }, + { + hooks: { + onDisconnect: async (client, payload) => void seen.push([payload, client.rooms]), + }, + }, + ); + const { url } = await boot(t, router); + const client = await connect(t, url); + await client.load('unit'); + assert.deepStrictEqual(await client.api.unit.enter(), ['lobby', 'games']); + await client.close(); + await waitFor(() => seen.length > 0, 'onDisconnect never fired'); + const [payload, liveRooms] = seen[0]; + assert.deepStrictEqual(payload.rooms, new Set(['lobby', 'games']), 'the payload snapshots the pre-destroy rooms'); + assert.deepStrictEqual(liveRooms, new Set(), 'the registry itself is already empty by hook time'); +}); + +test('meta: context.callMeta carries the packet meta field to hooks and handlers', async (t) => { + const seen = []; + const router = defineRouter( + { + unit: { + run: procedure({ access: 'public', handler: async (context) => context.callMeta }), + feed: procedure.subscription({ + access: 'public', + handler: async function* (context) { + yield context.callMeta; + }, + }), + on: { + poke: procedure({ + access: 'public', + handler: async (context) => void seen.push(['event', context.callMeta]), + }), + }, + }, + }, + { hooks: { onRequest: async (context) => void seen.push(['onRequest', context.callMeta]) } }, + ); + const { url } = await boot(t, router); + const client = await connect(t, url); + await client.load('unit'); + seen.length = 0; // load() is a hooked call too + + // The per-call spelling on the escape hatch... + assert.deepStrictEqual(await client.call('unit/run', {}, { meta: { idem: 'k1' } }), { idem: 'k1' }); + // ...and the bound-variant spelling on a scaffolded method. + assert.deepStrictEqual(await client.api.unit.run.withMeta({ idem: 'k2' })(), { idem: 'k2' }); + // A packet with NO meta reads as a frozen empty object, never null. + const bare = await client.api.unit.run(); + assert.deepStrictEqual(bare, {}); + assert.deepStrictEqual( + // Spread-normalized: the empty default is null-prototyped by design. + seen.filter(([phase]) => phase === 'onRequest').map(([, meta]) => ({ ...meta })), + [{ idem: 'k1' }, { idem: 'k2' }, {}], + ); + // An inbound EVENT packet carries the field the same way (no client + // spelling yet — sent raw, as any hand-written peer could). + client.send({ type: 'event', name: 'unit/poke', data: {}, meta: { trace: 't1' } }); + await waitFor(() => seen.some(([phase]) => phase === 'event'), 'the event never arrived'); + assert.deepStrictEqual({ ...seen.find(([phase]) => phase === 'event')[1] }, { trace: 't1' }); +}); + +test('meta: a malformed or oversize packet meta is refused, the call still runs', async (t) => { + const router = defineRouter({ + unit: { + run: procedure({ + access: 'public', + handler: async (context) => ({ + meta: context.callMeta, + frozen: Object.isFrozen(context.callMeta), + protoKey: Object.hasOwn(context.callMeta, '__proto__'), + }), + }), + }, + }); + const { server, url } = await boot(t, router, { metaMaxBytes: 64 }); + const client = await connect(t, url); + await client.load('unit'); + // An array, an oversize object, a bare string: each is dropped to the + // frozen empty default — the label is refused, never the call. + for (const bad of [['a'], { pad: 'x'.repeat(200) }, 'nope', 42]) { + const out = await client.call('unit/run', {}, { meta: bad }); + assert.deepStrictEqual(out.meta, {}, JSON.stringify(bad)); + assert.strictEqual(out.frozen, true); + } + // A declared __proto__ key never reaches the handler's bag. + const out = await client.call('unit/run', {}, { meta: JSON.parse('{"a":1,"__proto__":{"polluted":1}}') }); + assert.deepStrictEqual(out.meta, { a: 1 }); + assert.strictEqual(out.protoKey, false); + assert.strictEqual({}.polluted, undefined); + assert.ok(server); // silence unused +}); + +test('hooks: an onConnect room join is visible to the very first dispatched call', async (t) => { + const router = defineRouter({ + unit: { + rooms: procedure({ access: 'public', handler: async (context) => [...context.client.rooms] }), + }, + }); + // Slower than a localhost round trip on purpose: without the client.ready + // gate the introspect and the call would race past the join and this test + // would flake toward [] — the exact bug the gate closes. + router.addHook('onConnect', async (client) => { + await timers.setTimeout(25); + client.join('lobby'); + }); + const { url } = await boot(t, router); + const client = await connect(t, url); + await client.load('unit'); + assert.deepStrictEqual(await client.api.unit.rooms(), ['lobby']); +}); + +test('hooks: onConnect and onDisconnect bracket a connection', async (t) => { + const seen = []; + const router = defineRouter( + { + unit: { ping: procedure({ access: 'public', handler: async () => 'pong' }) }, + }, + { + hooks: { + onConnect: async (client) => void seen.push(['connect', client.transportKind]), + onDisconnect: async (client) => void seen.push(['disconnect', client.transportKind]), + }, + }, + ); + const { url } = await boot(t, router); + const client = await connect(t, url); + await client.load('unit'); + assert.strictEqual(await client.api.unit.ping(), 'pong'); + await client.close(); + await waitFor(() => seen.some(([kind]) => kind === 'disconnect'), 'onDisconnect never fired'); + assert.ok(seen.some(([kind, transport]) => kind === 'connect' && transport === 'ws')); +}); + +test('hooks: addHook after construction, and merge carries every level', async (t) => { + const order = []; + const base = defineRouter( + { + unit: { + hooks: { preHandler: async () => void order.push('unit') }, + run: procedure({ access: 'public', handler: async () => 'done' }), + }, + }, + { hooks: { preHandler: async () => void order.push('base-router') } }, + ); + base.addHook('preHandler', async () => void order.push('added')); + const extra = defineRouter({}, { hooks: { preHandler: async () => void order.push('other-router') } }); + const merged = base.merge(extra); + const { url } = await boot(t, merged); + const client = await connect(t, url); + await client.load('unit'); + order.length = 0; // load() is a hooked call too + assert.strictEqual(await client.api.unit.run(), 'done'); + assert.deepStrictEqual(order, ['base-router', 'added', 'other-router', 'unit']); +}); + +test('hooks: configuration mistakes are loud', () => { + assert.throws(() => defineRouter({}, { hooks: { onRequets: () => {} } }), /unknown hook phase 'onRequets'/); + assert.throws(() => defineRouter({}, { hooks: { onRequest: 'not-a-fn' } }), /must be a function/); + assert.throws( + () => defineRouter({ unit: { hooks: { onConnect: () => {} } } }), + /unknown hook phase 'onConnect'/, + 'connection phases are router-level only', + ); + assert.throws(() => defineRouter({}).addHook('nope', () => {}), /unknown hook phase/); + assert.throws(() => defineRouter({}).addHook('onRequest', 42), /must be a function/); + assert.throws(() => procedure({ handler: async () => {}, preValidation: 42 }), /must be a function/); + assert.throws(() => procedure({ handler: async () => {}, access: 'admin' }), /access must be 'public' or 'session'/); +}); + +// --------------------------------------------------------------------------- +// generateId (pluggable ids) and the wrpc.v1 subprotocol ride in this suite: +// they froze in the same phase and share the boot helpers. + +test('generateId: the client uses the injected generator for every packet id', async (t) => { + const router = defineRouter({ + unit: { echo: procedure({ access: 'public', handler: async (_context, args) => args }) }, + }); + const { url } = await boot(t, router); + let n = 0; + const client = await connect(t, url, { generateId: () => `custom-${++n}` }); + await client.load('unit'); + // load() consumed some ids already; the next call takes the next counter + // value — proving per-call invocation, not a per-client constant. + const before = n; + await client.api.unit.echo({ ok: 1 }); + assert.strictEqual(n, before + 1, 'each call draws exactly one id'); + assert.ok(n >= 2, 'load() drew ids from the same generator'); +}); + +test('generateId: the server stamps REST packets and contexts with it', async (t) => { + const router = defineRouter({ + unit: { + whoami: procedure({ access: 'public', handler: async (context) => ({ uuid: context.uuid }) }), + }, + }); + let n = 0; + const { server } = await boot(t, router, { generateId: () => `srv-${++n}` }); + const port = server.address().port; + const res = await fetch(`http://127.0.0.1:${port}/api/unit/whoami`); + const packet = await res.json(); + assert.match(packet.id, /^srv-\d+$/, 'the synthetic REST packet id comes from the injected generator'); + assert.match(packet.result.uuid, /^srv-\d+$/, 'the context uuid does too'); +}); + +test('generateId: a stream id past 255 characters is refused at the source', async (t) => { + const router = defineRouter({ + unit: { noop: procedure({ access: 'public', handler: async () => null }) }, + }); + const { url } = await boot(t, router); + const client = await connect(t, url, { generateId: () => 'x'.repeat(256) }); + assert.throws(() => client.createStream('name', 10), /at most 255 characters/); +}); + +test('subprotocol: the server echoes wrpc.v1 and the client records it', async (t) => { + const router = defineRouter({ + unit: { ping: procedure({ access: 'public', handler: async () => 'pong' }) }, + }); + const { server, url } = await boot(t, router); + const client = await connect(t, url); + await client.load('unit'); + assert.strictEqual(await client.api.unit.ping(), 'pong'); + // The engine saw the offer and selected the revision. + const connections = [...server.wsServer.connections]; + assert.strictEqual(connections.length, 1); + assert.strictEqual(connections[0].protocol, 'wrpc.v1'); +}); + +test('subprotocol: a peer that offers nothing still connects (1.0 stays valid)', async (t) => { + const router = defineRouter({ + unit: { ping: procedure({ access: 'public', handler: async () => 'pong' }) }, + }); + const { server, url } = await boot(t, router); + const client = await connect(t, url, { protocols: [] }); + await client.load('unit'); + assert.strictEqual(await client.api.unit.ping(), 'pong'); + const connections = [...server.wsServer.connections]; + assert.strictEqual(connections[0].protocol, ''); +}); + +test('meta: withMeta normalizes keys, the raw call() escape hatch does not', async (t) => { + const router = defineRouter({ + unit: { run: procedure({ access: 'public', handler: async (context) => ({ ...context.callMeta }) }) }, + }); + const { url } = await boot(t, router); + const client = await connect(t, url); + await client.load('unit'); + + // The ergonomic path normalizes, so a camelCase key addresses the same + // value it would over a header carrier. + assert.deepStrictEqual(await client.api.unit.run.withMeta({ traceId: 't1', userId: 2 })(), { + 'trace-id': 't1', + 'user-id': 2, + }); + + // The raw seam hands the wire exactly what it was given — this asymmetry + // is deliberate: it is what the auth hooks write against. + assert.deepStrictEqual(await client.call('unit/run', {}, { meta: { traceId: 't2' } }), { traceId: 't2' }); + + // Values keep their JSON types on the packet path; only a header carrier + // has to flatten them. + assert.deepStrictEqual(await client.api.unit.run.withMeta({ retryCount: 3, isRetry: true })(), { + 'retry-count': 3, + 'is-retry': true, + }); +}); diff --git a/tests/rpc/http.test.js b/tests/rpc/http.test.js new file mode 100644 index 0000000..ab957a5 --- /dev/null +++ b/tests/rpc/http.test.js @@ -0,0 +1,814 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); +const crypto = require('node:crypto'); +const { EventEmitter } = require('node:events'); + +const { Server, defineRouter, procedure } = require('../../index.js'); +const { ProtocolClient } = require('../websocket/protocolClient.js'); + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const COOKIE_RE = /^token=([^;]+); Path=\/; HttpOnly; Secure; SameSite=Lax$/; + +const createRouter = () => + defineRouter({ + echo: { + args: procedure({ access: 'public', handler: async (_context, args) => args }), + }, + auth: { + login: procedure({ + access: 'public', + handler: async (context, args) => { + context.client.startSession(undefined, { user: args.user }); + return { ok: true }; + }, + }), + whoami: procedure({ + access: 'session', + handler: async (context) => ({ user: context.session.state.user }), + }), + logout: procedure({ + access: 'session', + handler: async (context) => ({ ok: await context.client.finalizeSession() }), + }), + }, + }); + +const startServer = async (t, options = {}) => { + const server = new Server({ + router: createRouter(), + protocol: 'http', + host: '127.0.0.1', + port: 0, + logger: false, + ...options, + }); + await server.listen(); + const { port } = server.httpServer.address(); + t.after(() => server.close()); + return { server, port, origin: `http://127.0.0.1:${port}` }; +}; + +const postPacket = (url, method, args, headers = {}) => + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify({ type: 'call', id: '1', method, args }), + }); + +const login = async (origin, base, user) => { + const res = await postPacket(`${origin}${base}`, 'auth/login', { user }); + assert.strictEqual(res.status, 200); + const cookies = res.headers.getSetCookie(); + assert.strictEqual(cookies.length, 1); + const match = cookies[0].match(COOKIE_RE); + assert.ok(match, `Unexpected session cookie: ${cookies[0]}`); + return match[1]; +}; + +test('basePath default /api: packet POST endpoint', async (t) => { + const { origin } = await startServer(t); + const res = await postPacket(`${origin}/api`, 'echo/args', { a: 1, b: 'two' }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers.get('content-type'), 'application/json'); + const packet = await res.json(); + assert.deepStrictEqual(packet, { type: 'callback', id: '1', result: { a: 1, b: 'two' } }); +}); + +test('basePath default /api: REST GET with query args', async (t) => { + const { origin } = await startServer(t); + const res = await fetch(`${origin}/api/echo/args?a=1&b=two`); + assert.strictEqual(res.status, 200); + const packet = await res.json(); + assert.strictEqual(packet.type, 'callback'); + assert.match(packet.id, UUID_RE); + assert.deepStrictEqual(packet.result, { a: '1', b: 'two' }); +}); + +test('basePath default /api: path outside basePath is 404', async (t) => { + const { origin } = await startServer(t); + const res = await fetch(`${origin}/nope`); + assert.strictEqual(res.status, 404); + const packet = await res.json(); + assert.deepStrictEqual(packet, { + type: 'callback', + id: '', + error: { message: 'Not Found', code: 404 }, + }); +}); + +test('custom basePath /rpc: packet and REST move, /api becomes 404', async (t) => { + const { origin } = await startServer(t, { basePath: '/rpc' }); + + const packetRes = await postPacket(`${origin}/rpc`, 'echo/args', { x: 1 }); + assert.strictEqual(packetRes.status, 200); + assert.deepStrictEqual((await packetRes.json()).result, { x: 1 }); + + const restRes = await fetch(`${origin}/rpc/echo/args?x=2`); + assert.strictEqual(restRes.status, 200); + assert.deepStrictEqual((await restRes.json()).result, { x: '2' }); + + const missRes = await postPacket(`${origin}/api`, 'echo/args', { x: 3 }); + assert.strictEqual(missRes.status, 404); +}); + +test("basePath '': packet at / and REST at /unit/method", async (t) => { + const { origin } = await startServer(t, { basePath: '' }); + + const packetRes = await postPacket(`${origin}/`, 'echo/args', { q: 1 }); + assert.strictEqual(packetRes.status, 200); + assert.deepStrictEqual((await packetRes.json()).result, { q: 1 }); + + const restRes = await fetch(`${origin}/echo/args?q=2`); + assert.strictEqual(restRes.status, 200); + assert.deepStrictEqual((await restRes.json()).result, { q: '2' }); +}); + +test('packet endpoint: non-POST method is 403', async (t) => { + const { origin } = await startServer(t); + const res = await fetch(`${origin}/api`); + assert.strictEqual(res.status, 403); + const packet = await res.json(); + assert.deepStrictEqual(packet, { + type: 'callback', + id: '', + error: { message: 'Forbidden', code: 403 }, + }); +}); + +test('OPTIONS preflight: 200 with CORS headers, no body processing', async (t) => { + const { origin } = await startServer(t); + const res = await fetch(`${origin}/api`, { method: 'OPTIONS', body: 'not-json-at-all' }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers.get('access-control-allow-origin'), '*'); + assert.strictEqual(res.headers.get('access-control-allow-methods'), 'POST, GET, OPTIONS'); + assert.strictEqual( + res.headers.get('access-control-allow-headers'), + 'Content-Type, x-wrpc-channel, last-event-id, x-wrpc-meta', + 'the SSE transport sends two headers a preflight has to name explicitly', + ); + assert.strictEqual(await res.text(), ''); + + const anywhere = await fetch(`${origin}/definitely/not/an/api/path`, { method: 'OPTIONS' }); + assert.strictEqual(anywhere.status, 200); + assert.strictEqual(anywhere.headers.get('access-control-allow-origin'), '*'); +}); + +test('cors: no cors option means wildcard ACAO on responses', async (t) => { + const { origin } = await startServer(t); + const res = await postPacket(`${origin}/api`, 'echo/args', {}, { Origin: 'http://anything.example' }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers.get('access-control-allow-origin'), '*'); + assert.strictEqual(res.headers.get('vary'), null); +}); + +test('cors origins list: allowed origin echoed with Vary, disallowed gets no ACAO', async (t) => { + const { origin } = await startServer(t, { cors: { origins: ['http://app.example'] } }); + + const allowed = await postPacket(`${origin}/api`, 'echo/args', {}, { Origin: 'http://app.example' }); + assert.strictEqual(allowed.status, 200); + assert.strictEqual(allowed.headers.get('access-control-allow-origin'), 'http://app.example'); + assert.strictEqual(allowed.headers.get('vary'), 'Origin'); + + // Refused outright, not merely denied the header: the page could not read + // the answer either way, but the call itself must not run cross-site. + const denied = await postPacket(`${origin}/api`, 'echo/args', {}, { Origin: 'http://evil.example' }); + assert.strictEqual(denied.status, 403); + assert.strictEqual(denied.headers.get('access-control-allow-origin'), null); + assert.strictEqual(denied.headers.get('vary'), 'Origin'); +}); + +test('cors origins function decides per origin', async (t) => { + const origins = (origin) => origin.endsWith('.good.example'); + const { origin } = await startServer(t, { cors: { origins } }); + + const allowed = await postPacket(`${origin}/api`, 'echo/args', {}, { Origin: 'http://a.good.example' }); + assert.strictEqual(allowed.headers.get('access-control-allow-origin'), 'http://a.good.example'); + + const denied = await postPacket(`${origin}/api`, 'echo/args', {}, { Origin: 'http://bad.example' }); + assert.strictEqual(denied.headers.get('access-control-allow-origin'), null); +}); + +test('cors credentials: ACAC true only for allowed origins', async (t) => { + const cors = { origins: ['http://app.example'], credentials: true }; + const { origin } = await startServer(t, { cors }); + + const allowed = await postPacket(`${origin}/api`, 'echo/args', {}, { Origin: 'http://app.example' }); + assert.strictEqual(allowed.headers.get('access-control-allow-credentials'), 'true'); + + const denied = await postPacket(`${origin}/api`, 'echo/args', {}, { Origin: 'http://evil.example' }); + assert.strictEqual(denied.headers.get('access-control-allow-credentials'), null); + assert.strictEqual(denied.headers.get('access-control-allow-origin'), null); +}); + +test('cors: custom headers and methods strings are used verbatim', async (t) => { + const cors = { headers: 'Content-Type, X-Custom', methods: 'POST, OPTIONS' }; + const { origin } = await startServer(t, { cors }); + const res = await fetch(`${origin}/api`, { method: 'OPTIONS' }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers.get('access-control-allow-headers'), 'Content-Type, X-Custom'); + assert.strictEqual(res.headers.get('access-control-allow-methods'), 'POST, OPTIONS'); + assert.strictEqual(res.headers.get('access-control-allow-origin'), '*'); +}); + +test('REST args: query params merged with JSON body, body wins', async (t) => { + const { origin } = await startServer(t); + const res = await fetch(`${origin}/api/echo/args?a=1&b=query`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ b: 'body', c: 3 }), + }); + assert.strictEqual(res.status, 200); + const packet = await res.json(); + assert.deepStrictEqual(packet.result, { a: '1', b: 'body', c: 3 }); +}); + +test('http session e2e: login sets cookie, cookie restores, finalize invalidates', async (t) => { + const { origin } = await startServer(t); + const token = await login(origin, '/api', 'alice'); + assert.match(token, UUID_RE); + + const denied = await postPacket(`${origin}/api`, 'auth/whoami', {}); + assert.strictEqual(denied.status, 403); + + const whoRes = await postPacket(`${origin}/api`, 'auth/whoami', {}, { Cookie: `token=${token}` }); + assert.strictEqual(whoRes.status, 200); + assert.deepStrictEqual((await whoRes.json()).result, { user: 'alice' }); + + const logoutRes = await postPacket(`${origin}/api`, 'auth/logout', {}, { Cookie: `token=${token}` }); + assert.strictEqual(logoutRes.status, 200); + assert.deepStrictEqual((await logoutRes.json()).result, { ok: true }); + + const stale = await postPacket(`${origin}/api`, 'auth/whoami', {}, { Cookie: `token=${token}` }); + assert.strictEqual(stale.status, 403); +}); + +test('sessions option: custom cookie name is set and restored', async (t) => { + const { origin } = await startServer(t, { sessions: { cookie: { name: 'sid' } } }); + + const loginRes = await postPacket(`${origin}/api`, 'auth/login', { user: 'carol' }); + assert.strictEqual(loginRes.status, 200); + const cookies = loginRes.headers.getSetCookie(); + assert.strictEqual(cookies.length, 1); + const match = cookies[0].match(/^sid=([^;]+); Path=\/; HttpOnly; Secure; SameSite=Lax$/); + assert.ok(match, `Unexpected session cookie: ${cookies[0]}`); + const token = match[1]; + + const whoRes = await postPacket(`${origin}/api`, 'auth/whoami', {}, { Cookie: `sid=${token}` }); + assert.strictEqual(whoRes.status, 200); + assert.deepStrictEqual((await whoRes.json()).result, { user: 'carol' }); + + const wrongName = await postPacket(`${origin}/api`, 'auth/whoami', {}, { Cookie: `token=${token}` }); + assert.strictEqual(wrongName.status, 403); +}); + +// WrpcSocket-shaped fake: enough surface for RpcServer.attachSocket and +// ServerWsTransport (on/send/terminate/remoteAddress). +class FakeWsSocket extends EventEmitter { + remoteAddress = '127.0.0.1'; + #sent = []; + #waiters = []; + + send(data) { + const text = String(data); + const waiter = this.#waiters.shift(); + if (waiter) waiter(text); + else this.#sent.push(text); + return true; + } + + nextSent() { + if (this.#sent.length > 0) return Promise.resolve(this.#sent.shift()); + return new Promise((resolve) => this.#waiters.push(resolve)); + } + + terminate() { + this.emit('close'); + } +} + +test('ws attach: cookie in upgrade headers restores the http-started session', async (t) => { + const { server, origin } = await startServer(t); + const token = await login(origin, '/api', 'bob'); + + const socket = new FakeWsSocket(); + const client = server.rpc.attachSocket(socket, { headers: { cookie: `token=${token}` } }); + const restored = await client.sessionReady; + assert.strictEqual(restored, true); + assert.ok(client.session); + assert.strictEqual(client.session.token, token); + assert.strictEqual(client.session.state.user, 'bob'); + + const call = { type: 'call', id: '7', method: 'auth/whoami', args: {} }; + socket.emit('message', Buffer.from(JSON.stringify(call)), false); + const reply = JSON.parse(await socket.nextSent()); + assert.deepStrictEqual(reply, { type: 'callback', id: '7', result: { user: 'bob' } }); +}); + +test('ws attach: no cookie means no session, session-access call gets 403', async (t) => { + const { server } = await startServer(t); + const socket = new FakeWsSocket(); + const client = server.rpc.attachSocket(socket, { headers: {} }); + const restored = await client.sessionReady; + assert.strictEqual(restored, false); + assert.strictEqual(client.session, null); + + const call = { type: 'call', id: '8', method: 'auth/whoami', args: {} }; + socket.emit('message', Buffer.from(JSON.stringify(call)), false); + const reply = JSON.parse(await socket.nextSent()); + assert.deepStrictEqual(reply, { + type: 'callback', + id: '8', + error: { message: 'Forbidden', code: 403 }, + }); +}); + +test('ws attach: client.meta snapshots the upgrade (frozen, null-proto headers)', async (t) => { + const { server } = await startServer(t); + const socket = new FakeWsSocket(); + const client = server.rpc.attachSocket(socket, { + headers: { 'x-app-version': '2.1.0', cookie: 'token=nope' }, + url: '/api?x=1', + remoteAddress: '10.0.0.7', + }); + assert.strictEqual(client.meta.url, '/api?x=1'); + assert.strictEqual(client.meta.remoteAddress, '10.0.0.7'); + assert.strictEqual(client.meta.headers['x-app-version'], '2.1.0'); + assert.ok(Object.isFrozen(client.meta) && Object.isFrozen(client.meta.headers) && Object.isFrozen(client.meta.data)); + // Null-prototyped: a header named like an Object.prototype member must + // answer undefined, not a function. + assert.strictEqual(client.meta.headers.toString, undefined); + assert.strictEqual(Object.getPrototypeOf(client.meta.headers), null); + // A bare attach still carries the complete (empty) shape. + const bare = server.rpc.attachSocket(new FakeWsSocket(), {}); + assert.strictEqual(bare.meta.url, ''); + assert.strictEqual(bare.meta.remoteAddress, '127.0.0.1'); + assert.strictEqual(bare.meta.protocol, ''); + assert.deepStrictEqual(Object.keys(bare.meta.headers), []); + assert.deepStrictEqual(Object.keys(bare.meta.data), []); +}); + +const wrpcH = (value) => `wrpc_h=${encodeURIComponent(JSON.stringify(value))}`; + +test('ws attach: declared headers (wrpc_h) merge UNDER the observed ones', async (t) => { + const { server } = await startServer(t); + const declared = { + 'X-App-Version': '1.2.3', // lowercased on the way in + 'x-thing': 'declared', // observed value must win + cookie: 'token=forged', // reserved: cannot be spoofed through the URL + 'x-wrpc-channel': 'forged', // reserved prefix + 'Sec-Fetch-Site': 'same-origin', // reserved prefix + num: 5, // not a string: dropped + nested: { a: 1 }, // not a string: dropped + __proto__: { polluted: 1 }, // never carried over + }; + const socket = new FakeWsSocket(); + const client = server.rpc.attachSocket(socket, { + headers: { cookie: 'token=real', 'x-thing': 'observed' }, + url: `/api?${wrpcH(declared)}`, + }); + assert.strictEqual(client.meta.headers['x-app-version'], '1.2.3'); + assert.strictEqual(client.meta.headers['x-thing'], 'observed'); + assert.strictEqual(client.meta.headers.cookie, 'token=real'); + assert.strictEqual(client.meta.headers['x-wrpc-channel'], undefined); + assert.strictEqual(client.meta.headers['sec-fetch-site'], undefined); + assert.strictEqual(client.meta.headers.num, undefined); + assert.strictEqual(client.meta.headers.nested, undefined); + assert.strictEqual({}.polluted, undefined, 'Object.prototype survived'); + assert.strictEqual(Object.hasOwn(client.meta.headers, '__proto__'), false); +}); + +test('ws attach: malformed or oversize wrpc_h is refused, never fatal', async (t) => { + const { server } = await startServer(t, { metaMaxBytes: 64 }); + const attach = (url) => server.rpc.attachSocket(new FakeWsSocket(), { headers: { 'x-obs': 'kept' }, url }); + const cases = [ + `/api?${wrpcH({ pad: 'x'.repeat(200) })}`, // over the cap (encoded length) + '/api?wrpc_h=not-json', // malformed JSON + `/api?${wrpcH(['a', 'b'])}`, // an array + `/api?${wrpcH(7)}`, // a bare number + '/api?wrpc_h=%', // a broken percent sequence + '/api?other=1', // no parameter at all + ]; + for (const url of cases) { + const client = attach(url); + // The label is refused; the connection and the observed headers are not. + assert.strictEqual(client.meta.headers['x-obs'], 'kept', url); + assert.deepStrictEqual(Object.keys(client.meta.headers), ['x-obs'], `nothing declared survives for ${url}`); + } +}); + +test('onConnect: client.sessionReady is assigned before the hooks run (and awaiting it does not deadlock)', async (t) => { + const seen = []; + const router = createRouter(); + router.addHook('onConnect', async (client) => { + // The documented recipe: the hook awaits the restore. Before the fix it + // awaited the constructor's resolved default and saw session === null. + const restored = await client.sessionReady; + seen.push([restored, client.session?.state?.user ?? null]); + }); + const { server, origin } = await startServer(t, { router }); + const token = await login(origin, '/api', 'dana'); + + const socket = new FakeWsSocket(); + const client = server.rpc.attachSocket(socket, { headers: { cookie: `token=${token}` } }); + await client.ready; + // onConnect fires for the login POST's per-request client too; the ws + // attach is the last entry, and it must see the restored session. + assert.deepStrictEqual(seen.at(-1), [true, 'dana']); +}); + +test('ws attach: a call racing onConnect is dispatched only after the hooks settled', async (t) => { + const router = defineRouter({ + probe: { + rooms: procedure({ access: 'public', handler: async (context) => [...context.client.rooms] }), + }, + }); + router.addHook('onConnect', async (client) => { + await new Promise((resolve) => setTimeout(resolve, 20)); + client.join('lobby'); + }); + const { server } = await startServer(t, { router }); + const socket = new FakeWsSocket(); + server.rpc.attachSocket(socket, { headers: {} }); + // Sent in the same tick as the attach — the exact race client.ready closes. + const call = { type: 'call', id: '9', method: 'probe/rooms', args: {} }; + socket.emit('message', Buffer.from(JSON.stringify(call)), false); + const reply = JSON.parse(await socket.nextSent()); + assert.deepStrictEqual(reply, { type: 'callback', id: '9', result: ['lobby'] }); +}); + +test('packet POST: dispatch waits for client.ready (session restore plus onConnect hooks)', async (t) => { + const router = defineRouter({ + probe: { + stamp: procedure({ access: 'public', handler: async (context) => context.client.data.stamp ?? null }), + }, + }); + router.addHook('onConnect', async (client) => { + await new Promise((resolve) => setTimeout(resolve, 20)); + client.data.stamp = 'hooked'; + }); + const { origin } = await startServer(t, { router }); + const res = await postPacket(`${origin}/api`, 'probe/stamp', {}); + const packet = await res.json(); + assert.deepStrictEqual(packet, { type: 'callback', id: '1', result: 'hooked' }); +}); + +const wsHeaders = (extra = {}) => ({ + Upgrade: 'websocket', + Connection: 'Upgrade', + 'Sec-WebSocket-Key': crypto.randomBytes(16).toString('base64'), + 'Sec-WebSocket-Version': '13', + ...extra, +}); + +const statusOf = (result) => parseInt(result.statusLine.split(' ')[1], 10); + +test('ws upgrade: cors origins gate the handshake', async (t) => { + const { port } = await startServer(t, { cors: { origins: ['http://ok.example'] } }); + + const denied = await ProtocolClient.attemptHandshake({ + host: '127.0.0.1', + port, + path: '/api', + headers: wsHeaders({ Origin: 'http://evil.example' }), + }); + assert.strictEqual(statusOf(denied), 403); + + const allowed = await ProtocolClient.attemptHandshake({ + host: '127.0.0.1', + port, + path: '/api', + headers: wsHeaders({ Origin: 'http://ok.example' }), + }); + assert.strictEqual(statusOf(allowed), 101); + + const anonymous = await ProtocolClient.attemptHandshake({ + host: '127.0.0.1', + port, + path: '/api', + headers: wsHeaders(), + }); + assert.strictEqual(statusOf(anonymous), 101); +}); + +test('security regressions: CSRF gate, request cleanup, HTTP stream rejection', async (t) => { + await t.test('cross-site GET does not get the cookie session (CSRF)', async (sub) => { + const { origin } = await startServer(sub); + const token = await login(origin, '/api', 'victim'); + const cookie = `token=${token}`; + + // A cross-site top-level navigation carries the Lax cookie... + const crossSite = await fetch(`${origin}/api/auth/whoami`, { + headers: { cookie, 'sec-fetch-site': 'cross-site', 'sec-fetch-mode': 'navigate' }, + }); + assert.strictEqual((await crossSite.json()).error.code, 403); + + // ...while a same-origin fetch from the app still works + const sameOrigin = await fetch(`${origin}/api/auth/whoami`, { + headers: { cookie, 'sec-fetch-site': 'same-origin' }, + }); + assert.deepStrictEqual((await sameOrigin.json()).result, { user: 'victim' }); + + // non-browser peers (no Fetch metadata) keep working + const bare = await fetch(`${origin}/api/auth/whoami`, { headers: { cookie } }); + assert.deepStrictEqual((await bare.json()).result, { user: 'victim' }); + + // an unsafe method is not ambient-authority reachable, so it keeps the session + const post = await fetch(`${origin}/api/auth/whoami`, { + method: 'POST', + headers: { cookie, 'sec-fetch-site': 'cross-site' }, + }); + assert.deepStrictEqual((await post.json()).result, { user: 'victim' }); + }); + + await t.test('stream packets over HTTP are rejected instead of hanging', async (sub) => { + const { server, origin } = await startServer(sub); + const res = await fetch(`${origin}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'stream', id: 'x', name: 'u', size: 10 }), + }); + assert.strictEqual(res.status, 400); + const packet = await res.json(); + assert.match(packet.error.message, /persistent connection/); + // the client was evicted once the response was written + assert.strictEqual(server.rpc.clients.size, 0); + }); + + await t.test('an aborted request evicts its client', async (sub) => { + const { server, origin } = await startServer(sub, { + router: defineRouter({ + slow: { + wait: procedure({ + access: 'public', + handler: () => new Promise((resolve) => setTimeout(() => resolve('late'), 3000)), + }), + }, + }), + }); + const controller = new AbortController(); + const pending = fetch(`${origin}/api/slow/wait`, { signal: controller.signal }).catch(() => null); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.strictEqual(server.rpc.clients.size, 1); + controller.abort(); + await pending; + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.strictEqual(server.rpc.clients.size, 0); + }); +}); + +test('maxBodySize on the built-in Server refuses an oversized body', async (t) => { + const { origin } = await startServer(t, { maxBodySize: 256 }); + const res = await postPacket(`${origin}/api`, 'echo/args', { blob: 'x'.repeat(1024) }); + assert.strictEqual(res.status, 400); + const body = await res.json(); + assert.match(body.error.message, /Body size limit exceeded/); +}); + +test('introspection option', async (t) => { + const probe = (origin) => + postPacket(`${origin}/api`, 'system/introspect', ['echo']).then(async (res) => (await res.json()).error?.code); + + await t.test('false leaves the API surface unadvertised', async () => { + const { origin } = await startServer(t, { introspection: false }); + assert.strictEqual(await probe(origin), 404); + }); + + await t.test("'session' gates it behind a session", async () => { + const { origin } = await startServer(t, { introspection: 'session' }); + assert.strictEqual(await probe(origin), 403); + }); + + await t.test('true (the default) keeps it public', async () => { + const { origin } = await startServer(t); + assert.strictEqual(await probe(origin), undefined); + }); +}); + +test('x-wrpc-meta: a REST caller passes per-request meta; connection meta.data doubles as callMeta', async (t) => { + const router = defineRouter({ + probe: { + peek: procedure({ + access: 'public', + handler: async (context) => ({ call: { ...context.callMeta }, data: { ...context.meta.data } }), + }), + }, + }); + const { origin } = await startServer(t, { router }); + const meta = encodeURIComponent(JSON.stringify({ idem: '9f3c', v: '1.2.3' })); + // The conventional REST mode: one GET, the header carries the meta. + const res = await fetch(`${origin}/api/probe/peek`, { headers: { 'x-wrpc-meta': meta } }); + const { result } = await res.json(); + assert.deepStrictEqual(result.call, { idem: '9f3c', v: '1.2.3' }); + assert.deepStrictEqual(result.data, { idem: '9f3c', v: '1.2.3' }); + // Without the header both bags read empty, not null. + const bare = await fetch(`${origin}/api/probe/peek`); + const { result: none } = await bare.json(); + assert.deepStrictEqual(none, { call: {}, data: {} }); +}); + +test('ws attach: the wrpc_meta query lands on client.meta.data', async (t) => { + const { server } = await startServer(t); + const declared = encodeURIComponent(JSON.stringify({ v: '2.0', locale: 'de-CH' })); + const socket = new FakeWsSocket(); + const client = server.rpc.attachSocket(socket, { headers: {}, url: `/api?wrpc_meta=${declared}` }); + assert.deepStrictEqual({ ...client.meta.data }, { v: '2.0', locale: 'de-CH' }); + assert.ok(Object.isFrozen(client.meta.data)); +}); + +test('sessions.transport: a bearer strategy restores from Authorization and skips the CSRF rule', async (t) => { + const bearer = { + ambient: false, + read: ({ headers }) => { + const value = headers?.authorization; + return typeof value === 'string' && value.startsWith('Bearer ') ? value.slice(7) : null; + }, + write: () => null, // a server cannot SEND Authorization: the handler returns tokens + }; + const router = defineRouter({ + auth: { + login: procedure({ + access: 'public', + handler: async (context) => { + context.client.startSession(undefined, { user: 'bea' }); + return { token: context.session.token }; + }, + }), + whoami: procedure({ access: 'session', handler: async (context) => ({ user: context.session.state.user }) }), + }, + }); + const { server, origin } = await startServer(t, { router, sessions: { transport: bearer } }); + + const res = await postPacket(`${origin}/api`, 'auth/login', {}); + assert.strictEqual(res.status, 200); + // write() answered null, so no Set-Cookie is stamped — the token travels + // in the handler's own result instead. + assert.deepStrictEqual(res.headers.getSetCookie(), []); + const { token } = (await res.json()).result; + assert.ok(token); + + // A safe-method REST call with NO same-origin fetch header: under the + // cookie default this runs sessionless (ambient authority, CSRF); a + // bearer credential is script-attached, so it restores. + const who = await fetch(`${origin}/api/auth/whoami`, { headers: { authorization: `Bearer ${token}` } }); + assert.strictEqual(who.status, 200); + assert.deepStrictEqual((await who.json()).result, { user: 'bea' }); + + // The same header on a ws upgrade restores the session for the socket. + const socket = new FakeWsSocket(); + const client = server.rpc.attachSocket(socket, { headers: { authorization: `Bearer ${token}` } }); + assert.strictEqual(await client.sessionReady, true); + assert.strictEqual(client.session.state.user, 'bea'); + + // A wrong token is nobody. + const miss = await fetch(`${origin}/api/auth/whoami`, { headers: { authorization: 'Bearer nope' } }); + assert.strictEqual(miss.status, 403); +}); + +test('x-wrpc-meta-*: prefixed headers are the curl-friendly spelling (strings, JSON header wins)', async (t) => { + const router = defineRouter({ + probe: { + peek: procedure({ + access: 'public', + handler: async (context) => ({ call: { ...context.callMeta }, data: { ...context.meta.data } }), + }), + }, + }); + const { origin } = await startServer(t, { router, metaMaxBytes: 128 }); + + // The S3 x-amz-meta-* idiom: one header per key, no encoding needed. + const res = await fetch(`${origin}/api/probe/peek`, { + headers: { 'x-wrpc-meta-idem': '9f3c', 'x-wrpc-meta-locale': 'de-CH' }, + }); + const { result } = await res.json(); + assert.deepStrictEqual(result.call, { idem: '9f3c', locale: 'de-CH' }); + assert.deepStrictEqual(result.data, { idem: '9f3c', locale: 'de-CH' }); + + // On a key collision the canonical JSON header wins — it is the + // type-faithful channel the wrpc client emits. + const canonical = encodeURIComponent(JSON.stringify({ idem: 'json-wins', retries: 3 })); + const both = await fetch(`${origin}/api/probe/peek`, { + headers: { 'x-wrpc-meta': canonical, 'x-wrpc-meta-idem': 'prefixed', 'x-wrpc-meta-extra': 'kept' }, + }); + const merged = (await both.json()).result.call; + assert.deepStrictEqual(merged, { idem: 'json-wins', retries: 3, extra: 'kept' }); + + // Refusals: a bare prefix, a __proto__ key, and the size cap. + const bad = await fetch(`${origin}/api/probe/peek`, { + headers: { 'x-wrpc-meta-': 'nameless', 'x-wrpc-meta-__proto__': 'nope' }, + }); + assert.deepStrictEqual((await bad.json()).result.call, {}); + assert.strictEqual({}.polluted, undefined); + const over = await fetch(`${origin}/api/probe/peek`, { + headers: { 'x-wrpc-meta-pad': 'x'.repeat(200) }, + }); + assert.deepStrictEqual((await over.json()).result.call, {}, 'over the cap the whole label is refused'); + + // A malformed canonical header does not take the prefixed ones with it. + const mixed = await fetch(`${origin}/api/probe/peek`, { + headers: { 'x-wrpc-meta': '%not-json', 'x-wrpc-meta-idem': 'survives' }, + }); + assert.deepStrictEqual((await mixed.json()).result.call, { idem: 'survives' }); +}); + +test('kebab: declared header and meta keys normalize to one casing convention', async (t) => { + const { server, origin } = await startServer(t, { + router: defineRouter({ + probe: { + peek: procedure({ + access: 'public', + handler: async (context) => ({ ...context.meta.data }), + }), + }, + }), + }); + + await t.test('wrpc_h: a camelCase name becomes kebab, not a run-on word', () => { + const client = server.rpc.attachSocket(new FakeWsSocket(), { + headers: {}, + url: `/api?${wrpcH({ xAppVersion: '2', XMLHttpRequest: 'x', alreadyKebab: 'y', 'x-plain': 'z' })}`, + }); + // Before kebab-normalization this landed as `xappversion` — a key nobody + // would write in a schema.headers, which is the whole point. + assert.strictEqual(client.meta.headers['x-app-version'], '2'); + assert.strictEqual(client.meta.headers.xappversion, undefined); + assert.strictEqual(client.meta.headers['xml-http-request'], 'x'); + assert.strictEqual(client.meta.headers['already-kebab'], 'y'); + assert.strictEqual(client.meta.headers['x-plain'], 'z', 'an already-kebab name is untouched'); + }); + + await t.test('wrpc_h: the reserved deny list still applies to the normalized name', () => { + const client = server.rpc.attachSocket(new FakeWsSocket(), { + headers: {}, + url: `/api?${wrpcH({ xWrpcChannel: 'forged', secFetchSite: 'forged', Cookie: 'forged' })}`, + }); + assert.strictEqual(client.meta.headers['x-wrpc-channel'], undefined); + assert.strictEqual(client.meta.headers['sec-fetch-site'], undefined); + assert.strictEqual(client.meta.headers.cookie, undefined); + }); + + await t.test('prefixed meta headers normalize the same way', async () => { + const res = await fetch(`${origin}/api/probe/peek`, { + headers: { 'x-wrpc-meta-user-id': '7', 'x-wrpc-meta-locale': 'de-CH' }, + }); + assert.deepStrictEqual((await res.json()).result, { 'user-id': '7', locale: 'de-CH' }); + }); + + await t.test('the canonical JSON header is normalized too, and still wins a collision', async () => { + const canonical = encodeURIComponent(JSON.stringify({ userId: 'json-wins', traceId: 'abc' })); + const res = await fetch(`${origin}/api/probe/peek`, { + headers: { 'x-wrpc-meta': canonical, 'x-wrpc-meta-user-id': 'prefixed' }, + }); + // Both spellings reduce to `user-id`, so the collision is REAL and the + // canonical header is the one that survives it. + assert.deepStrictEqual((await res.json()).result, { 'user-id': 'json-wins', 'trace-id': 'abc' }); + }); +}); + +test('metaFormat: every carrier lands the same bag on client.meta.data', async (t) => { + const { origin, server } = await startServer(t, { + router: defineRouter({ + probe: { peek: procedure({ access: 'public', handler: async (ctx) => ({ ...ctx.meta.data }) }) }, + }), + }); + const { connect } = require('../../index.js'); + const declared = { userId: 7, traceId: 'abc' }; + + const seen = async (options) => { + const client = await connect(`${origin}/api`, { ...options, heartbeat: false, logger: false, meta: declared }); + t.after(() => void client.close()); + await client.load('probe'); + return client.api.probe.peek(); + }; + + // The whole point of the kebab rule: whatever carried it, ONE spelling + // addresses the value. ws keeps JSON types; a header carrier cannot. + const ws = await seen({}); + const httpJson = await seen({ transport: ['http'] }); + const httpPrefixed = await seen({ transport: ['http'], metaFormat: 'prefixed' }); + + const keys = (bag) => Object.keys(bag).sort(); + assert.deepStrictEqual(keys(ws), ['trace-id', 'user-id']); + assert.deepStrictEqual(keys(httpJson), ['trace-id', 'user-id'], 'http json disagreed with ws on keys'); + assert.deepStrictEqual(keys(httpPrefixed), ['trace-id', 'user-id'], 'the prefixed carrier disagreed on keys'); + + assert.deepStrictEqual(ws, { 'user-id': 7, 'trace-id': 'abc' }); + assert.deepStrictEqual(httpJson, { 'user-id': 7, 'trace-id': 'abc' }); + // Values are the documented difference, and the ONLY one. + assert.deepStrictEqual(httpPrefixed, { 'user-id': '7', 'trace-id': 'abc' }); + assert.ok(server); +}); + +test('cors.metaHeaders: the preflight names each prefixed meta header', async (t) => { + const { origin } = await startServer(t, { + cors: { origins: ['http://app.example'], metaHeaders: ['userId', 'locale'] }, + }); + const res = await fetch(`${origin}/api`, { method: 'OPTIONS', headers: { Origin: 'http://app.example' } }); + assert.strictEqual(res.status, 200); + const allow = res.headers.get('access-control-allow-headers'); + assert.ok(allow.includes('x-wrpc-meta-user-id'), allow); + assert.ok(allow.includes('x-wrpc-meta-locale'), allow); + // The defaults survive: replacing them would disable cross-origin SSE. + assert.ok(allow.includes('x-wrpc-channel') && allow.includes('last-event-id'), allow); +}); diff --git a/tests/rpc/observability.test.js b/tests/rpc/observability.test.js new file mode 100644 index 0000000..5e41868 --- /dev/null +++ b/tests/rpc/observability.test.js @@ -0,0 +1,262 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { RpcServer, WrpcClient, defineRouter, procedure } = require('../../index.js'); +const { bootServer } = require('../helpers/server.js'); +const { Server } = require('../../index.js'); +const { MemoryBackplane } = require('../../scaling.js'); + +// A pino-shaped collector: `child` and `level` are what mark a sink +// structured, and the merged bindings are what this file is really testing — +// they are the only evidence that the per-component and per-connection +// children were built and handed down. +const collector = () => { + const entries = []; + const make = (bindings) => ({ + level: 'trace', + bindings, + child: (extra) => make({ ...bindings, ...extra }), + log: (entry, message) => entries.push({ level: 'log', ...bindings, ...entry, message }), + info: (entry, message) => entries.push({ level: 'info', ...bindings, ...entry, message }), + debug: (entry, message) => entries.push({ level: 'debug', ...bindings, ...entry, message }), + warn: (entry, message) => entries.push({ level: 'warn', ...bindings, ...entry, message }), + error: (entry, message) => entries.push({ level: 'error', ...bindings, ...entry, message }), + }); + return { logger: make({}), entries }; +}; + +const createRouter = () => + defineRouter({ + probe: { + echo: procedure({ access: 'public', handler: async (_context, args) => args }), + boom: procedure({ + access: 'public', + handler: async () => { + throw new Error('handler exploded'); + }, + }), + }, + }); + +// Delegates to the shared boot (tests/helpers/server.js); `logger` arrives +// through options, overriding the shared quiet default. +const boot = async (t, options) => { + const { url } = await bootServer(t, { router: createRouter(), ...options }); + return url; +}; + +test('the logger reaches every component of a live server', async (t) => { + const { logger, entries } = collector(); + const url = await boot(t, { logger }); + + const client = await WrpcClient.connect(url, { reconnect: false }); + await client.load('probe'); + await client.api.probe.echo({ ok: 1 }); + await assert.rejects(client.api.probe.boom()); + await client.close(); + + await t.test('the listen line carries its event', () => { + const listen = entries.find((entry) => entry.event === 'listen'); + assert.ok(listen, 'a listen entry was written'); + assert.strictEqual(listen.level, 'info'); + }); + + await t.test('a successful call is logged with its method and peer', () => { + const ok = entries.find((entry) => entry.event === 'call.ok' && entry.method === 'probe/echo'); + assert.ok(ok, 'the probe/echo call was logged'); + assert.ok(typeof ok.peer === 'string' && ok.peer.length > 0, 'the connection binding is present'); + assert.strictEqual(ok.message, `${ok.peer}\tCALL\tprobe/echo\tOK`); + }); + + await t.test('a failing handler is logged as an error with its code', () => { + const failure = entries.find((entry) => entry.event === 'rpc.error'); + assert.ok(failure, 'an rpc.error entry was written'); + assert.strictEqual(failure.code, 500); + assert.ok(failure.err instanceof Error); + assert.ok(typeof failure.peer === 'string', 'errors carry the connection binding too'); + }); +}); + +test('component children are bound where the plan says they are', async (t) => { + await t.test('the rooms child labels a broadcast serialization failure', async () => { + const { logger, entries } = collector(); + const rpc = new RpcServer({ router: createRouter(), logger, backplane: new MemoryBackplane() }); + const circular = {}; + circular.self = circular; + // The fan-out serializes ONCE now, so the failure surfaces there — before + // local delivery and before the backplane publish — and is reported, not + // thrown at the broadcaster. + assert.strictEqual(rpc.broadcast('ping', circular), 0); + const failure = entries.find((entry) => entry.event === 'broadcast.serialize'); + assert.ok(failure, 'the non-serializable payload was reported'); + assert.strictEqual(failure.component, 'rooms'); + assert.ok(failure.err instanceof Error); + }); +}); + +test('logger: false is silent everywhere', async (t) => { + const written = []; + const original = globalThis.console; + globalThis.console = { + log: (...args) => written.push(args), + info: (...args) => written.push(args), + debug: (...args) => written.push(args), + warn: (...args) => written.push(args), + error: (...args) => written.push(args), + }; + try { + const url = await boot(t, { logger: false }); + const client = await WrpcClient.connect(url, { reconnect: false }); + await client.load('probe'); + await client.api.probe.echo({ ok: 1 }); + await assert.rejects(client.api.probe.boom()); + await client.close(); + } finally { + globalThis.console = original; + } + assert.deepStrictEqual(written, [], 'nothing leaked to the global console'); +}); + +test('a logger that throws never breaks a call', async (t) => { + const exploding = { + level: 'trace', + child() { + return this; + }, + }; + for (const level of ['log', 'info', 'debug', 'warn', 'error']) { + exploding[level] = () => { + throw new Error(`${level} exploded`); + }; + } + const url = await boot(t, { logger: exploding }); + const client = await WrpcClient.connect(url, { reconnect: false }); + await client.load('probe'); + assert.deepStrictEqual(await client.api.probe.echo({ ok: 1 }), { ok: 1 }); + // An uncaught handler error is a 500 whose message stays in the server + // log: the peer sees the status line, not the exception text. + await assert.rejects( + client.api.probe.boom(), + (error) => error.code === 500 && /Internal Server Error/.test(error.message), + ); + await client.close(); +}); + +test('the client logger is off by default and complete when on', async (t) => { + await t.test('no logger means nothing is written', async () => { + const url = await boot(t, { logger: false }); + const written = []; + const original = globalThis.console; + globalThis.console = { log: (...a) => written.push(a), error: (...a) => written.push(a) }; + try { + const client = await WrpcClient.connect(url, { reconnect: false }); + await client.load('probe'); + await assert.rejects(client.api.probe.boom()); + await client.close(); + } finally { + globalThis.console = original; + } + assert.deepStrictEqual(written, [], 'a client without a logger stays silent'); + }); + + await t.test('open and close are logged with the url', async () => { + const url = await boot(t, { logger: false }); + const { logger, entries } = collector(); + const WsTransport = WrpcClient.transport.ws; + const transport = new WsTransport(url); + const client = new WrpcClient(url, transport, { reconnect: false, logger }); + await client.open(); + const open = entries.find((entry) => entry.event === 'open'); + assert.ok(open, 'the open was logged'); + assert.strictEqual(open.url, url); + await new Promise((resolve) => { + transport.once('close', resolve); + client.close(); + }); + assert.ok( + entries.some((entry) => entry.event === 'close'), + 'the close was logged', + ); + }); + + await t.test('a client error is logged AND emitted, not one or the other', async () => { + const url = await boot(t, { logger: false }); + const { logger, entries } = collector(); + const WsTransport = WrpcClient.transport.ws; + const transport = new WsTransport(url); + const client = new WrpcClient(url, transport, { reconnect: false, logger }); + await client.open(); + const seen = []; + client.on('error', (error) => seen.push(error)); + const boom = new Error('transport blew up'); + await transport.emit('error', boom); + client.close(); + assert.deepStrictEqual(seen, [boom], 'the listener still ran'); + const logged = entries.find((entry) => entry.event === 'transport.error'); + assert.ok(logged, 'and the logger observed it too'); + assert.strictEqual(logged.err, boom); + }); +}); + +test('a subscription that dies server-side is logged', async (t) => { + const { logger, entries } = collector(); + const server = new Server({ + router: defineRouter({ + feed: { + broken: procedure({ + access: 'public', + subscription: true, + handler: async function* () { + yield 1; + throw new Error('feed collapsed'); + }, + }), + }, + }), + host: '127.0.0.1', + port: 0, + protocol: 'http', + timeouts: { bind: 100 }, + logger, + }); + await server.listen(); + t.after(() => server.close()); + + const client = await WrpcClient.connect(`ws://127.0.0.1:${server.address().port}/api`, { reconnect: false }); + await client.load('feed'); + const errors = []; + await new Promise((resolve) => { + client.api.feed.broken.subscribe( + {}, + { + onError: (error) => { + errors.push(error); + resolve(); + }, + }, + ); + }); + await client.close(); + + assert.strictEqual(errors.length, 1); + const ended = entries.find((entry) => entry.event === 'subscribe.end' && entry.code === 500); + assert.ok(ended, 'the dead subscription was logged with its code'); + assert.strictEqual(ended.method, 'feed/broken'); +}); + +test('a malformed packet is reported through the one funnel it passes', async (t) => { + const { logger, entries } = collector(); + const url = await boot(t, { logger }); + const client = await WrpcClient.connect(url, { reconnect: false }); + // The server answers the garbage with a 500 the client cannot route; a + // listener keeps that expected escalation out of the test run's output. + client.on('error', () => {}); + client.write('this is not json'); // straight onto the wire, bypassing send() + await new Promise((resolve) => setTimeout(resolve, 50)); + client.close(); + const malformed = entries.find((entry) => entry.event === 'packet.malformed'); + assert.ok(malformed, 'the unparseable frame was reported'); + assert.ok(malformed.bytes > 0); +}); diff --git a/tests/rpc/rest.test.js b/tests/rpc/rest.test.js new file mode 100644 index 0000000..c5bc4d1 --- /dev/null +++ b/tests/rpc/rest.test.js @@ -0,0 +1,752 @@ +'use strict'; + +// Declarative REST: procedure({ http, schema }) — the option validation, the +// router's per-verb route table, the shell's trie dispatch with plain-result +// bodies, and the client's REST leg over the http transport. + +const test = require('node:test'); +const assert = require('node:assert'); + +const { defineRouter, procedure, effectiveSchema } = require('../../index.js'); +const { bootServer, connectClient } = require('../helpers/server.js'); + +const api = () => + defineRouter({ + projects: { + create: procedure({ + access: 'public', + http: { method: 'POST', path: '/projects/:orgId', status: 201 }, + handler: async (_ctx, { params, query, body }) => ({ orgId: params.orgId, name: body?.name, q: query }), + }), + findById: procedure({ + access: 'public', + http: { method: 'GET', path: '/projects/:id' }, + handler: async (_ctx, { params, query }) => ({ id: params.id, q: query }), + }), + remove: procedure({ + access: 'public', + http: { method: 'DELETE', path: '/projects/:id', status: 204 }, + handler: async () => ({ discarded: true }), + }), + boom: procedure({ + access: 'public', + http: { method: 'GET', path: '/projects/:id/boom' }, + handler: async () => { + const error = new Error('gone'); + error.code = 404; + error.details = { why: 'archived' }; + throw error; + }, + }), + plain: procedure({ access: 'public', handler: async (_ctx, args) => ({ plain: true, args }) }), + }, + misc: { + // Deliberately outside every trie path: the conventional-mode probe. + plain: procedure({ access: 'public', handler: async (_ctx, args) => ({ plain: true, args }) }), + }, + }); + +test('procedure({ http }) option validation', async (t) => { + const handler = async () => 1; + + await t.test('rejects unknown verbs, bad paths and bad statuses', () => { + assert.throws(() => procedure({ handler, http: { method: 'FETCH', path: '/x' } }), /http\.method/); + assert.throws(() => procedure({ handler, http: { method: 'GET', path: 'x' } }), /http\.path/); + assert.throws(() => procedure({ handler, http: { method: 'GET', path: '/a/*' } }), /invalid segment/); + assert.throws(() => procedure({ handler, http: { method: 'GET', path: '/x', status: 42 } }), /http\.status/); + }); + + await t.test('refused on subscriptions', () => { + assert.throws( + () => procedure({ handler: async function* () {}, http: { method: 'GET', path: '/x' } }), + /does not support http/, + ); + }); + + await t.test('schema and input/output are mutually exclusive', () => { + assert.throws( + () => procedure({ handler, schema: { body: { type: 'object' } }, input: () => {} }), + /mutually exclusive/, + ); + }); + + await t.test('query and querystring are interchangeable; both-and-different throws', () => { + const q = { type: 'object' }; + assert.deepStrictEqual(procedure({ handler, schema: { query: q } }).schema, { querystring: q }); + assert.deepStrictEqual(procedure({ handler, schema: { querystring: q } }).schema, { querystring: q }); + assert.strictEqual(procedure({ handler, schema: { query: q, querystring: q } }).schema.querystring, q); + assert.throws(() => procedure({ handler, schema: { query: q, querystring: { type: 'string' } } }), /use one/); + }); +}); + +test('router REST table', async (t) => { + await t.test('per-verb trees: same position, different param names, different verbs', () => { + const router = defineRouter({ + projects: { + create: { access: 'public', http: { method: 'POST', path: '/projects/:orgId' }, handler: async () => 1 }, + findById: { access: 'public', http: { method: 'GET', path: '/projects/:id' }, handler: async () => 2 }, + }, + }); + assert.deepStrictEqual(router.matchRest('POST', ['projects', '7']).params, { orgId: '7' }); + assert.deepStrictEqual(router.matchRest('GET', ['projects', '7']).params, { id: '7' }); + }); + + await t.test('static beats param; unknown path answers null; wrong verb answers allowed', () => { + const router = api(); + assert.strictEqual(router.matchRest('GET', ['projects', 'x']).methodName, 'findById'); + assert.strictEqual(router.matchRest('GET', ['nope']), null); + assert.deepStrictEqual(router.matchRest('PATCH', ['projects', 'x']).allowed.sort(), ['DELETE', 'GET', 'POST']); + }); + + await t.test('duplicate verb+path and mixed param names in one verb throw at build', () => { + assert.throws( + () => + defineRouter({ + a: { one: { http: { method: 'GET', path: '/x' }, handler: async () => 1 } }, + b: { two: { http: { method: 'GET', path: '/x' }, handler: async () => 2 } }, + }), + /declared by both a\/one and b\/two/, + ); + assert.throws( + () => + defineRouter({ + a: { + one: { http: { method: 'GET', path: '/x/:id' }, handler: async () => 1 }, + two: { http: { method: 'GET', path: '/x/:key/deep' }, handler: async () => 2 }, + }, + }), + /same position/, + ); + }); + + await t.test('merge() carries the REST table', () => { + const merged = api().merge(defineRouter({ extra: { ping: { access: 'public', handler: async () => 1 } } })); + assert.strictEqual(merged.matchRest('POST', ['projects', '1']).methodName, 'create'); + }); + + await t.test('introspection exposes the mapping', () => { + const info = api().introspect().projects; + assert.deepStrictEqual(info.create.http, { method: 'POST', path: '/projects/:orgId', status: 201 }); + assert.strictEqual('http' in info.plain, false); + }); +}); + +test('effectiveSchema: default wrpc error responses with user override', async (t) => { + await t.test('derived from the procedure options', () => { + const proc = procedure({ + handler: async () => 1, + timeout: 50, + schema: { body: { type: 'object' }, response: { 200: { type: 'object' } } }, + }); + const codes = Object.keys(effectiveSchema(proc).response).sort(); + assert.deepStrictEqual(codes, ['200', '400', '403', '408', '429', '500', '503']); + }); + + await t.test('a public, schemaless, untimed call documents only the always-set', () => { + const proc = procedure({ access: 'public', handler: async () => 1 }); + assert.deepStrictEqual(Object.keys(effectiveSchema(proc).response).sort(), ['429', '500', '503']); + }); + + await t.test('response[code] overrides; false removes', () => { + const mine = { type: 'object', properties: { custom: { type: 'string' } } }; + const proc = procedure({ + access: 'public', + handler: async () => 1, + schema: { response: { 500: mine, 503: false } }, + }); + const { response } = effectiveSchema(proc); + assert.strictEqual(response[500], mine); + assert.strictEqual('503' in response, false); + }); +}); + +test('invokeBare keeps queue/timeout semantics without hooks or validators', async (t) => { + await t.test('timeout answers 408', async () => { + const proc = procedure({ + access: 'public', + timeout: 20, + handler: () => new Promise(() => {}), + }); + await assert.rejects(proc.invokeBare({ signal: null }, {}), (error) => error.code === 408); + }); + + await t.test('queue overflow answers 503; the slot is held until the handler settles', async () => { + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + const proc = procedure({ + access: 'public', + queue: { concurrency: 1, size: 0 }, + handler: () => gate, + }); + const first = proc.invokeBare({ signal: null }, {}); + await assert.rejects(proc.invokeBare({ signal: null }, {}), (error) => error.code === 503); + release(42); + assert.strictEqual(await first, 42); + }); + + await t.test('skips input validators and hooks by contract', async () => { + const proc = procedure({ + access: 'public', + input: () => { + throw new Error('input validator must not run'); + }, + preHandler: () => { + throw new Error('hook must not run'); + }, + handler: async (_ctx, args) => args, + }); + assert.deepStrictEqual(await proc.invokeBare({ signal: null }, { ok: 1 }), { ok: 1 }); + }); +}); + +test('shell REST dispatch: trie hits answer plain results', async (t) => { + const { server, port } = await bootServer(t, { router: api() }); + const base = `http://127.0.0.1:${port}${server.rpc.basePath}`; + + await t.test('POST with params, query and body; status from http.status', async () => { + const res = await fetch(`${base}/projects/42?x=1`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Alpha' }), + }); + assert.strictEqual(res.status, 201); + assert.deepStrictEqual(await res.json(), { orgId: '42', name: 'Alpha', q: { x: '1' } }); + }); + + await t.test('204 discards the result body', async () => { + const res = await fetch(`${base}/projects/42`, { method: 'DELETE' }); + assert.strictEqual(res.status, 204); + assert.strictEqual(await res.text(), ''); + }); + + await t.test('a known path with the wrong verb answers 405 with Allow, in REST shape', async () => { + const res = await fetch(`${base}/projects/42`, { method: 'PATCH' }); + assert.strictEqual(res.status, 405); + assert.deepStrictEqual(res.headers.get('allow').split(', ').sort(), ['DELETE', 'GET', 'POST']); + assert.deepStrictEqual(await res.json(), { message: 'Method Not Allowed', code: 405 }); + }); + + await t.test('errors answer the wire error object, details included', async () => { + const res = await fetch(`${base}/projects/1/boom`); + assert.strictEqual(res.status, 404); + assert.deepStrictEqual(await res.json(), { message: 'gone', code: 404, details: { why: 'archived' } }); + }); + + await t.test('path params are percent-decoded; malformed escapes answer 400', async () => { + const res = await fetch(`${base}/projects/a%2Fb`); + assert.deepStrictEqual((await res.json()).id, 'a/b'); + const bad = await fetch(`${base}/projects/%ZZ`); + assert.strictEqual(bad.status, 400); + }); + + await t.test('the conventional /:unit/:method mode still answers callback packets', async () => { + const res = await fetch(`${base}/misc/plain?x=1`); + assert.strictEqual(res.status, 200); + const packet = await res.json(); + assert.strictEqual(packet.type, 'callback'); + assert.deepStrictEqual(packet.result, { plain: true, args: { x: '1' } }); + }); +}); + +test('injected querystring codec parses REST queries', async (t) => { + const seen = []; + const querystring = { + parse: (text) => { + seen.push(text); + return { parsed: text }; + }, + }; + const { server, port } = await bootServer(t, { router: api(), querystring }); + const base = `http://127.0.0.1:${port}${server.rpc.basePath}`; + const res = await fetch(`${base}/projects/9?a[0]=1`); + assert.deepStrictEqual((await res.json()).q, { parsed: 'a[0]=1' }); + assert.ok(seen.length > 0); +}); + +test('client REST leg: mapped procedures travel as real REST requests', async (t) => { + const { server, port } = await bootServer(t, { router: api() }); + const base = `http://127.0.0.1:${port}${server.rpc.basePath}`; + + await t.test('same args shape, plain result, status honoured', async () => { + const client = await connectClient(t, base, { transport: 'http' }); + await client.load('projects'); + const created = await client.api.projects.create({ + params: { orgId: '7' }, + query: { x: '1' }, + body: { name: 'B' }, + }); + assert.deepStrictEqual(created, { orgId: '7', name: 'B', q: { x: '1' } }); + assert.strictEqual(await client.api.projects.remove({ params: { id: '7' } }), undefined); + }); + + await t.test('a wire error rejects with WrpcError carrying code and details', async () => { + const client = await connectClient(t, base, { transport: 'http' }); + await client.load('projects'); + await assert.rejects(client.api.projects.boom({ params: { id: '1' } }), (error) => { + assert.strictEqual(error.code, 404); + assert.strictEqual(error.message, 'gone'); + assert.deepStrictEqual(error.details, { why: 'archived' }); + return true; + }); + }); + + await t.test('a missing path param fails locally with 400', async () => { + const client = await connectClient(t, base, { transport: 'http' }); + await client.load('projects'); + await assert.rejects(client.api.projects.findById({ query: {} }), (error) => error.code === 400); + }); + + await t.test('unmapped procedures keep packet mode', async () => { + const client = await connectClient(t, base, { transport: 'http' }); + await client.load('projects'); + assert.deepStrictEqual(await client.api.projects.plain({ n: 1 }), { plain: true, args: { n: 1 } }); + }); + + await t.test('the injected client querystring serializer builds the query', async () => { + const client = await connectClient(t, base, { + transport: 'http', + querystring: { stringify: () => 'x=custom' }, + }); + await client.load('projects'); + const found = await client.api.projects.findById({ params: { id: '1' }, query: { anything: true } }); + assert.deepStrictEqual(found.q, { x: 'custom' }); + }); + + await t.test('a ws client passes the same shape through packet mode', async () => { + const client = await connectClient(t, `ws://127.0.0.1:${port}${server.rpc.basePath}`); + await client.load('projects'); + const created = await client.api.projects.create({ params: { orgId: '9' }, query: {}, body: { name: 'C' } }); + assert.deepStrictEqual(created, { orgId: '9', name: 'C', q: {} }); + }); +}); + +// --------------------------------------------------------------------------- +// Injected validation compilers (phase: ajv/fjs). Hand-rolled ajv-shaped +// fakes — no schema library enters devDependencies. + +const fakeAjv = (log = []) => ({ + compile: (schema) => { + log.push(schema); + const validate = (value) => { + validate.errors = null; + if (schema.required) { + for (const key of schema.required) { + if (value?.[key] === undefined) { + validate.errors = [{ message: `must have required property '${key}'`, instancePath: '' }]; + return false; + } + } + } + return true; + }; + return validate; + }, +}); + +test('injected validation: compile-once, per-part issues, details', async (t) => { + await t.test('compiles each declared part exactly once, at build', () => { + const compiled = []; + defineRouter( + { + p: { + make: procedure({ + access: 'public', + schema: { params: { id: 1 }, querystring: { id: 2 }, body: { id: 3 }, response: { 200: { id: 4 } } }, + handler: async () => 1, + }), + }, + }, + { validation: { ajv: fakeAjv(compiled) } }, + ); + assert.deepStrictEqual(compiled.map((s) => s.id).sort(), [1, 2, 3, 4]); + }); + + await t.test('a failing part rejects 400 with part-prefixed issue paths', async () => { + const router = defineRouter( + { + p: { + make: procedure({ + access: 'public', + schema: { body: { required: ['name'] } }, + handler: async (_ctx, args) => args, + }), + }, + }, + { validation: { ajv: fakeAjv() } }, + ); + const proc = router.getProcedure('p', '*', 'make'); + await assert.rejects(proc.invoke({ signal: null }, { body: {} }, undefined, router.compiledFor(proc)), (error) => { + assert.strictEqual(error.code, 400); + assert.deepStrictEqual(error.details, { + issues: [{ message: "must have required property 'name'", path: '/body' }], + }); + return true; + }); + }); + + await t.test('schema.headers validates the CONNECTION headers, issues prefixed /headers', async () => { + const router = defineRouter( + { + p: { + make: procedure({ + access: 'public', + schema: { headers: { required: ['x-app-version'] } }, + handler: async (ctx) => ctx.meta.headers['x-app-version'], + }), + }, + }, + { validation: { ajv: fakeAjv() } }, + ); + const proc = router.getProcedure('p', '*', 'make'); + const compiled = router.compiledFor(proc); + // A connection that presented no such header: refused 400, same issue + // shape as every other part, under its own /headers prefix. + await assert.rejects(proc.invoke({ signal: null, meta: { headers: {} } }, {}, undefined, compiled), (error) => { + assert.strictEqual(error.code, 400); + assert.deepStrictEqual(error.details, { + issues: [{ message: "must have required property 'x-app-version'", path: '/headers' }], + }); + return true; + }); + const ok = { signal: null, meta: { headers: Object.freeze({ 'x-app-version': '3' }) } }; + assert.strictEqual(await proc.invoke(ok, {}, undefined, compiled), '3'); + }); + + await t.test('a coercing ajv cannot corrupt the frozen headers snapshot', async () => { + // An ajv compiled with coerceTypes writes coerced values back into the + // object it validates — compiled validators are strict-mode code, so on + // the frozen client.meta.headers that write would THROW. The router + // therefore validates a copy; this fake mutates to prove it got one. + const coercingAjv = { + compile: () => { + const validate = (value) => { + value.coerced = 'yes'; + validate.errors = null; + return true; + }; + return validate; + }, + }; + const router = defineRouter( + { p: { make: procedure({ access: 'public', schema: { headers: {} }, handler: async () => 'ok' }) } }, + { validation: { ajv: coercingAjv } }, + ); + const proc = router.getProcedure('p', '*', 'make'); + const headers = Object.freeze({ a: '1' }); + const context = { signal: null, meta: { headers } }; + assert.strictEqual(await proc.invoke(context, {}, undefined, router.compiledFor(proc)), 'ok'); + assert.strictEqual(headers.coerced, undefined, 'the frozen snapshot stayed untouched'); + }); + + await t.test('a schema with parts and no injected ajv throws at build, naming the procedure', () => { + assert.throws( + () => + defineRouter({ + p: { make: procedure({ access: 'public', schema: { body: {} }, handler: async () => 1 }) }, + }), + /p\/make declares schema validation/, + ); + // headers is an input part like any other: declaring it demands the + // same injected compiler, loudly, at build. + assert.throws( + () => + defineRouter({ + p: { make: procedure({ access: 'public', schema: { headers: {} }, handler: async () => 1 }) }, + }), + /p\/make declares schema validation/, + ); + }); + + await t.test('merge recompiles against the surviving compilers', () => { + const compiled = []; + const withAjv = defineRouter({}, { validation: { ajv: fakeAjv(compiled) } }); + const other = defineRouter({}, { validation: { ajv: fakeAjv() } }); + other.addUnit('p', { make: procedure({ access: 'public', schema: { body: {} }, handler: async () => 1 }) }); + const merged = withAjv.merge(other); + const proc = merged.getProcedure('p', '*', 'make'); + assert.notStrictEqual(merged.compiledFor(proc), null); + assert.ok(compiled.length > 0, 'the surviving ajv compiled the schema'); + }); +}); + +test('injected serializer: the compiled fast path feeds the wire', async (t) => { + const serializer = { + compile: (schema) => (value) => JSON.stringify({ ...value, serialized: true }), + }; + const router = defineRouter( + { + p: { + make: procedure({ + access: 'public', + schema: { response: { 200: { type: 'object' } } }, + handler: async () => ({ n: 1 }), + }), + hooked: procedure({ + access: 'public', + schema: { response: { 200: { type: 'object' } } }, + handler: async () => ({ n: 2 }), + }), + }, + // onSend disables the fast path — the hook may reshape the packet. + q: { + hooks: { onSend: async () => {} }, + make: procedure({ + access: 'public', + schema: { response: { 200: { type: 'object' } } }, + handler: async () => ({ n: 3 }), + }), + }, + }, + { validation: { ajv: fakeAjv(), serializer } }, + ); + assert.strictEqual(router.hasSerializers, true); + + const { bootServer: boot } = require('../helpers/server.js'); + const { server, port } = await boot(t, { router }); + const base = `http://127.0.0.1:${port}${server.rpc.basePath}`; + + await t.test('the serialized text is what arrives', async () => { + const res = await fetch(`${base}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'call', id: '1', method: 'p/make', args: {} }), + }); + const packet = await res.json(); + assert.deepStrictEqual(packet.result, { n: 1, serialized: true }); + }); + + await t.test('an onSend hook takes the slow path — the mutated packet wins', async () => { + const res = await fetch(`${base}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'call', id: '1', method: 'q/make', args: {} }), + }); + const packet = await res.json(); + // Slow path: plain JSON.stringify of the packet — no `serialized` mark. + assert.deepStrictEqual(packet.result, { n: 3 }); + }); +}); + +test('introspection carries input schema parts; client prevalidates with injected ajv', async (t) => { + const routerWithSchemas = () => + defineRouter( + { + p: { + make: procedure({ + access: 'public', + schema: { body: { required: ['name'] }, response: { 200: { type: 'object' } } }, + handler: async (_ctx, { body }) => ({ made: body.name }), + }), + }, + }, + { validation: { ajv: fakeAjv() } }, + ); + + await t.test('info.schema holds the input parts only', () => { + const info = routerWithSchemas().introspect().p.make; + assert.deepStrictEqual(info.schema, { body: { required: ['name'] } }); + }); + + await t.test('introspection option { schemas: false } strips them', () => { + const info = routerWithSchemas().introspect(null, { schemas: false }).p.make; + assert.strictEqual('schema' in info, false); + }); + + await t.test('the client rejects locally, no request made', async () => { + const { server, port } = await bootServer(t, { router: routerWithSchemas() }); + const client = await connectClient(t, `ws://127.0.0.1:${port}${server.rpc.basePath}`, { + validation: { ajv: fakeAjv() }, + }); + await client.load('p'); + let requests = 0; + for (const peer of server.rpc.clients) peer.on('close', () => {}); + server.rpc.router.addHook?.('onRequest', async () => void requests++); + await assert.rejects(client.api.p.make({ body: {} }), (error) => { + assert.strictEqual(error.code, 400); + assert.deepStrictEqual(error.details, { + issues: [{ message: "must have required property 'name'", path: '/body' }], + }); + return true; + }); + assert.strictEqual(requests, 0, 'the doomed call never left the client'); + assert.deepStrictEqual(await client.api.p.make({ body: { name: 'x' } }), { made: 'x' }); + }); + + await t.test('without injected ajv the client sends and the server answers 400', async () => { + const { server, port } = await bootServer(t, { router: routerWithSchemas() }); + const client = await connectClient(t, `ws://127.0.0.1:${port}${server.rpc.basePath}`); + await client.load('p'); + await assert.rejects(client.api.p.make({ body: {} }), (error) => error.code === 400); + }); +}); + +// --------------------------------------------------------------------------- +// rest.version: 'auth.v1' + '/auth/signIn' -> '/v1/auth/signIn' + +const versionedApi = (rest = { version: 'path' }) => + defineRouter( + { + auth: { + signIn: procedure({ + access: 'public', + http: { method: 'POST', path: '/auth/signIn' }, + handler: async () => ({ version: 'default' }), + }), + }, + 'auth.v1': { + signIn: procedure({ + access: 'public', + http: { method: 'POST', path: '/auth/signIn' }, + handler: async () => ({ version: 'v1' }), + }), + root: procedure({ + access: 'public', + http: { method: 'GET', path: '/' }, + handler: async () => ({ root: 'v1' }), + }), + }, + }, + { rest }, + ); + +test('rest.version option validation', () => { + assert.throws(() => defineRouter({}, { rest: 'path' }), /rest must be an options object/); + assert.throws(() => defineRouter({}, { rest: { version: 'header' } }), /rest\.version must be 'path' or a function/); + assert.ok(defineRouter({}, { rest: {} }) instanceof Object, 'an empty rest object configures nothing'); + assert.throws( + () => versionedApi({ version: () => 'no-slash' }).restRoutes(), + /must produce a path starting with '\/'/, + ); +}); + +test('rest.version at the router: trie, restRoutes, introspection', async (t) => { + await t.test('the same declared path diverges by version before the conflict check', () => { + const router = versionedApi(); + assert.deepStrictEqual( + router + .restRoutes() + .map((route) => `${route.http.method} ${route.http.path}`) + .sort(), + ['GET /v1', 'POST /auth/signIn', 'POST /v1/auth/signIn'], + ); + }); + + await t.test('proc.http is the declaration and never mutates', () => { + const router = versionedApi(); + router.restRoutes(); + router.introspect(); + assert.strictEqual(router.getProcedure('auth', 'v1', 'signIn').http.path, '/auth/signIn'); + }); + + await t.test('the function form receives the vN token and the declared path', () => { + const seen = []; + const router = versionedApi({ + version: (version, path) => { + seen.push([version, path]); + return `/${version}-api${path === '/' ? '' : path}`; + }, + }); + const paths = router + .restRoutes() + .map((route) => route.http.path) + .sort(); + assert.deepStrictEqual(paths, ['/auth/signIn', '/v1-api', '/v1-api/auth/signIn']); + assert.ok(seen.every(([version]) => version === 'v1')); + }); + + await t.test('without the option the declared paths collide loudly, as before', () => { + assert.throws(() => versionedApi({}).restRoutes(), /REST route conflict/); + }); + + await t.test('merge carries the strategy either way (receiver wins)', () => { + const carried = versionedApi().merge(defineRouter({})); + assert.ok(carried.restRoutes().some((route) => route.http.path === '/v1/auth/signIn')); + const adopted = defineRouter({}).merge(versionedApi()); + assert.ok(adopted.restRoutes().some((route) => route.http.path === '/v1/auth/signIn')); + }); +}); + +test('rest.version end to end: shell dispatch, introspection, client REST leg', async (t) => { + const router = versionedApi(); + const { server, port } = await bootServer(t, { router }); + const base = `http://127.0.0.1:${port}${server.rpc.basePath}`; + + await t.test('both versions of one declared path dispatch to their own procedure', async () => { + // The server's router is the MERGED one (#withIntrospection), so this + // also proves the strategy survived the merge every boot performs. + const v1 = await fetch(`${base}/v1/auth/signIn`, { method: 'POST' }); + assert.strictEqual(v1.status, 200); + assert.deepStrictEqual(await v1.json(), { version: 'v1' }); + const def = await fetch(`${base}/auth/signIn`, { method: 'POST' }); + assert.deepStrictEqual(await def.json(), { version: 'default' }); + }); + + await t.test("a versioned path: '/' becomes /v1", async () => { + const res = await fetch(`${base}/v1`); + assert.deepStrictEqual(await res.json(), { root: 'v1' }); + }); + + await t.test('the conventional /auth.v1/signIn mode still answers packets', async () => { + const res = await fetch(`${base}/auth.v1/signIn`, { method: 'POST', body: '' }); + assert.strictEqual(res.status, 200); + const packet = await res.json(); + assert.strictEqual(packet.type, 'callback'); + assert.deepStrictEqual(packet.result, { version: 'v1' }); + }); + + await t.test('introspection ships the prefixed path, so the client REST leg calls it', async () => { + const info = server.rpc.router.introspect()['auth.v1']; + assert.strictEqual(info.signIn.http.method, 'POST'); + assert.strictEqual(info.signIn.http.path, '/v1/auth/signIn'); + const client = await connectClient(t, base, { transport: 'http' }); + await client.load('auth.v1'); + // Hitting the UNprefixed path would answer { version: 'default' } — the + // assertion below is exactly the versioned-URL proof. + assert.deepStrictEqual(await client.api['auth.v1'].signIn({}), { version: 'v1' }); + }); +}); + +test('withMeta reaches callMeta over the mapped REST leg, in both metaFormats', async (t) => { + const router = defineRouter({ + orders: { + create: procedure({ + access: 'public', + http: { method: 'POST', path: '/orders' }, + handler: async (context) => ({ ...context.callMeta }), + }), + }, + }); + const { origin } = await bootServer(t, { router }); + + const over = async (metaFormat) => { + const client = await connectClient(t, `${origin}/api`, { + transport: ['http'], + metaFormat, + meta: { tenant: 'acme', traceId: 'connection' }, + }); + await client.load('orders'); + return client.api.orders.create; + }; + + for (const metaFormat of ['json', 'prefixed']) { + const create = await over(metaFormat); + + // The gap this closes: #restCall used to destructure `signal` only, so + // withMeta on a mapped procedure was silently dropped. + const stamped = await create.withMeta({ idem: '9f3c' })({ body: {} }); + assert.strictEqual(stamped.idem, '9f3c', `withMeta was dropped in ${metaFormat} mode`); + + // On REST there is no packet, so the two channels are one: connection + // meta rides every request and the per-call half is layered over it. + assert.strictEqual(stamped.tenant, 'acme', 'connection meta must survive alongside the call meta'); + + // ...and wins a key collision, because it is the more specific of the two. + const shadowed = await create.withMeta({ traceId: 'per-call' })({ body: {} }); + assert.strictEqual(shadowed['trace-id'], 'per-call', `per-call meta lost the collision in ${metaFormat} mode`); + + // A call with no meta of its own still sees the connection bag. + assert.deepStrictEqual(await create({ body: {} }), { tenant: 'acme', 'trace-id': 'connection' }); + } +}); diff --git a/tests/rpc/rooms.test.js b/tests/rpc/rooms.test.js new file mode 100644 index 0000000..ff7373f --- /dev/null +++ b/tests/rpc/rooms.test.js @@ -0,0 +1,210 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { RoomRegistry, Broadcast } = require('../../src/rpc/rooms.js'); + +const noop = () => {}; +const quiet = { log: noop, info: noop, warn: noop, error: noop, debug: noop }; + +// Rooms only ever touch `persistent` and `sendRaw` on a client (the fan-out +// serializes once and hands every recipient the same text), which is what +// makes the registry testable without a socket underneath. +const fakeClient = (name, { persistent = true, broken = false } = {}) => ({ + name, + persistent, + received: [], + sendRaw(text) { + if (broken) throw new Error(`socket ${name} is gone`); + const packet = JSON.parse(text); + this.received.push([packet.name, packet.data]); + return true; + }, +}); + +const target = (registry, clients, options = {}) => + new Broadcast({ registry, clients: () => clients, log: quiet, ...options }); + +test('RoomRegistry: join, leave and the reverse index', async (t) => { + const registry = new RoomRegistry(); + const ada = fakeClient('ada'); + const grace = fakeClient('grace'); + + await t.test('join reports whether the membership is new', () => { + assert.strictEqual(registry.join(ada, 'chat'), true); + assert.strictEqual(registry.join(ada, 'chat'), false, 'joining twice is a no-op'); + assert.strictEqual(registry.count('chat'), 1); + }); + + await t.test('both directions stay in sync', () => { + registry.join(ada, 'lobby'); + registry.join(grace, 'chat'); + assert.deepStrictEqual(registry.list().sort(), ['chat', 'lobby']); + assert.deepStrictEqual([...registry.roomsOf(ada)].sort(), ['chat', 'lobby']); + assert.strictEqual(registry.members('chat').has(grace), true); + assert.strictEqual(registry.size, 2); + }); + + await t.test('leave removes the room once it empties', () => { + assert.strictEqual(registry.leave(ada, 'lobby'), true); + assert.strictEqual(registry.has('lobby'), false, 'an empty room is dropped'); + assert.strictEqual(registry.leave(ada, 'lobby'), false, 'leaving twice is a no-op'); + assert.strictEqual(registry.leave(ada, 'never-joined'), false); + }); + + await t.test('leaveAll clears every membership of one client', () => { + registry.join(ada, 'a'); + registry.join(ada, 'b'); + registry.leaveAll(ada); + assert.strictEqual(registry.roomsOf(ada).size, 0); + assert.strictEqual(registry.members('chat').has(ada), false); + assert.strictEqual(registry.members('chat').has(grace), true, 'other members survive'); + }); + + await t.test('a room name must be a non-empty string', () => { + assert.throws(() => registry.join(ada, ''), TypeError); + assert.throws(() => registry.join(ada, 42), TypeError); + }); +}); + +test('RoomRegistry: subscribe callbacks fire on the first and last member', () => { + const subscribed = []; + const unsubscribed = []; + const registry = new RoomRegistry({ + onSubscribe: (room) => subscribed.push(room), + onUnsubscribe: (room) => unsubscribed.push(room), + }); + const ada = fakeClient('ada'); + const grace = fakeClient('grace'); + + registry.join(ada, 'chat'); + registry.join(grace, 'chat'); + assert.deepStrictEqual(subscribed, ['chat'], 'only the first member subscribes'); + + registry.leave(ada, 'chat'); + assert.deepStrictEqual(unsubscribed, [], 'a room with members stays subscribed'); + registry.leave(grace, 'chat'); + assert.deepStrictEqual(unsubscribed, ['chat']); + + registry.join(ada, 'chat'); + assert.deepStrictEqual(subscribed, ['chat', 'chat'], 'a re-created room subscribes again'); + registry.clear(); + assert.deepStrictEqual(unsubscribed, ['chat', 'chat']); + assert.strictEqual(registry.size, 0); +}); + +test('Broadcast: targeting', async (t) => { + const registry = new RoomRegistry(); + const ada = fakeClient('ada'); + const grace = fakeClient('grace'); + const linus = fakeClient('linus'); + const clients = new Set([ada, grace, linus]); + registry.join(ada, 'chat'); + registry.join(grace, 'chat'); + registry.join(grace, 'lobby'); + registry.join(linus, 'lobby'); + + await t.test('to(room) reaches exactly its members', () => { + const sent = target(registry, clients).to('chat').emit('msg', { n: 1 }); + assert.strictEqual(sent, 2); + assert.deepStrictEqual(ada.received, [['msg', { n: 1 }]]); + assert.deepStrictEqual(linus.received, []); + }); + + await t.test('to() over several rooms is a union, each client once', () => { + for (const client of clients) client.received.length = 0; + const sent = target(registry, clients).to('chat', 'lobby').emit('msg', 2); + assert.strictEqual(sent, 3); + assert.strictEqual(grace.received.length, 1, 'a member of both rooms gets one copy'); + }); + + await t.test('except() drops a client from the fan-out', () => { + for (const client of clients) client.received.length = 0; + const sent = target(registry, clients).to('chat').except(ada).emit('msg', 3); + assert.strictEqual(sent, 1); + assert.deepStrictEqual(ada.received, []); + assert.deepStrictEqual(grace.received, [['msg', 3]]); + }); + + await t.test('with no room the target is every client', () => { + for (const client of clients) client.received.length = 0; + assert.strictEqual(target(registry, clients).emit('all', 4), 3); + assert.strictEqual(target(registry, clients).except(linus).emit('all', 5), 2); + }); + + await t.test('chaining is immutable: a stored target is not narrowed later', () => { + const chat = target(registry, clients).to('chat'); + const narrowed = chat.except(ada); + assert.notStrictEqual(chat, narrowed); + assert.deepStrictEqual(chat.rooms, ['chat']); + assert.strictEqual(target(registry, clients).rooms, null); + for (const client of clients) client.received.length = 0; + assert.strictEqual(chat.emit('msg', 6), 2, 'the original still reaches both'); + }); + + await t.test('an empty room delivers to nobody', () => { + assert.strictEqual(target(registry, clients).to('nowhere').emit('msg', 7), 0); + }); + + await t.test('narrowing to no rooms reaches nobody, not everybody', () => { + const published = []; + const rooms = []; // e.g. a computed list that came back empty + const empty = target(registry, clients, { publish: (e) => published.push(e) }).to(...rooms); + assert.deepStrictEqual(empty.rooms, []); + assert.strictEqual(empty.emit('msg', 8), 0, 'an empty target must not fall back to everyone'); + assert.deepStrictEqual(published, [], 'and there is nothing to publish either'); + }); + + await t.test('the event name must be a non-empty string', () => { + assert.throws(() => target(registry, clients).emit(''), TypeError); + assert.throws(() => target(registry, clients).emit(null), TypeError); + }); +}); + +test('Broadcast: non-persistent and broken clients', () => { + const registry = new RoomRegistry(); + const http = fakeClient('http', { persistent: false }); + const dead = fakeClient('dead', { broken: true }); + const live = fakeClient('live'); + const clients = new Set([http, dead, live]); + for (const client of clients) registry.join(client, 'chat'); + + const errors = []; + const log = { ...quiet, error: (error) => errors.push(error) }; + const sent = new Broadcast({ registry, clients: () => clients, log }).to('chat').emit('msg', 1); + + assert.strictEqual(sent, 1, 'only the live persistent client counts'); + assert.deepStrictEqual(http.received, [], 'an http client cannot carry events'); + assert.deepStrictEqual(live.received, [['msg', 1]], 'a throwing peer does not truncate the fan-out'); + assert.strictEqual(errors.length, 1); +}); + +test('Broadcast: publishing to the backplane', async (t) => { + const registry = new RoomRegistry(); + const ada = fakeClient('ada'); + const clients = new Set([ada]); + registry.join(ada, 'chat'); + + const published = []; + const publish = (envelope) => published.push(envelope); + + await t.test('a normal emit publishes alongside local delivery', () => { + target(registry, clients, { publish }).to('chat').emit('msg', { n: 1 }); + assert.deepStrictEqual(published, [{ rooms: ['chat'], name: 'msg', data: { n: 1 } }]); + }); + + await t.test('a broadcast publishes with a null room list', () => { + published.length = 0; + target(registry, clients, { publish }).emit('all', 2); + assert.deepStrictEqual(published, [{ rooms: null, name: 'all', data: 2 }]); + }); + + await t.test('local() delivers without publishing', () => { + published.length = 0; + ada.received.length = 0; + const sent = target(registry, clients, { publish }).to('chat').local().emit('msg', 3); + assert.strictEqual(sent, 1); + assert.deepStrictEqual(published, [], 'a replayed event must not be republished'); + }); +}); diff --git a/tests/rpc/roomsBackplane.test.js b/tests/rpc/roomsBackplane.test.js new file mode 100644 index 0000000..23fd406 --- /dev/null +++ b/tests/rpc/roomsBackplane.test.js @@ -0,0 +1,346 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); +const timers = require('node:timers/promises'); + +const { RoomsBackplane, BROADCAST_CHANNEL, roomChannel } = require('../../src/rpc/rooms.js'); + +const noop = () => {}; + +const settle = async () => { + for (let i = 0; i < 8; i++) await Promise.resolve(); +}; + +// retain() defers the subscribe by a microtask (so a synchronously throwing +// backplane surfaces as a rejection rather than in the caller's join), which +// is why a test controlling that promise has to let the tick pass first. +const tick = () => Promise.resolve(); + +// A controllable stand-in for an injected broker: every subscription can be +// held open, resolved late, or made to fail, which is what the reference +// counting around a room's channel has to survive. +const createBackplane = (overrides = {}) => { + const state = { subscribed: [], unsubscribed: [], published: [], handlers: new Map() }; + const backplane = { + ...state, + publish(channel, message) { + state.published.push([channel, message]); + }, + subscribe(channel, handler) { + state.subscribed.push(channel); + state.handlers.set(channel, handler); + return () => void state.unsubscribed.push(channel); + }, + close: noop, + ...overrides, + }; + backplane.state = state; + return backplane; +}; + +const createBinder = (backplane, { deliver = noop, log, linger = 0 } = {}) => { + const errors = []; + // linger: 0 — these tests exercise the immediate release mechanics; the + // grace window has its own test below. + const binder = new RoomsBackplane({ + backplane, + instance: 'node-1', + deliver, + linger, + log: log ?? { log: noop, error: (error) => errors.push(error) }, + }); + return { binder, errors }; +}; + +test('RoomsBackplane: channel reference counting', async (t) => { + const backplane = createBackplane(); + const { binder } = createBinder(backplane); + binder.start(); + await settle(); + + await t.test('the broadcast channel is held from the start', () => { + assert.deepStrictEqual(backplane.state.subscribed, [BROADCAST_CHANNEL]); + }); + + await t.test('a room subscribes once, however many joins', async () => { + binder.joinRoom('chat'); + binder.joinRoom('chat'); + await settle(); + assert.deepStrictEqual(backplane.state.subscribed, [BROADCAST_CHANNEL, roomChannel('chat')]); + }); + + await t.test('the last leave releases the channel', async () => { + binder.leaveRoom('chat'); + assert.deepStrictEqual(backplane.state.unsubscribed, [], 'a held channel is not released'); + binder.leaveRoom('chat'); + assert.deepStrictEqual(backplane.state.unsubscribed, [roomChannel('chat')]); + }); + + await t.test('releasing an unknown channel is a no-op', () => { + assert.doesNotThrow(() => binder.leaveRoom('never-joined')); + }); +}); + +test('RoomsBackplane: a subscription still in flight', async (t) => { + await t.test('a room emptied before subscribe lands is unsubscribed on arrival', async () => { + let resolveSubscribe = null; + const unsubscribed = []; + const backplane = createBackplane({ + subscribe: (channel) => + new Promise((resolve) => { + resolveSubscribe = () => resolve(() => void unsubscribed.push(channel)); + }), + }); + const { binder } = createBinder(backplane); + + binder.joinRoom('chat'); + await tick(); + binder.leaveRoom('chat'); // the room emptied while subscribe was pending + assert.deepStrictEqual(unsubscribed, [], 'there is nothing to unsubscribe yet'); + + resolveSubscribe(); + await settle(); + assert.deepStrictEqual(unsubscribed, [roomChannel('chat')], 'the late subscription is undone'); + }); + + await t.test('a rejoin before subscribe lands keeps the channel', async () => { + let resolveSubscribe = null; + const unsubscribed = []; + const backplane = createBackplane({ + subscribe: (channel) => + new Promise((resolve) => { + resolveSubscribe = () => resolve(() => void unsubscribed.push(channel)); + }), + }); + const { binder } = createBinder(backplane); + + binder.joinRoom('chat'); + await tick(); + binder.leaveRoom('chat'); + binder.joinRoom('chat'); // somebody joined again while subscribe was pending + resolveSubscribe(); + await settle(); + assert.deepStrictEqual(unsubscribed, [], 'the channel is still wanted'); + }); + + await t.test('a failing subscribe is reported, kept and RETRIED — never terminal', async () => { + let attempts = 0; + const backplane = createBackplane({ + subscribe: () => { + attempts++; + if (attempts < 2) return Promise.reject(new Error('broker refused')); + return Promise.resolve(() => {}); + }, + }); + const { binder, errors } = createBinder(backplane); + + binder.joinRoom('chat'); + await settle(); + assert.strictEqual(errors.length, 1); + assert.match(errors[0].message, /broker refused/); + // The instance is degraded, not silently deaf-forever. + assert.strictEqual(binder.healthy, false); + // The capped-backoff retry (500ms first step) re-subscribes on its own. + await timers.setTimeout(600); + await settle(); + assert.strictEqual(attempts, 2, 'the retry ran'); + assert.strictEqual(binder.healthy, true, 'recovery clears the degraded state'); + }); + + await t.test('a backplane that returns no unsubscribe function is tolerated', async () => { + const backplane = createBackplane({ subscribe: () => undefined }); + const { binder, errors } = createBinder(backplane); + binder.joinRoom('chat'); + await settle(); + assert.doesNotThrow(() => binder.leaveRoom('chat')); + assert.deepStrictEqual(errors, []); + }); +}); + +test('RoomsBackplane: failures while unsubscribing are contained', async (t) => { + await t.test('a throwing unsubscribe is reported', async () => { + const backplane = createBackplane({ + subscribe: () => () => { + throw new Error('unsubscribe blew up'); + }, + }); + const { binder, errors } = createBinder(backplane); + binder.joinRoom('chat'); + await settle(); + assert.doesNotThrow(() => binder.leaveRoom('chat')); + assert.strictEqual(errors.length, 1); + }); + + await t.test('a rejected unsubscribe is reported', async () => { + const backplane = createBackplane({ + subscribe: () => () => Promise.reject(new Error('unsubscribe failed')), + }); + const { binder, errors } = createBinder(backplane); + binder.joinRoom('chat'); + await settle(); + binder.leaveRoom('chat'); + await settle(); + assert.strictEqual(errors.length, 1); + }); +}); + +test('RoomsBackplane: publishing', async (t) => { + await t.test('a single room goes to its own channel, anything else to broadcast', () => { + const backplane = createBackplane(); + const { binder } = createBinder(backplane); + binder.publish({ rooms: ['chat'], name: 'msg', data: 1 }); + binder.publish({ rooms: ['chat', 'lobby'], name: 'msg', data: 2 }); + binder.publish({ rooms: null, name: 'msg', data: 3 }); + const channels = backplane.state.published.map(([channel]) => channel); + assert.deepStrictEqual(channels, [roomChannel('chat'), BROADCAST_CHANNEL, BROADCAST_CHANNEL]); + const envelope = JSON.parse(backplane.state.published[1][1]); + assert.deepStrictEqual(envelope, { v: 1, instance: 'node-1', rooms: ['chat', 'lobby'], name: 'msg', data: 2 }); + }); + + await t.test('a payload that cannot be serialized is reported, not thrown', () => { + const backplane = createBackplane(); + const { binder, errors } = createBinder(backplane); + const circular = {}; + circular.self = circular; + assert.doesNotThrow(() => binder.publish({ rooms: ['chat'], name: 'msg', data: circular })); + assert.strictEqual(errors.length, 1); + assert.deepStrictEqual(backplane.state.published, []); + }); + + await t.test('a synchronously throwing publish is reported', () => { + const backplane = createBackplane({ + publish() { + throw new Error('broker is down'); + }, + }); + const { binder, errors } = createBinder(backplane); + assert.doesNotThrow(() => binder.publish({ rooms: ['chat'], name: 'msg', data: 1 })); + assert.strictEqual(errors.length, 1); + }); + + await t.test('a rejected publish is reported', async () => { + const backplane = createBackplane({ publish: () => Promise.reject(new Error('publish failed')) }); + const { binder, errors } = createBinder(backplane); + binder.publish({ rooms: ['chat'], name: 'msg', data: 1 }); + await settle(); + assert.strictEqual(errors.length, 1); + }); +}); + +test('RoomsBackplane: receiving', async (t) => { + const deliveries = []; + const backplane = createBackplane(); + const { binder, errors } = createBinder(backplane, { + deliver: (rooms, name, data) => { + if (name === 'explode') throw new Error('delivery blew up'); + deliveries.push([rooms, name, data]); + }, + }); + binder.start(); + await settle(); + const feed = (envelope) => backplane.state.handlers.get(BROADCAST_CHANNEL)(JSON.stringify(envelope)); + + await t.test('a foreign envelope is delivered locally', () => { + feed({ instance: 'node-2', rooms: ['chat'], name: 'msg', data: 1 }); + assert.deepStrictEqual(deliveries, [[['chat'], 'msg', 1]]); + }); + + await t.test('a null room list means every client', () => { + deliveries.length = 0; + feed({ instance: 'node-2', rooms: null, name: 'msg', data: 2 }); + assert.deepStrictEqual(deliveries, [[null, 'msg', 2]]); + }); + + await t.test("this instance's own envelope is dropped", () => { + deliveries.length = 0; + feed({ instance: 'node-1', rooms: ['chat'], name: 'msg', data: 3 }); + assert.deepStrictEqual(deliveries, []); + }); + + await t.test('a failing delivery is reported, not thrown at the broker', () => { + assert.doesNotThrow(() => feed({ instance: 'node-2', rooms: null, name: 'explode' })); + assert.strictEqual(errors.length, 1); + }); + + await t.test('a pre-parsed envelope works too', () => { + deliveries.length = 0; + backplane.state.handlers.get(BROADCAST_CHANNEL)({ instance: 'node-2', rooms: ['chat'], name: 'msg', data: 4 }); + assert.deepStrictEqual(deliveries, [[['chat'], 'msg', 4]]); + }); +}); + +test('RoomsBackplane: close', async (t) => { + await t.test('every held channel is released and further work is a no-op', async () => { + const backplane = createBackplane(); + const { binder } = createBinder(backplane); + binder.start(); + binder.joinRoom('chat'); + await settle(); + + binder.close(); + assert.deepStrictEqual(backplane.state.unsubscribed.sort(), [BROADCAST_CHANNEL, roomChannel('chat')].sort()); + + binder.publish({ rooms: ['chat'], name: 'msg', data: 1 }); + assert.deepStrictEqual(backplane.state.published, [], 'a closed binder publishes nothing'); + binder.joinRoom('late'); + await settle(); + assert.strictEqual(backplane.state.subscribed.includes(roomChannel('late')), false); + assert.doesNotThrow(() => binder.close(), 'close is idempotent'); + }); + + await t.test('a subscription that lands after close is undone', async () => { + let resolveSubscribe = null; + const unsubscribed = []; + const backplane = createBackplane({ + subscribe: (channel) => + new Promise((resolve) => { + resolveSubscribe = () => resolve(() => void unsubscribed.push(channel)); + }), + }); + const { binder } = createBinder(backplane); + binder.joinRoom('chat'); + await tick(); + binder.close(); + resolveSubscribe(); + await settle(); + assert.deepStrictEqual(unsubscribed, [roomChannel('chat')]); + }); + + await t.test('a message arriving after close is ignored', async () => { + const deliveries = []; + const backplane = createBackplane(); + const { binder } = createBinder(backplane, { deliver: (...args) => deliveries.push(args) }); + binder.start(); + await settle(); + const handler = backplane.state.handlers.get(BROADCAST_CHANNEL); + binder.close(); + handler(JSON.stringify({ instance: 'node-2', rooms: null, name: 'msg', data: 1 })); + assert.deepStrictEqual(deliveries, []); + }); +}); + +test('RoomsBackplane: the linger window absorbs a reconnect bounce', async () => { + const subscribed = []; + const unsubscribed = []; + const backplane = createBackplane({ + subscribe: (channel) => { + subscribed.push(channel); + return Promise.resolve(() => void unsubscribed.push(channel)); + }, + }); + const { binder } = createBinder(backplane, { linger: 50 }); + binder.joinRoom('me'); + await settle(); + // The single member bounces: leave + rejoin inside the window. + binder.leaveRoom('me'); + binder.joinRoom('me'); + await settle(); + assert.strictEqual(subscribed.filter((c) => c.includes('me')).length, 1, 'no broker round trip at all'); + assert.deepStrictEqual(unsubscribed, [], 'the channel never released'); + // A leave the window EXPIRES on does release. + binder.leaveRoom('me'); + await timers.setTimeout(80); + await settle(); + assert.strictEqual(unsubscribed.length, 1); +}); diff --git a/tests/rpc/router.test.js b/tests/rpc/router.test.js new file mode 100644 index 0000000..4460452 --- /dev/null +++ b/tests/rpc/router.test.js @@ -0,0 +1,429 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); +const { setTimeout: delay } = require('node:timers/promises'); + +const { Procedure, Router, procedure, defineRouter } = require('../../src/rpc/router.js'); + +const deferred = () => { + let release = null; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, resolve: release }; +}; + +test('procedure definitions', async (t) => { + await t.test('procedure(fn) wraps a bare handler with session access', async () => { + const handler = async (context, args) => ({ context, args }); + const proc = procedure(handler); + assert.ok(proc instanceof Procedure); + assert.strictEqual(proc.access, 'session'); + assert.strictEqual(proc.handler, handler); + const context = { uuid: 'ctx-1' }; + const result = await proc.invoke(context, { x: 1 }); + assert.strictEqual(result.context, context); + assert.deepStrictEqual(result.args, { x: 1 }); + }); + + await t.test('bare function in a router definition defaults to session access', () => { + const router = defineRouter({ unit: { m: async () => 1 } }); + const proc = router.getProcedure('unit', '*', 'm'); + assert.ok(proc instanceof Procedure); + assert.strictEqual(proc.access, 'session'); + }); + + await t.test('options-object method values become procedures', async () => { + const router = defineRouter({ + unit: { sum: { access: 'public', handler: async (_context, args) => args.a + args.b } }, + }); + const proc = router.getProcedure('unit', '*', 'sum'); + assert.ok(proc instanceof Procedure); + assert.strictEqual(proc.access, 'public'); + assert.strictEqual(await proc.invoke({}, { a: 2, b: 3 }), 5); + }); + + await t.test('Procedure instances are reused as-is', () => { + const proc = procedure({ access: 'public', handler: async () => 1 }); + const router = defineRouter({ unit: { m: proc } }); + assert.strictEqual(router.getProcedure('unit', '*', 'm'), proc); + }); + + await t.test('definitions without a handler throw TypeError', () => { + assert.throws(() => procedure({}), TypeError); + assert.throws(() => procedure({ handler: 42 }), TypeError); + assert.throws(() => defineRouter({ unit: { m: {} } }), { name: 'TypeError', message: /must be a procedure/ }); + assert.throws(() => defineRouter({ unit: { m: 'nope' } }), TypeError); + }); + + await t.test('bad unit values throw TypeError', () => { + assert.throws(() => defineRouter({ unit: null }), { name: 'TypeError', message: /Invalid router unit/ }); + assert.throws(() => defineRouter({ unit: 42 }), TypeError); + }); + + await t.test('non-validator input/output options throw TypeError', () => { + assert.throws(() => procedure({ handler: async () => 1, input: 42 }), TypeError); + assert.throws(() => procedure({ handler: async () => 1, output: 'bad' }), TypeError); + }); +}); + +test('versioned units', async (t) => { + const v1 = procedure({ access: 'public', handler: async () => 'v1' }); + const def = procedure({ access: 'public', handler: async () => 'default' }); + const router = defineRouter({ + 'unit.v1': { m: v1 }, + unit: { m: def }, + }); + + await t.test('unit.v1 key registers version v1', () => { + assert.strictEqual(router.getProcedure('unit', 'v1', 'm'), v1); + }); + + await t.test('bare unit key registers the default version *', () => { + assert.strictEqual(router.getProcedure('unit', '*', 'm'), def); + }); + + await t.test('no cross-version fallback', () => { + assert.strictEqual(router.getProcedure('unit', 'v2', 'm'), null); + const only = defineRouter({ 'solo.v1': { m: v1 } }); + assert.strictEqual(only.getProcedure('solo', '*', 'm'), null); + assert.strictEqual(only.getProcedure('solo', 'v1', 'm'), v1); + }); + + await t.test('getProcedure misses return null', () => { + assert.strictEqual(router.getProcedure('nope', '*', 'm'), null); + assert.strictEqual(router.getProcedure('unit', '*', 'nope'), null); + }); + + await t.test("the version token is 'vN' and nothing else", () => { + const method = { m: async () => 1 }; + assert.throws(() => defineRouter({ 'unit.1': method }), /a unit key is 'unit' or 'unit\.vN'/); + assert.throws(() => defineRouter({ 'unit.v': method }), TypeError); + assert.throws(() => defineRouter({ 'unit.vx': method }), TypeError); + assert.throws(() => defineRouter({ 'unit.V1': method }), TypeError, 'the v is lowercase'); + assert.strictEqual(defineRouter({ 'unit.v12': method }).getProcedure('unit', 'v12', 'm') !== null, true); + }); +}); + +test('introspect', async (t) => { + const noop = async () => {}; + const router = defineRouter({ + unit: { + open: procedure({ + access: 'public', + handler: noop, + meta: { description: 'Open thing' }, + signature: { args: ['id'], result: 'object' }, + }), + plain: noop, + }, + 'unit.v1': { legacy: procedure({ access: 'public', handler: noop }) }, + other: { m: noop }, + }); + + await t.test('full introspection lists every unit and version key', () => { + assert.deepStrictEqual(router.introspect(), { + unit: { + open: { + access: 'public', + meta: { description: 'Open thing' }, + signature: { args: ['id'], result: 'object' }, + }, + plain: { access: 'session' }, + }, + 'unit.v1': { legacy: { access: 'public' } }, + other: { m: { access: 'session' } }, + }); + }); + + await t.test('empty meta and null signature are omitted', () => { + const info = router.introspect().other.m; + assert.ok(!('meta' in info)); + assert.ok(!('signature' in info)); + }); + + await t.test('filter by units list', () => { + assert.deepStrictEqual(Object.keys(router.introspect(['unit'])), ['unit']); + assert.deepStrictEqual(router.introspect(['unit.v1']), { 'unit.v1': { legacy: { access: 'public' } } }); + assert.deepStrictEqual(router.introspect(['missing']), {}); + }); +}); + +test('merge', async (t) => { + const procA = procedure({ access: 'public', handler: async () => 'a' }); + const onlyA = procedure({ access: 'public', handler: async () => 'only-a' }); + const procB = procedure({ access: 'public', handler: async () => 'b' }); + const procX = procedure({ access: 'public', handler: async () => 'x' }); + const versioned = procedure({ access: 'public', handler: async () => 'v2' }); + const a = defineRouter({ unit: { m: procA, only: onlyA }, 'legacy.v2': { m: versioned } }); + const b = defineRouter({ unit: { m: procB }, extra: { x: procX } }); + const merged = a.merge(b); + + await t.test('returns a new router with the union of units', () => { + assert.ok(merged instanceof Router); + assert.notStrictEqual(merged, a); + assert.notStrictEqual(merged, b); + assert.strictEqual(merged.getProcedure('unit', '*', 'only'), onlyA); + assert.strictEqual(merged.getProcedure('extra', '*', 'x'), procX); + assert.strictEqual(merged.getProcedure('legacy', 'v2', 'm'), versioned); + }); + + await t.test('collision: the other router wins', () => { + assert.strictEqual(merged.getProcedure('unit', '*', 'm'), procB); + }); + + await t.test('originals are untouched', () => { + assert.strictEqual(a.getProcedure('unit', '*', 'm'), procA); + assert.strictEqual(b.getProcedure('unit', '*', 'm'), procB); + assert.strictEqual(a.getProcedure('extra', '*', 'x'), null); + assert.strictEqual(b.getProcedure('legacy', 'v2', 'm'), null); + assert.strictEqual(b.getProcedure('unit', '*', 'only'), null); + }); +}); + +test('invoke validation', async (t) => { + await t.test('input validator transforms args before the handler', async () => { + const proc = procedure({ + handler: async (_context, args) => args, + input: (args) => ({ ...args, n: Number(args.n) }), + }); + assert.deepStrictEqual(await proc.invoke({}, { n: '42' }), { n: 42 }); + }); + + await t.test('input validator returning undefined keeps args', async () => { + const original = { n: 1 }; + let seen = null; + const proc = procedure({ + handler: async (_context, args) => args, + input: (args) => { + seen = args; + return undefined; + }, + }); + const result = await proc.invoke({}, original); + assert.strictEqual(result, original); + assert.strictEqual(seen, original); + }); + + await t.test('input validator rejection maps to code 400', async () => { + const proc = procedure({ + handler: async () => 'unreachable', + input: () => { + throw new Error('n must be a number'); + }, + }); + await assert.rejects(proc.invoke({}, {}), (error) => { + assert.strictEqual(error.code, 400); + assert.match(error.message, /Invalid arguments: n must be a number/); + return true; + }); + }); + + await t.test('output validator rejection maps to code 500', async () => { + const proc = procedure({ + handler: async () => ({ secret: true }), + output: () => { + throw new Error('secret leak'); + }, + }); + await assert.rejects(proc.invoke({}, {}), (error) => { + assert.strictEqual(error.code, 500); + assert.match(error.message, /Invalid procedure result: secret leak/); + return true; + }); + }); + + await t.test('output validator can transform the result', async () => { + const proc = procedure({ + handler: async () => ({ a: 1, b: 2 }), + output: (result) => ({ a: result.a }), + }); + assert.deepStrictEqual(await proc.invoke({}, {}), { a: 1 }); + }); + + await t.test('Standard Schema validator success path', async () => { + const schema = { + '~standard': { validate: async (value) => ({ value: { ...value, checked: true } }) }, + }; + const proc = procedure({ handler: async (_context, args) => args, input: schema }); + assert.deepStrictEqual(await proc.invoke({}, { n: 1 }), { n: 1, checked: true }); + }); + + await t.test('Standard Schema validator issues path maps to code 400', async () => { + const schema = { + '~standard': { + validate: async () => ({ issues: [{ message: 'name required' }, { message: 'age invalid' }] }), + }, + }; + const proc = procedure({ handler: async () => 'unreachable', input: schema }); + await assert.rejects(proc.invoke({}, {}), (error) => { + assert.strictEqual(error.code, 400); + assert.match(error.message, /name required; age invalid/); + return true; + }); + }); + + await t.test('Standard Schema issues keep their paths as structured details', async () => { + const schema = { + '~standard': { + validate: async () => ({ + issues: [ + { message: 'name required', path: ['name'] }, + { message: 'age invalid', path: ['profile', 'age'] }, + ], + }), + }, + }; + const proc = procedure({ handler: async () => 'unreachable', input: schema }); + await assert.rejects(proc.invoke({}, {}), (error) => { + assert.strictEqual(error.code, 400); + assert.strictEqual(error.expose, true); + assert.deepStrictEqual(error.details, { + issues: [ + { message: 'name required', path: ['name'] }, + { message: 'age invalid', path: ['profile', 'age'] }, + ], + }); + return true; + }); + }); +}); + +test('invoke timeout and queue', async (t) => { + await t.test('timeout rejects with code 408', async () => { + const proc = procedure({ + handler: () => delay(100).then(() => 'late'), + timeout: 30, + }); + await assert.rejects(proc.invoke({}, {}), (error) => { + assert.strictEqual(error.code, 408); + assert.match(error.message, /Procedure timeout/); + return true; + }); + }); + + await t.test('queue overflow rejects with code 503', async () => { + const gate = deferred(); + const proc = procedure({ + handler: () => gate.promise, + queue: { concurrency: 1, size: 0 }, + }); + const first = proc.invoke({}, {}); + await assert.rejects(proc.invoke({}, {}), (error) => { + assert.strictEqual(error.code, 503); + assert.match(error.message, /queue is full/); + return true; + }); + gate.resolve('done'); + assert.strictEqual(await first, 'done'); + }); + + await t.test('queued call times out with code 503', async () => { + const gate = deferred(); + const proc = procedure({ + handler: () => gate.promise, + queue: { concurrency: 1, size: 1, timeout: 20 }, + }); + const first = proc.invoke({}, {}); + const second = proc.invoke({}, {}); + await assert.rejects(second, (error) => { + assert.strictEqual(error.code, 503); + assert.match(error.message, /Semaphore timeout/); + return true; + }); + gate.resolve('done'); + assert.strictEqual(await first, 'done'); + }); + + await t.test('semaphore is released after a handler throw', async () => { + let calls = 0; + const proc = procedure({ + handler: async () => { + calls++; + if (calls === 1) throw new Error('boom'); + return 'recovered'; + }, + queue: { concurrency: 1, size: 0 }, + }); + await assert.rejects(proc.invoke({}, {}), /boom/); + assert.strictEqual(await proc.invoke({}, {}), 'recovered'); + assert.strictEqual(calls, 2); + }); +}); + +test('review regressions: keys, filters, and queue-timeout interplay', async (t) => { + await t.test('multi-dot unit keys are rejected instead of silently truncated', () => { + const method = { m: async () => 1 }; + assert.throws(() => defineRouter({ 'unit.v1.2': method }), TypeError); + assert.throws(() => defineRouter({ 'unit.': method }), TypeError); + assert.throws(() => defineRouter({ '.v1': method }), TypeError); + }); + + await t.test('introspect treats a non-array units argument as no filter', () => { + const router = defineRouter({ unit: { m: async () => 1 } }); + assert.deepStrictEqual(Object.keys(router.introspect({})), ['unit']); + assert.deepStrictEqual(Object.keys(router.introspect('unit')), ['unit']); + assert.deepStrictEqual(Object.keys(router.introspect(42)), ['unit']); + }); + + await t.test('timeout does not release the queue slot while the handler still runs', async () => { + let active = 0; + let maxActive = 0; + const proc = procedure({ + timeout: 30, + queue: { concurrency: 1, size: 2 }, + handler: async () => { + active++; + maxActive = Math.max(maxActive, active); + await delay(80); + active--; + return 'done'; + }, + }); + const first = proc.invoke({}, {}); + const second = proc.invoke({}, {}); + await assert.rejects(first, (error) => error.code === 408); + // the second call must NOT start before the first handler settles + await assert.rejects(second, (error) => error.code === 408); + await delay(200); + assert.strictEqual(maxActive, 1); + assert.strictEqual(active, 0); + // slots released after settlement: a fresh call on the same procedure works + assert.strictEqual(await procedure({ queue: { concurrency: 1 }, handler: async () => 3 }).invoke({}, {}), 3); + }); + + await t.test('validation failure with a queue releases the slot', async () => { + const proc = procedure({ + queue: { concurrency: 1, size: 0 }, + input: () => { + throw new Error('nope'); + }, + handler: async () => 1, + }); + await assert.rejects(proc.invoke({}, {}), (error) => error.code === 400); + // slot free again: the next call reaches the validator, not a 503 + await assert.rejects(proc.invoke({}, {}), (error) => error.code === 400); + }); +}); + +test('addUnit: post-construction units for the mirror feature', async (t) => { + const { defineRouter, procedure } = require('../../index.js'); + + await t.test('adds a unit and rebuilds the hook chains', async () => { + const trace = []; + const router = defineRouter({}, { hooks: { preHandler: async () => void trace.push('router') } }); + router.addUnit('late', { hello: procedure({ access: 'public', handler: async () => 'hi' }) }); + const proc = router.getProcedure('late', '*', 'hello'); + assert.notStrictEqual(proc, null); + assert.strictEqual(router.hooksFor(proc).preHandler.length, 1); + }); + + await t.test('refuses a duplicate unit key', () => { + const router = defineRouter({ a: { x: procedure({ access: 'public', handler: async () => 1 }) } }); + assert.throws(() => router.addUnit('a', {}), /already registered/); + // A different VERSION of the same unit is a different key. + router.addUnit('a.v2', { x: procedure({ access: 'public', handler: async () => 2 }) }); + assert.notStrictEqual(router.getProcedure('a', 'v2', 'x'), null); + }); +}); diff --git a/tests/rpc/sessions.test.js b/tests/rpc/sessions.test.js new file mode 100644 index 0000000..4a52826 --- /dev/null +++ b/tests/rpc/sessions.test.js @@ -0,0 +1,397 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); +const { setImmediate: settle, setTimeout: delay } = require('node:timers/promises'); + +const { Session, MemorySessionStore, SessionManager } = require('../../src/rpc/sessions.js'); +const { RpcServer } = require('../../src/rpc/core.js'); +const { defineRouter, procedure } = require('../../src/rpc/router.js'); + +const noop = () => {}; +const quiet = { log: noop, info: noop, warn: noop, error: noop, debug: noop }; + +const capturingStore = () => { + const sets = []; + return { + sets, + async get() { + return null; + }, + async set(token, data) { + sets.push({ token, data: { ...data } }); + }, + async delete() {}, + }; +}; + +test('MemorySessionStore', async (t) => { + await t.test('get miss returns null', async () => { + const store = new MemorySessionStore(); + assert.strictEqual(await store.get('missing'), null); + }); + + await t.test('set then get returns the stored data', async () => { + const store = new MemorySessionStore(); + const data = { user: 'alex' }; + await store.set('tok', data); + assert.strictEqual(await store.get('tok'), data); + }); + + await t.test('delete removes the entry', async () => { + const store = new MemorySessionStore(); + await store.set('tok', { user: 'alex' }); + await store.delete('tok'); + assert.strictEqual(await store.get('tok'), null); + }); +}); + +test('SessionManager create', async (t) => { + await t.test('generates a token when omitted', () => { + const manager = new SessionManager({}, quiet); + const session = manager.create(); + assert.ok(session instanceof Session); + assert.match(session.token, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + + await t.test('uses the provided token', () => { + const manager = new SessionManager({}, quiet); + assert.strictEqual(manager.create('explicit').token, 'explicit'); + }); + + await t.test('persists the initial data to the store', async () => { + const store = capturingStore(); + const manager = new SessionManager({ store }, quiet); + manager.create('tok', { user: 'alex' }); + await settle(); + assert.deepStrictEqual(store.sets, [{ token: 'tok', data: { user: 'alex' } }]); + }); + + await t.test('state proxy auto-saves on property set', async () => { + const store = capturingStore(); + const manager = new SessionManager({ store }, quiet); + const session = manager.create('tok', { count: 0 }); + session.state.count = 5; + session.state.name = 'alex'; + await settle(); + assert.strictEqual(store.sets.length, 3); + assert.deepStrictEqual(store.sets[1], { token: 'tok', data: { count: 5 } }); + assert.deepStrictEqual(store.sets[2], { token: 'tok', data: { count: 5, name: 'alex' } }); + assert.strictEqual(session.state.count, 5); + assert.strictEqual(session.state.name, 'alex'); + }); + + await t.test('custom generateToken is used for omitted tokens', () => { + let n = 0; + const manager = new SessionManager({ generateToken: () => `tok-${++n}` }, quiet); + assert.strictEqual(manager.create().token, 'tok-1'); + assert.strictEqual(manager.create().token, 'tok-2'); + assert.strictEqual(manager.create('explicit').token, 'explicit'); + }); +}); + +test('SessionManager restore and destroy', async (t) => { + await t.test('restore returns a session with the stored state', async () => { + const manager = new SessionManager({}, quiet); + manager.create('tok', { user: 'alex' }); + await settle(); + const restored = await manager.restore('tok'); + assert.ok(restored instanceof Session); + assert.strictEqual(restored.token, 'tok'); + assert.strictEqual(restored.state.user, 'alex'); + }); + + await t.test('restored session auto-saves too', async () => { + const store = new MemorySessionStore(); + const manager = new SessionManager({ store }, quiet); + manager.create('tok', { count: 0 }); + await settle(); + const restored = await manager.restore('tok'); + restored.state.count = 7; + await settle(); + const data = await store.get('tok'); + assert.strictEqual(data.count, 7); + }); + + await t.test('restore miss returns null', async () => { + const manager = new SessionManager({}, quiet); + assert.strictEqual(await manager.restore('missing'), null); + }); + + await t.test('destroy deletes the session from the store', async () => { + const manager = new SessionManager({}, quiet); + manager.create('tok', { user: 'alex' }); + await settle(); + await manager.destroy('tok'); + assert.strictEqual(await manager.restore('tok'), null); + }); +}); + +test('SessionManager cookies', async (t) => { + await t.test('cookieHeader has the default attributes', () => { + const manager = new SessionManager({}, quiet); + const header = manager.cookieHeader('abc123'); + assert.ok(header.startsWith('token=abc123')); + assert.ok(header.includes('Path=/')); + assert.ok(header.includes('HttpOnly')); + assert.ok(header.includes('Secure')); + assert.ok(header.includes('SameSite=Lax')); + assert.ok(!header.includes('Max-Age')); + }); + + await t.test('cookieDeleteHeader expires the cookie', () => { + const manager = new SessionManager({}, quiet); + const header = manager.cookieDeleteHeader(); + assert.ok(header.startsWith('token=deleted')); + assert.ok(header.includes('Max-Age=0')); + assert.ok(header.includes('Path=/')); + assert.ok(header.includes('HttpOnly')); + }); + + await t.test('maxAge option adds Max-Age', () => { + const manager = new SessionManager({ cookie: { maxAge: 3600 } }, quiet); + assert.ok(manager.cookieHeader('t').includes('Max-Age=3600')); + }); + + await t.test('custom cookie name is used everywhere', () => { + const manager = new SessionManager({ cookie: { name: 'sid' } }, quiet); + assert.ok(manager.cookieHeader('t').startsWith('sid=t')); + assert.ok(manager.cookieDeleteHeader().startsWith('sid=deleted')); + assert.strictEqual(manager.readToken({ sid: 'x' }), 'x'); + assert.strictEqual(manager.readToken({ token: 'x' }), null); + }); + + await t.test('readToken reads the token cookie', () => { + const manager = new SessionManager({}, quiet); + assert.strictEqual(manager.readToken({ token: 'abc' }), 'abc'); + assert.strictEqual(manager.readToken({}), null); + }); +}); + +test('SessionManager with a custom async store', async () => { + const map = new Map(); + const store = { + async get(token) { + await delay(1); + return map.get(token) ?? null; + }, + async set(token, data) { + await delay(1); + map.set(token, { ...data }); + }, + async delete(token) { + await delay(1); + map.delete(token); + }, + }; + const manager = new SessionManager({ store }, quiet); + const session = manager.create('slow', { hits: 0 }); + await delay(5); + assert.deepStrictEqual(map.get('slow'), { hits: 0 }); + session.state.hits = 1; + await delay(5); + assert.deepStrictEqual(map.get('slow'), { hits: 1 }); + const restored = await manager.restore('slow'); + assert.strictEqual(restored.state.hits, 1); + restored.state.hits = 2; + await delay(5); + assert.deepStrictEqual(map.get('slow'), { hits: 2 }); + await manager.destroy('slow'); + assert.strictEqual(await manager.restore('slow'), null); +}); + +test('RpcServer session isolation', async (t) => { + const makeRouter = () => + defineRouter({ + unit: { hello: procedure({ access: 'public', handler: async () => 'hi' }) }, + }); + + await t.test('default stores are per-server', async () => { + const serverA = new RpcServer({ router: makeRouter(), logger: false }); + const serverB = new RpcServer({ router: makeRouter(), logger: false }); + serverA.sessions.create('tokA', { user: 'alex' }); + await settle(); + assert.ok(await serverA.sessions.restore('tokA')); + assert.strictEqual(await serverB.sessions.restore('tokA'), null); + await serverA.close(); + await serverB.close(); + }); + + await t.test('a shared store is shared', async () => { + const store = new MemorySessionStore(); + const serverA = new RpcServer({ router: makeRouter(), sessions: { store }, logger: false }); + const serverB = new RpcServer({ router: makeRouter(), sessions: { store }, logger: false }); + serverA.sessions.create('tokA', { user: 'alex' }); + await settle(); + const restored = await serverB.sessions.restore('tokA'); + assert.ok(restored instanceof Session); + assert.strictEqual(restored.state.user, 'alex'); + await serverA.close(); + await serverB.close(); + }); +}); + +test('initializeSession with the same token does not race delete against set', async () => { + const { Client } = require('../../src/rpc/core.js'); + const deletes = []; + const map = new Map(); + const store = { + async get(token) { + return map.get(token) ?? null; + }, + async set(token, data) { + map.set(token, data); + }, + async delete(token) { + deletes.push(token); + map.delete(token); + }, + }; + const noop = () => {}; + const quiet = { log: noop, info: noop, warn: noop, error: noop }; + const sessions = new SessionManager({ store }, quiet); + const transport = { source: 'x', connection: {}, send: noop, error: noop, close: noop, once: noop }; + const client = new Client(transport, { sessions, logger: false }); + + client.initializeSession('same-token', { round: 1 }); + client.initializeSession('same-token', { round: 2 }); // refresh, no finalize + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepStrictEqual(deletes, []); // the old delete would race the new set + assert.deepStrictEqual(map.get('same-token'), { round: 2 }); + + client.initializeSession('other-token', { round: 3 }); // different token DOES finalize + await new Promise((resolve) => setImmediate(resolve)); + assert.deepStrictEqual(deletes, ['same-token']); +}); + +test('MemorySessionStore bounds', async (t) => { + await t.test('evicts the least recently used entry past maxSessions', async () => { + const store = new MemorySessionStore({ maxSessions: 2 }); + await store.set('a', { n: 1 }); + await store.set('b', { n: 2 }); + await store.get('a'); // 'a' becomes the most recently used + await store.set('c', { n: 3 }); + assert.strictEqual(store.size, 2); + assert.strictEqual(await store.get('b'), null); + assert.deepStrictEqual(await store.get('a'), { n: 1 }); + assert.deepStrictEqual(await store.get('c'), { n: 3 }); + }); + + await t.test('expires entries past the ttl', async () => { + let clock = 1000; + const store = new MemorySessionStore({ ttl: 100, now: () => clock }); + await store.set('a', { n: 1 }); + clock += 50; + assert.deepStrictEqual(await store.get('a'), { n: 1 }); + clock += 100; + assert.strictEqual(await store.get('a'), null); + assert.strictEqual(store.size, 0); + }); + + await t.test('a write sweeps already-expired entries', async () => { + let clock = 0; + const store = new MemorySessionStore({ ttl: 10, now: () => clock }); + await store.set('old', { n: 1 }); + clock += 50; + await store.set('fresh', { n: 2 }); + assert.strictEqual(store.size, 1); + assert.deepStrictEqual(await store.get('fresh'), { n: 2 }); + }); + + await t.test('ttl 0 and maxSessions 0 disable the bounds', async () => { + const store = new MemorySessionStore({ ttl: 0, maxSessions: 0 }); + for (let i = 0; i < 50; i++) await store.set(`t${i}`, { i }); + assert.strictEqual(store.size, 50); + assert.deepStrictEqual(await store.get('t0'), { i: 0 }); + }); +}); + +test('session state proxy: wire-shaped keys cannot mutate the prototype', async () => { + const manager = new SessionManager({ store: capturingStore() }, quiet); + const session = manager.create('tok', {}); + // Built at runtime: the literal member access is what the lint forbids, + // and a wire-supplied key is exactly a runtime string. + const protoKey = ['__pro', 'to__'].join(''); + session.state[protoKey] = { polluted: true }; + assert.strictEqual({}.polluted, undefined, 'Object.prototype must stay untouched'); + assert.strictEqual(Object.getPrototypeOf(session.state), Object.prototype); + assert.strictEqual(session.state[protoKey].polluted, true, 'the value lands as an own property'); +}); + +test('buildCookie: RFC 6265 grammar is enforced', async (t) => { + const { buildCookie } = require('../../src/rpc/sessions.js'); + const options = { path: '/', httpOnly: true, secure: true, sameSite: 'Lax', maxAge: null }; + + await t.test('a clean token passes', () => { + assert.match(buildCookie('token', 'abc-123', options), /^token=abc-123; /); + }); + + await t.test('a token that smuggles attributes is refused', () => { + assert.throws(() => buildCookie('token', 'x; Domain=evil.example', options), /forbids/); + assert.throws(() => buildCookie('token', 'x\r\nSet-Cookie: y=z', options), /forbids/); + }); + + await t.test('bad names, paths and sameSite values are refused', () => { + assert.throws(() => buildCookie('to ken', 'x', options), /name/); + assert.throws(() => buildCookie('token', 'x', { ...options, path: '/; Secure' }), /Path/); + assert.throws(() => buildCookie('token', 'x', { ...options, sameSite: 'Sneaky' }), /sameSite/); + }); +}); + +test('MemorySessionStore.touch slides the expiry under active use', async () => { + let now = 1000; + const store = new MemorySessionStore({ ttl: 100, now: () => now }); + await store.set('tok', { user: 'ada' }); + now = 1090; // 10ms before expiry + await store.touch('tok'); + now = 1150; // past the ORIGINAL expiry, inside the touched one + assert.deepStrictEqual(await store.get('tok'), { user: 'ada' }); + now = 1250; // past the touched expiry too + assert.strictEqual(await store.get('tok'), null); +}); + +test('SessionManager.restore touches the store when it can', async () => { + const touched = []; + const store = { + async get() { + return { user: 'ada' }; + }, + async set() {}, + async delete() {}, + async touch(token) { + touched.push(token); + }, + }; + const manager = new SessionManager({ store }, quiet); + const session = await manager.restore('tok'); + assert.strictEqual(session.state.user, 'ada'); + await settle(); + assert.deepStrictEqual(touched, ['tok'], 'restore is active use — the TTL must slide'); +}); + +test('token transport: the cookie default and the structural check', async (t) => { + const { SessionManager, isTokenTransport } = require('../../src/rpc/sessions.js'); + const quiet = { error: () => {}, warn: () => {}, info: () => {}, debug: () => {} }; + + await t.test('the default transport IS the cookie behaviour, ambient', () => { + const manager = new SessionManager({}, quiet); + const { transport } = manager; + assert.strictEqual(transport.ambient, true); + assert.strictEqual(transport.read({ headers: { cookie: 'token=abc' } }), 'abc'); + assert.strictEqual(transport.read({ headers: {} }), null); + assert.strictEqual(transport.write('abc'), manager.cookieHeader('abc')); + assert.strictEqual(transport.clear(), manager.cookieDeleteHeader()); + }); + + await t.test('a malformed injection throws loudly at construction', () => { + assert.throws(() => new SessionManager({ transport: { read: () => null } }, quiet), /sessions\.transport/); + assert.throws(() => new SessionManager({ transport: 'cookie' }, quiet), /sessions\.transport/); + }); + + await t.test('isTokenTransport is the structural gate', () => { + assert.strictEqual(isTokenTransport({ read: () => null, write: () => null }), true); + assert.strictEqual(isTokenTransport({ read: () => null }), false); + assert.strictEqual(isTokenTransport(null), false); + }); +}); diff --git a/tests/rpc/subscriptions.test.js b/tests/rpc/subscriptions.test.js new file mode 100644 index 0000000..30d6e93 --- /dev/null +++ b/tests/rpc/subscriptions.test.js @@ -0,0 +1,582 @@ +'use strict'; + +const timers = require('node:timers/promises'); +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { Server, WrpcClient, defineRouter, procedure } = require('../../index.js'); +const { tracked, createEventLog, createEventStream } = require('../../index.js'); + +const noop = () => {}; + +const waitFor = async (predicate, message = 'condition never held') => { + for (let i = 0; i < 200; i++) { + if (predicate()) return; + await timers.setTimeout(5); + } + assert.fail(message); +}; + +// A subscription that never answers would hang the whole run rather than +// fail it: node's runner has no default per-test timeout. +const within = (promise, label, ms = 2000) => + Promise.race([ + promise, + timers.setTimeout(ms).then(() => { + throw new Error(`timed out waiting for ${label}`); + }), + ]); + +// Sends a hand-built packet on a bare socket and resolves with the raw +// answer: the scaffolded api deliberately cannot express these mistakes. +const rawPacket = async (url, packet) => { + const socket = new WebSocket(url); + await new Promise((resolve) => socket.addEventListener('open', resolve, { once: true })); + const answer = new Promise((resolve) => { + socket.addEventListener('message', ({ data }) => resolve(JSON.parse(data)), { once: true }); + }); + socket.send(JSON.stringify({ args: {}, ...packet })); + try { + return await answer; + } finally { + socket.close(); + } +}; + +const createServer = async (router, options = {}) => { + const server = new Server({ + router, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 50 }, + retry: 20, + ...options, + }); + await server.listen(); + return { server, port: server.address().port }; +}; + +// --------------------------------------------------------------------------- +// The primitives, on their own + +test('tracked / createEventLog: the replay buffer', async (t) => { + // A fixed epoch pins the ids for the assertions; the default is random + // per instance (that is what makes cross-process resume honest). + const log = createEventLog({ size: 3, epoch: 'e' }); + + await t.test('ids are monotonic, epoch-stamped, and push returns them', () => { + assert.strictEqual(log.push('a'), 'e.0'); + assert.strictEqual(log.push('b'), 'e.1'); + assert.strictEqual(log.lastEventId, 'e.1'); + assert.strictEqual(log.epoch, 'e'); + assert.strictEqual(log.length, 2); + }); + + await t.test('since() replays everything after an id, as tracked values', () => { + const missed = log.since('e.0'); + assert.deepStrictEqual(missed, [tracked('e.1', 'b')]); + assert.deepStrictEqual(log.since('e.1'), [], 'nothing was missed'); + }); + + await t.test('no lastEventId means nothing to replay', () => { + assert.deepStrictEqual(log.since(undefined), []); + assert.deepStrictEqual(log.since(null), []); + assert.deepStrictEqual(log.since(''), []); + }); + + await t.test('an evicted id still resumes while nothing after it is missing', () => { + log.push('c'); + log.push('d'); // evicts 'a' (id e.0), leaving e.1..e.3 + assert.strictEqual(log.length, 3); + // The client saw e.0; e.1..e.3 are all still held, nothing is missing. + assert.deepStrictEqual(log.since('e.0'), [tracked('e.1', 'b'), tracked('e.2', 'c'), tracked('e.3', 'd')]); + assert.deepStrictEqual(log.since('e.1'), [tracked('e.2', 'c'), tracked('e.3', 'd')]); + }); + + await t.test('a real gap is an honest null, not a truncated history', () => { + log.push('e'); // evicts id e.1, leaving e.2..e.4 + assert.strictEqual(log.since('e.0'), null, 'the caller must know it cannot resume'); + assert.deepStrictEqual(log.since('e.2'), [tracked('e.3', 'd'), tracked('e.4', 'e')]); + }); + + await t.test('a foreign epoch cannot resume — another process minted it', () => { + assert.strictEqual(log.since('other.2'), null); + assert.strictEqual(log.since('2'), null, 'a pre-epoch numeric id is foreign too'); + }); + + await t.test('a nonsense id cannot resume either', () => { + assert.strictEqual(log.since('not-a-number'), null); + assert.strictEqual(log.since('e.NaN'), null); + }); + + await t.test('an id newer than everything held means nothing was missed', () => { + assert.deepStrictEqual(log.since('e.99'), []); + }); + + await t.test('size must be a positive integer, epoch must be dot-free', () => { + assert.throws(() => createEventLog({ size: 0 }), TypeError); + assert.throws(() => createEventLog({ size: 1.5 }), TypeError); + assert.throws(() => createEventLog({ epoch: 'a.b' }), TypeError); + }); +}); + +test('createEventStream: push becomes pull', async (t) => { + await t.test('values arrive in order, and end() finishes the loop', async () => { + const stream = createEventStream(); + stream.push(1); + stream.push(2); + queueMicrotask(() => { + stream.push(3); + stream.end(); + }); + const seen = []; + for await (const value of stream) seen.push(value); + assert.deepStrictEqual(seen, [1, 2, 3]); + }); + + await t.test('a consumer waiting is resolved by the next push', async () => { + const stream = createEventStream(); + const pending = stream.next(); + stream.push('late'); + assert.deepStrictEqual(await pending, { value: 'late', done: false }); + }); + + await t.test('an abort signal ends it', async () => { + const controller = new AbortController(); + const stream = createEventStream({ signal: controller.signal }); + const pending = stream.next(); + controller.abort(); + assert.deepStrictEqual(await pending, { value: undefined, done: true }); + assert.strictEqual(stream.closed, true); + }); + + await t.test('an already-aborted signal yields an empty stream', async () => { + const controller = new AbortController(); + controller.abort(); + const stream = createEventStream({ signal: controller.signal }); + assert.deepStrictEqual(await stream.next(), { value: undefined, done: true }); + }); + + await t.test('the queue is bounded and reports what it dropped', () => { + const stream = createEventStream({ highWaterMark: 2 }); + stream.push(1); + stream.push(2); + stream.push(3); + assert.strictEqual(stream.length, 2); + assert.strictEqual(stream.dropped, 1); + }); + + await t.test('fail() throws into the consumer', async () => { + const stream = createEventStream(); + stream.fail(new Error('upstream died')); + await assert.rejects(stream.next(), /upstream died/); + }); + + await t.test('pushing after the end is refused', () => { + const stream = createEventStream(); + stream.end(); + assert.strictEqual(stream.push(1), false); + }); +}); + +// --------------------------------------------------------------------------- +// The wire + +const countdown = defineRouter({ + feed: { + // An async generator handler IS the subscription declaration — no + // procedure.subscription() wrapper needed, only the usual access. + count: { + access: 'public', + handler: async function* (_context, { to = 3 } = {}) { + for (let i = 1; i <= to; i++) yield { n: i }; + }, + }, + // The bare-function shorthand keeps its default access, like any method. + bare: async function* () { + yield { secret: true }; + }, + tracked: procedure.subscription({ + access: 'public', + handler: async function* (_context, _args, { lastEventId }) { + const log = createEventLog({ size: 10, epoch: 'e' }); + for (const value of ['a', 'b', 'c']) log.push(value); + const missed = log.since(lastEventId); + for (const value of missed ?? []) yield value; + yield tracked(log.push('live'), 'live'); + }, + }), + failing: procedure.subscription({ + access: 'public', + handler: async function* () { + yield { ok: true }; + const error = new Error('generator blew up'); + error.code = 418; + throw error; + }, + }), + private: procedure.subscription({ + access: 'session', + handler: async function* () { + yield { secret: true }; + }, + }), + plain: procedure({ access: 'public', handler: async () => 'not a subscription' }), + }, +}); + +test('subscriptions: the wire lifecycle', async (t) => { + const { server, port } = await createServer(countdown); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false }); + t.after(() => void client.close()); + await client.load('feed'); + + await t.test('introspection marks a subscription, and the client scaffolds one', () => { + assert.strictEqual(client.api.feed.count.kind, 'subscription'); + assert.strictEqual(typeof client.api.feed.count.subscribe, 'function'); + assert.strictEqual(typeof client.api.feed.count.iterate, 'function'); + assert.strictEqual(client.api.feed.bare.kind, 'subscription', 'a bare generator is one too'); + assert.strictEqual(typeof client.api.feed.plain, 'function', 'a call is still a plain function'); + }); + + await t.test('subscribe delivers every value and then ends', async () => { + const seen = []; + const ended = new Promise((resolve) => { + client.api.feed.count.subscribe({ to: 3 }, { onData: (data) => seen.push(data), onEnd: resolve }); + }); + await within(ended, 'the subscription to end'); + assert.deepStrictEqual(seen, [{ n: 1 }, { n: 2 }, { n: 3 }]); + }); + + await t.test('iterate consumes the same stream with for await', async () => { + const seen = []; + const consume = (async () => { + for await (const value of client.api.feed.count.iterate({ to: 2 })) seen.push(value); + })(); + await within(consume, 'the iterator to finish'); + assert.deepStrictEqual(seen, [{ n: 1 }, { n: 2 }]); + }); + + await t.test('an onEnd that throws still ends the iterator', async () => { + // The stream has to be ended even when a terminal listener blew up: + // skipping it leaves a for-await parked in next() with nothing coming. + const escalated = []; + const onError = (error) => void escalated.push(error.message); + client.on('error', onError); + const seen = []; + const consume = (async () => { + const values = client.api.feed.count.iterate( + { to: 2 }, + { + onEnd: () => { + throw new Error('terminal listener blew up'); + }, + }, + ); + for await (const value of values) seen.push(value); + })(); + await within(consume, 'the iterator to finish despite a throwing onEnd'); + assert.deepStrictEqual(seen, [{ n: 1 }, { n: 2 }]); + assert.deepStrictEqual(escalated, ['terminal listener blew up']); + client.off('error', onError); + }); + + await t.test('the handle reports the last tracked eventId', async () => { + const values = []; + let handle = null; + const ended = new Promise((resolve) => { + handle = client.api.feed.tracked.subscribe({}, { onData: (d) => values.push(d), onEnd: resolve }); + }); + await within(ended, 'the tracked subscription to end'); + assert.deepStrictEqual(values, ['live']); + assert.strictEqual(handle.lastEventId, 'e.3'); + assert.strictEqual(handle.closed, true); + }); + + await t.test('lastEventId replays what was missed', async () => { + const values = []; + const ended = new Promise((resolve) => { + client.api.feed.tracked.subscribe({}, { lastEventId: 'e.0', onData: (d) => values.push(d), onEnd: resolve }); + }); + await within(ended, 'the resumed subscription to end'); + assert.deepStrictEqual(values, ['b', 'c', 'live'], 'everything after id 0'); + }); + + await t.test('a generator that throws ends with its own code', async () => { + const values = []; + const failure = await within( + new Promise((resolve) => { + client.api.feed.failing.subscribe({}, { onData: (d) => values.push(d), onError: resolve }); + }), + 'the failure', + ); + assert.deepStrictEqual(values, [{ ok: true }]); + assert.strictEqual(failure.code, 418); + assert.strictEqual(failure.message, 'generator blew up'); + }); + + await t.test('access control applies', async () => { + const failure = await within( + new Promise((resolve) => client.api.feed.private.subscribe({}, { onError: resolve })), + 'the 403', + ); + assert.strictEqual(failure.code, 403); + }); + + // The two kinds are not interchangeable, and mixing them up has to say so + // rather than hang. Raw packets here: the scaffolded api cannot express + // the mistake, which is rather the point of scaffolding it. + const url = `ws://127.0.0.1:${port}/api`; + + await t.test('subscribing to a call is refused', async () => { + const answer = await within(rawPacket(url, { type: 'subscribe', id: 's1', method: 'feed/plain' }), 'answer'); + assert.strictEqual(answer.error.code, 400); + assert.match(answer.error.message, /not a subscription/); + }); + + await t.test('calling a subscription is refused', async () => { + const answer = await within(rawPacket(url, { type: 'call', id: 's2', method: 'feed/count' }), 'answer'); + assert.strictEqual(answer.error.code, 400); + assert.match(answer.error.message, /subscription/); + }); +}); + +test('subscriptions: unsubscribe runs the generator finally', async (t) => { + let opened = 0; + let closed = 0; + const router = defineRouter({ + feed: { + forever: procedure.subscription({ + access: 'public', + handler: async function* (_context, _args, { signal }) { + opened++; + const stream = createEventStream({ signal }); + const timer = setInterval(() => stream.push({ tick: true }), 5); + try { + yield* stream; + } finally { + clearInterval(timer); + closed++; + } + }, + }), + }, + }); + const { server, port } = await createServer(router); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false }); + t.after(() => void client.close()); + await client.load('feed'); + + await t.test('an explicit unsubscribe closes it server-side', async () => { + let ticks = 0; + const handle = client.api.feed.forever.subscribe({}, { onData: () => void ticks++ }); + await waitFor(() => ticks >= 2, 'the subscription never produced'); + handle.unsubscribe(); + assert.strictEqual(handle.closed, true); + await waitFor(() => closed === 1, "the generator's finally never ran"); + assert.strictEqual(server.rpc.clients.size, 1); + const [serverClient] = server.rpc.clients; + await waitFor(() => serverClient.subscriptions.size === 0, 'the server kept the subscription'); + }); + + await t.test('breaking out of for await unsubscribes too', async () => { + const before = closed; + for await (const value of client.api.feed.forever.iterate()) { + assert.deepStrictEqual(value, { tick: true }); + break; + } + await waitFor(() => closed === before + 1, 'breaking the loop never reached the server'); + }); + + await t.test('a disconnect closes everything still running', async () => { + const before = closed; + const other = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false }); + await other.load('feed'); + let ticks = 0; + other.api.feed.forever.subscribe({}, { onData: () => void ticks++ }); + other.api.feed.forever.subscribe({}, { onData: () => void ticks++ }); + await waitFor(() => ticks >= 2, 'the subscriptions never produced'); + other.close(); + await waitFor(() => closed >= before + 2, 'a dropped connection left generators running'); + }); + + // close() used to end an iterate() consumer's stream and tell a + // subscribe() consumer nothing at all, so a listener-based caller never + // learned its feed was dead. + await t.test('close() ends every live subscription, both flavours', async () => { + const other = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false }); + await other.load('feed'); + + const ends = []; + const handles = [ + other.api.feed.forever.subscribe({}, { onEnd: () => void ends.push('first') }), + other.api.feed.forever.subscribe({}, { onEnd: () => void ends.push('second') }), + ]; + // ...and an iterator, which was already being told + const iterated = (async () => { + for await (const _value of other.api.feed.forever.iterate()) void _value; + return 'iterator'; + })(); + let ticks = 0; + other.api.feed.forever.subscribe({}, { onData: () => void ticks++ }); + await waitFor(() => ticks >= 1, 'the subscriptions never produced'); + + other.close(); + + assert.deepStrictEqual(ends, ['first', 'second'], 'every callback subscriber is told, in order'); + assert.strictEqual(await within(iterated, 'the iterator to finish'), 'iterator'); + for (const handle of handles) { + assert.strictEqual(handle.closed, true); + assert.strictEqual(handle.unsubscribe(), false, 'nothing is left to unsubscribe'); + } + }); + + await t.test('close() is not an unsubscribe: onError stays quiet', async () => { + const other = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false }); + await other.load('feed'); + const seen = []; + other.api.feed.forever.subscribe( + {}, + { + onEnd: () => void seen.push('end'), + onError: (error) => void seen.push(`error:${error.message}`), + }, + ); + // An explicit unsubscribe is silent — the caller already knows + const quietHandle = other.api.feed.forever.subscribe({}, { onEnd: () => void seen.push('unsubscribed') }); + quietHandle.unsubscribe(); + other.close(); + assert.deepStrictEqual(seen, ['end'], 'a clean close is an end, never an error'); + }); + + await t.test('a listener that throws does not strand the others or the teardown', async () => { + const other = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false }); + await other.load('feed'); + const escalated = []; + other.on('error', (error) => void escalated.push(error.message)); + const ends = []; + other.api.feed.forever.subscribe( + {}, + { + onEnd: () => { + throw new Error('listener blew up'); + }, + }, + ); + other.api.feed.forever.subscribe({}, { onEnd: () => void ends.push('after') }); + const iterated = (async () => { + for await (const _value of other.api.feed.forever.iterate()) void _value; + })(); + + assert.doesNotThrow(() => other.close()); + assert.deepStrictEqual(ends, ['after'], 'one bad listener must not rob the next of its signal'); + assert.deepStrictEqual(escalated, ['listener blew up']); + // ...and the stream still ends, or an iterate() consumer waits forever + await within(iterated, 'the iterator to finish despite a throwing listener'); + // The teardown ran past the throw: deregistering happens before the + // transport is closed, and the socket itself closes a tick later. + assert.strictEqual(WrpcClient.connections.has(other), false); + await waitFor(() => other.active === false, 'the transport stayed open'); + }); + + assert.ok(opened >= 4); +}); + +test('subscriptions: a reconnect resumes from the last eventId', async (t) => { + // The log lives outside the server, so a restart on the same port is a new + // process from the client's point of view but the same feed from the + // application's — exactly the case resume exists for. + const log = createEventLog({ size: 50, epoch: 'e' }); + const live = new Set(); + const router = defineRouter({ + feed: { + messages: procedure.subscription({ + access: 'public', + handler: async function* (_context, _args, { lastEventId, signal }) { + const missed = log.since(lastEventId); + assert.notStrictEqual(missed, null, 'the buffer was too small for this test'); + for (const value of missed) yield value; + const stream = createEventStream({ signal }); + live.add(stream); + try { + yield* stream; + } finally { + live.delete(stream); + } + }, + }), + }, + }); + const publish = (text) => { + const id = log.push(text); + for (const stream of live) stream.push(tracked(id, text)); + }; + + const first = await createServer(router); + const { port } = first; + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { + heartbeat: false, + reconnect: { minDelay: 10, maxDelay: 20, jitter: false }, + }); + t.after(() => void client.close()); + client.on('error', noop); + await client.load('feed'); + + const seen = []; + const handle = client.api.feed.messages.subscribe({}, { onData: (data) => seen.push(data) }); + await waitFor(() => live.size === 1, 'the subscription never opened'); + publish('one'); + await waitFor(() => seen.length === 1, 'the live value never arrived'); + assert.strictEqual(handle.lastEventId, 'e.0'); + + // The outage: values published while nobody is connected are what the + // resume has to fill in. + const reconnected = new Promise((resolve) => client.once('reconnect', resolve)); + await first.server.close(); + publish('two'); + publish('three'); + const second = await createServer(router, { port }); + t.after(() => second.server.close()); + + const info = await within(reconnected, 'the reconnect'); + assert.strictEqual(info.subscriptions, 1, 'the reconnect re-opened the subscription'); + await waitFor(() => seen.length === 3, `only ${seen.length} values after the resume`); + assert.deepStrictEqual(seen, ['one', 'two', 'three'], 'nothing missed, nothing duplicated'); + assert.strictEqual(handle.lastEventId, 'e.2'); + + publish('four'); + await waitFor(() => seen.length === 4, 'the resumed subscription is not live'); + assert.strictEqual(handle.closed, false); +}); + +test('subscriptions: limits and duplicate ids', async (t) => { + const router = defineRouter({ + feed: { + forever: procedure.subscription({ + access: 'public', + handler: async function* (_context, _args, { signal }) { + yield* createEventStream({ signal }); + }, + }), + }, + }); + const { server, port } = await createServer(router, { maxSubscriptions: 2 }); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false }); + t.after(() => void client.close()); + await client.load('feed'); + + await t.test('past maxSubscriptions the answer is 429', async () => { + client.api.feed.forever.subscribe(); + client.api.feed.forever.subscribe(); + const failure = await within( + new Promise((resolve) => client.api.feed.forever.subscribe({}, { onError: resolve })), + 'the 429', + ); + assert.strictEqual(failure.code, 429); + }); +}); diff --git a/tests/scaling.test-d.ts b/tests/scaling.test-d.ts new file mode 100644 index 0000000..e4e5697 --- /dev/null +++ b/tests/scaling.test-d.ts @@ -0,0 +1,51 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import * as scaling from '../scaling.js'; +import type { Backplane, BackplaneHandler, MemoryBackplane, RedisAdapterOptions, Unsubscribe } from '../scaling.js'; +import type { RpcServerOptions, ServerOptions, Router } from '../index.js'; + +expectType(scaling.MemoryBackplane); + +// The memory backplane satisfies the structural contract +const memory = new scaling.MemoryBackplane(); +expectAssignable(memory); +expectType(memory.size); +expectType(memory.name); +expectType( + memory.subscribe('room:chat', (message) => { + expectType(message); + }), +); +expectType(memory.publish('room:chat', '{}')); +expectType(memory.close()); +expectAssignable(scaling.createMemoryBackplane({ prefix: 'app' })); + +// Anything with the right shape plugs in — that is the whole point +expectAssignable({ + publish: (channel: string, message: string) => void [channel, message], + subscribe: (_channel: string, _handler: BackplaneHandler) => () => {}, + close: () => {}, +}); +expectError({ publish: () => {}, subscribe: () => () => {} }); // close missing + +// Redis clients are injected, never imported +declare const ioredis: { + publish(channel: string, message: string): Promise; + duplicate(): { + subscribe(channel: string): Promise; + unsubscribe(channel: string): Promise; + on(event: 'message', listener: (channel: string, message: string) => void): unknown; + }; +}; +expectAssignable({ pub: ioredis }); +expectAssignable({ pub: ioredis, sub: ioredis.duplicate(), prefix: 'app' }); +expectAssignable(scaling.createRedisAdapter({ pub: ioredis })); +expectError(scaling.createRedisAdapter({})); + +expectType(scaling.isBackplane(memory)); +expectType(scaling.DEFAULT_PREFIX); + +// A backplane is accepted wherever a server is configured +declare const router: Router; +expectAssignable({ router, backplane: memory, instanceId: 'node-1' }); +expectAssignable({ router, backplane: memory }); +expectAssignable({ router, backplane: null }); diff --git a/tests/scaling/backplane.test.js b/tests/scaling/backplane.test.js new file mode 100644 index 0000000..1360759 --- /dev/null +++ b/tests/scaling/backplane.test.js @@ -0,0 +1,269 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { RpcServer } = require('../../index.js'); +const { defineRouter, procedure } = require('../../index.js'); +const { MemoryBackplane } = require('../../scaling.js'); + +const noop = () => {}; +const quiet = { log: noop, info: noop, warn: noop, error: noop, debug: noop }; + +const settle = async () => { + for (let i = 0; i < 8; i++) await Promise.resolve(); +}; + +const router = defineRouter({ test: { ping: procedure({ access: 'public', handler: async () => 'pong' }) } }); + +// A socket-shaped stub: RpcServer.attachSocket only needs the events and a +// send(), which is exactly enough to watch what a client would receive. +class FakeSocket { + constructor() { + this.sent = []; + this.listeners = new Map(); + this.remoteAddress = '127.0.0.1'; + } + + on(event, listener) { + const list = this.listeners.get(event) ?? []; + list.push(listener); + this.listeners.set(event, list); + } + + once(event, listener) { + this.on(event, listener); + } + + emit(event, ...args) { + for (const listener of this.listeners.get(event) ?? []) listener(...args); + } + + send(data) { + this.sent.push(JSON.parse(data)); + return true; + } + + close() { + this.emit('close'); + } + + terminate() { + this.emit('close'); + } + + get events() { + return this.sent.filter((packet) => packet.type === 'event'); + } +} + +const attach = (rpc) => { + const socket = new FakeSocket(); + const client = rpc.attachSocket(socket, { headers: {} }); + return { socket, client }; +}; + +const createPair = (backplane) => { + const first = new RpcServer({ router, logger: false, backplane, instanceId: 'node-1' }); + const second = new RpcServer({ router, logger: false, backplane, instanceId: 'node-2' }); + return { first, second }; +}; + +test('backplane: a room event crosses instances', async (t) => { + const backplane = new MemoryBackplane(); + const { first, second } = createPair(backplane); + t.after(async () => { + await first.close(); + await second.close(); + backplane.close(); + }); + + const here = attach(first); + const there = attach(second); + here.client.join('chat'); + there.client.join('chat'); + await settle(); // the room's channel subscription lands asynchronously + + const sent = first.to('chat').emit('message', { text: 'hi' }); + assert.strictEqual(sent, 1, 'the local count covers this instance only'); + assert.deepStrictEqual(here.socket.events, [{ type: 'event', name: 'message', data: { text: 'hi' } }]); + + await settle(); + assert.deepStrictEqual( + there.socket.events, + [{ type: 'event', name: 'message', data: { text: 'hi' } }], + 'the remote instance delivered it to its own member', + ); +}); + +test('backplane: echo suppression keeps the publisher from re-delivering', async (t) => { + const backplane = new MemoryBackplane(); + const { first, second } = createPair(backplane); + t.after(async () => { + await first.close(); + await second.close(); + backplane.close(); + }); + + const here = attach(first); + here.client.join('chat'); + attach(second).client.join('chat'); + await settle(); + + first.to('chat').emit('message', 1); + await settle(); + assert.strictEqual(here.socket.events.length, 1, 'the publisher sees exactly one copy'); +}); + +test('backplane: a client in two targeted rooms gets one copy on every instance', async (t) => { + const backplane = new MemoryBackplane(); + const { first, second } = createPair(backplane); + t.after(async () => { + await first.close(); + await second.close(); + backplane.close(); + }); + + const there = attach(second); + there.client.join('chat'); + there.client.join('lobby'); + await settle(); + + first.to('chat', 'lobby').emit('message', 1); + await settle(); + assert.strictEqual(there.socket.events.length, 1, 'a multi-room emit is deduplicated remotely too'); +}); + +test('backplane: broadcast reaches every instance', async (t) => { + const backplane = new MemoryBackplane(); + const { first, second } = createPair(backplane); + t.after(async () => { + await first.close(); + await second.close(); + backplane.close(); + }); + + const there = attach(second); // no rooms at all + await settle(); + + first.broadcast('announce', { up: true }); + await settle(); + assert.deepStrictEqual(there.socket.events, [{ type: 'event', name: 'announce', data: { up: true } }]); +}); + +test('backplane: an instance without members of the room is not reached', async (t) => { + const backplane = new MemoryBackplane(); + const { first, second } = createPair(backplane); + t.after(async () => { + await first.close(); + await second.close(); + backplane.close(); + }); + + const there = attach(second); + there.client.join('other'); + await settle(); + + first.to('chat').emit('message', 1); + await settle(); + assert.deepStrictEqual(there.socket.events, []); +}); + +test('backplane: a disconnect releases the room and its channel', async (t) => { + const backplane = new MemoryBackplane(); + const { first, second } = createPair(backplane); + t.after(async () => { + await first.close(); + await second.close(); + backplane.close(); + }); + + const there = attach(second); + there.client.join('chat'); + await settle(); + assert.strictEqual(second.rooms.count('chat'), 1); + + there.socket.emit('close'); + await settle(); + assert.strictEqual(second.rooms.has('chat'), false, 'the empty room is gone'); + + // With nobody subscribed to the room channel the publish reaches nobody, + // and — the point of the assertion — nothing throws on the way. + assert.doesNotThrow(() => first.to('chat').emit('message', 1)); + await settle(); + assert.deepStrictEqual(there.socket.events, []); +}); + +test('backplane: a broken backplane never breaks local delivery', async (t) => { + const errors = []; + const broken = { + publish() { + throw new Error('backplane is down'); + }, + subscribe() { + return () => {}; + }, + close: noop, + }; + const rpc = new RpcServer({ + router, + backplane: broken, + logger: { ...quiet, error: (error) => errors.push(error) }, + }); + t.after(() => rpc.close()); + + const { socket, client } = attach(rpc); + client.join('chat'); + const sent = rpc.to('chat').emit('message', { text: 'hi' }); + + assert.strictEqual(sent, 1, 'local delivery is unaffected'); + assert.deepStrictEqual(socket.events, [{ type: 'event', name: 'message', data: { text: 'hi' } }]); + // At least the room publish failed and was reported; the cluster's own + // presence traffic (hello, join delta) fails through the same path. + assert.ok(errors.length >= 1, 'the failure is reported'); +}); + +test('backplane: malformed envelopes are ignored', async (t) => { + const backplane = new MemoryBackplane(); + const rpc = new RpcServer({ router, logger: false, backplane, instanceId: 'node-1' }); + t.after(async () => { + await rpc.close(); + backplane.close(); + }); + + const { socket, client } = attach(rpc); + client.join('chat'); + await settle(); + + for (const message of [ + 'not json', + 'null', + '[]', + JSON.stringify({ instance: 'node-2' }), // no name + JSON.stringify({ instance: 'node-2', name: '', rooms: ['chat'] }), + JSON.stringify({ instance: 'node-2', name: 'x', rooms: 'chat' }), // rooms not an array + ]) { + backplane.publish('room:chat', message); + backplane.publish('broadcast', message); + } + await settle(); + assert.deepStrictEqual(socket.events, []); +}); + +test('backplane: a non-conforming backplane is rejected at construction', () => { + assert.throws(() => new RpcServer({ router, logger: false, backplane: { publish: noop } }), TypeError); +}); + +test('backplane: rooms work identically without one', async (t) => { + const rpc = new RpcServer({ router, logger: false }); + t.after(() => rpc.close()); + const a = attach(rpc); + const b = attach(rpc); + a.client.join('chat'); + b.client.join('chat'); + + assert.strictEqual(rpc.to('chat').except(b.client).emit('message', 1), 1); + assert.strictEqual(a.socket.events.length, 1); + assert.strictEqual(b.socket.events.length, 0); + assert.strictEqual(rpc.instanceId.length > 0, true, 'an instance id exists even with no backplane'); +}); diff --git a/tests/scaling/memory.test.js b/tests/scaling/memory.test.js new file mode 100644 index 0000000..a4b2688 --- /dev/null +++ b/tests/scaling/memory.test.js @@ -0,0 +1,139 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { MemoryBackplane, createMemoryBackplane, isBackplane } = require('../../scaling.js'); + +const tick = () => new Promise((resolve) => queueMicrotask(resolve)); + +const noop = () => {}; +const quiet = { log: noop, info: noop, warn: noop, error: noop, debug: noop }; + +test('MemoryBackplane: publish and subscribe', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + + await t.test('a subscriber receives what is published to its channel', async () => { + const seen = []; + backplane.subscribe('room:chat', (message) => seen.push(message)); + backplane.publish('room:chat', '{"n":1}'); + assert.deepStrictEqual(seen, [], 'delivery is never synchronous, like a real broker'); + await tick(); + assert.deepStrictEqual(seen, ['{"n":1}']); + }); + + await t.test('other channels are not disturbed', async () => { + const seen = []; + backplane.subscribe('room:lobby', (message) => seen.push(message)); + backplane.publish('room:other', 'x'); + await tick(); + assert.deepStrictEqual(seen, []); + }); + + await t.test('every subscriber of a channel gets the message', async () => { + const first = []; + const second = []; + backplane.subscribe('fanout', (message) => first.push(message)); + backplane.subscribe('fanout', (message) => second.push(message)); + backplane.publish('fanout', 'both'); + await tick(); + assert.deepStrictEqual(first, ['both']); + assert.deepStrictEqual(second, ['both']); + }); + + await t.test('publishing to a channel nobody holds is a no-op', () => { + assert.doesNotThrow(() => backplane.publish('silent', 'x')); + }); + + await t.test('a non-function handler is rejected', () => { + assert.throws(() => backplane.subscribe('bad', null), TypeError); + }); +}); + +test('MemoryBackplane: unsubscribe', async (t) => { + const backplane = new MemoryBackplane(); + t.after(() => backplane.close()); + const seen = []; + const off = backplane.subscribe('room:chat', (message) => seen.push(message)); + + await t.test('the returned function removes only that handler', async () => { + const other = []; + backplane.subscribe('room:chat', (message) => other.push(message)); + off(); + backplane.publish('room:chat', 'after'); + await tick(); + assert.deepStrictEqual(seen, []); + assert.deepStrictEqual(other, ['after']); + }); + + await t.test('unsubscribing twice is safe', () => { + assert.doesNotThrow(off); + }); + + await t.test('a handler removed between publish and delivery is not called', async () => { + const late = []; + const remove = backplane.subscribe('late', (message) => late.push(message)); + backplane.publish('late', 'x'); + remove(); // still inside the same microtask turn as publish + await tick(); + assert.deepStrictEqual(late, []); + }); + + await t.test('the channel is dropped once its last handler leaves', () => { + const single = new MemoryBackplane(); + const stop = single.subscribe('solo', noop); + assert.strictEqual(single.size, 1); + stop(); + assert.strictEqual(single.size, 0); + }); +}); + +test('MemoryBackplane: isolation and lifecycle', async (t) => { + await t.test('the prefix namespaces two adapters over one instance', async () => { + const app = createMemoryBackplane({ prefix: 'app' }); + const other = createMemoryBackplane({ prefix: 'other' }); + t.after(() => { + app.close(); + other.close(); + }); + const seen = []; + app.subscribe('room:chat', (message) => seen.push(message)); + other.subscribe('room:chat', (message) => seen.push(message)); + app.publish('room:chat', 'x'); + await tick(); + assert.deepStrictEqual(seen, ['x'], 'prefixes keep the two channel spaces apart'); + }); + + await t.test('a throwing handler is isolated and reported', async () => { + const errors = []; + const backplane = new MemoryBackplane({ logger: { ...quiet, error: (e) => errors.push(e) } }); + t.after(() => backplane.close()); + const seen = []; + backplane.subscribe('chan', () => { + throw new Error('handler blew up'); + }); + backplane.subscribe('chan', (message) => seen.push(message)); + backplane.publish('chan', 'x'); + await tick(); + assert.deepStrictEqual(seen, ['x']); + assert.strictEqual(errors.length, 1); + }); + + await t.test('close() drops every subscription and stops delivery', async () => { + const backplane = new MemoryBackplane(); + const seen = []; + backplane.subscribe('chan', (message) => seen.push(message)); + backplane.close(); + assert.strictEqual(backplane.size, 0); + backplane.publish('chan', 'x'); + await tick(); + assert.deepStrictEqual(seen, []); + }); + + await t.test('it satisfies the structural contract', () => { + assert.strictEqual(isBackplane(new MemoryBackplane()), true); + assert.strictEqual(isBackplane({ publish: noop, subscribe: noop }), false); + assert.strictEqual(isBackplane(null), false); + }); +}); diff --git a/tests/scaling/redis.integration.test.js b/tests/scaling/redis.integration.test.js new file mode 100644 index 0000000..0b5b4e6 --- /dev/null +++ b/tests/scaling/redis.integration.test.js @@ -0,0 +1,163 @@ +'use strict'; + +// The Redis backplane against a REAL Redis, not the in-repo fake of +// redis.test.js. The fake encodes the contract the adapter depends on; this +// file checks that ioredis actually honours that contract. Manual/local only +// — not wired into CI, since it needs a live server. Set REDIS_URL to run it: +// +// REDIS_URL=redis://127.0.0.1:6379 node --test tests/scaling/redis.integration.test.js +// +// Without REDIS_URL every test skips, so `pnpm test` stays self-contained on +// a machine with no Redis. `ioredis` is a devDependency used only here: the +// adapter itself requires nothing and duck-types whatever it is handed. + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { RpcServer, defineRouter, procedure } = require('../../index.js'); +const { createRedisAdapter } = require('../../scaling.js'); + +const REDIS_URL = process.env.REDIS_URL; + +// Two separate adapters over two separate connection pairs — what two +// processes sharing one Redis actually look like, unlike MemoryBackplane +// where both instances hold the same object. +let Redis = null; +if (REDIS_URL) { + try { + Redis = require('ioredis'); + } catch { + Redis = null; + } +} + +const skip = !REDIS_URL ? 'REDIS_URL is not set' : !Redis ? 'ioredis is not installed' : false; + +const router = defineRouter({ test: { ping: procedure({ access: 'public', handler: async () => 'pong' }) } }); + +// A socket-shaped stub: attachSocket only needs the events and a send(). +class FakeSocket { + constructor() { + this.sent = []; + this.listeners = new Map(); + this.remoteAddress = '127.0.0.1'; + } + + on(event, listener) { + const list = this.listeners.get(event) ?? []; + list.push(listener); + this.listeners.set(event, list); + } + + once(event, listener) { + this.on(event, listener); + } + + emit(event, ...args) { + for (const listener of this.listeners.get(event) ?? []) listener(...args); + } + + send(data) { + this.sent.push(JSON.parse(data)); + return true; + } + + close() { + this.emit('close'); + } + + terminate() { + this.emit('close'); + } + + get events() { + return this.sent.filter((packet) => packet.type === 'event'); + } +} + +// A real round trip through Redis is not a microtask, so waiting is polling +// with a deadline rather than draining the job queue. +const waitFor = async (predicate, message, timeout = 5000) => { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail(`timed out waiting for ${message}`); +}; + +// Every run gets its own prefix so a shared Redis (a CI service container +// reused across jobs, a developer's local instance) cannot cross-talk. +const prefix = () => `wrpc-test:${process.pid}:${Number(process.hrtime.bigint() % 1000000n)}`; + +const createNode = (t, { instanceId, prefix: keyPrefix }) => { + const pub = new Redis(REDIS_URL, { lazyConnect: false, maxRetriesPerRequest: 1 }); + const backplane = createRedisAdapter({ pub, prefix: keyPrefix, logger: false }); + const rpc = new RpcServer({ router, logger: false, backplane, instanceId }); + t.after(async () => { + await rpc.close(); + backplane.close(); // quits the subscriber it duplicated, not `pub` + await pub.quit(); + }); + return rpc; +}; + +const attach = (rpc) => { + const socket = new FakeSocket(); + const client = rpc.attachSocket(socket, { headers: {} }); + return { socket, client }; +}; + +test('redis backplane: a room event crosses two independent adapters', { skip }, async (t) => { + const keyPrefix = prefix(); + const first = createNode(t, { instanceId: 'node-1', prefix: keyPrefix }); + const second = createNode(t, { instanceId: 'node-2', prefix: keyPrefix }); + + const here = attach(first); + const there = attach(second); + here.client.join('chat'); + there.client.join('chat'); + // SUBSCRIBE is a round trip: publishing before it lands drops the message. + await new Promise((resolve) => setTimeout(resolve, 250)); + + const sent = first.to('chat').emit('message', { text: 'hi' }); + assert.strictEqual(sent, 1, 'the local count covers this instance only'); + assert.deepStrictEqual(here.socket.events, [{ type: 'event', name: 'message', data: { text: 'hi' } }]); + + await waitFor(() => there.socket.events.length === 1, 'the remote instance to deliver the event'); + assert.deepStrictEqual(there.socket.events, [{ type: 'event', name: 'message', data: { text: 'hi' } }]); + + // Echo suppression: the publisher's own subscriber sees its message come + // back off the wire and must drop it instead of delivering a second copy. + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.strictEqual(here.socket.events.length, 1, 'the publisher sees exactly one copy'); +}); + +test('redis backplane: broadcast reaches an instance holding no rooms', { skip }, async (t) => { + const keyPrefix = prefix(); + const first = createNode(t, { instanceId: 'node-1', prefix: keyPrefix }); + const second = createNode(t, { instanceId: 'node-2', prefix: keyPrefix }); + + const there = attach(second); + await new Promise((resolve) => setTimeout(resolve, 250)); + + first.broadcast('announce', { up: true }); + await waitFor(() => there.socket.events.length === 1, 'the broadcast to cross'); + assert.deepStrictEqual(there.socket.events, [{ type: 'event', name: 'announce', data: { up: true } }]); +}); + +test('redis backplane: a prefix isolates two logically separate deployments', { skip }, async (t) => { + const first = createNode(t, { instanceId: 'node-1', prefix: prefix() }); + const second = createNode(t, { instanceId: 'node-2', prefix: prefix() }); + + const here = attach(first); + const there = attach(second); + here.client.join('chat'); + there.client.join('chat'); + await new Promise((resolve) => setTimeout(resolve, 250)); + + first.to('chat').emit('message', 1); + await waitFor(() => here.socket.events.length === 1, 'the local delivery'); + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.strictEqual(there.socket.events.length, 0, 'a different prefix is a different deployment'); +}); diff --git a/tests/scaling/redis.test.js b/tests/scaling/redis.test.js new file mode 100644 index 0000000..472e7ce --- /dev/null +++ b/tests/scaling/redis.test.js @@ -0,0 +1,323 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); +const { EventEmitter } = require('node:events'); + +const { createRedisAdapter, isBackplane } = require('../../scaling.js'); + +const noop = () => {}; +const quiet = { log: noop, info: noop, warn: noop, error: noop, debug: noop }; + +// An in-repo fake shaped like ioredis: publish/subscribe/unsubscribe return +// promises, every channel of a connection arrives through one 'message' +// event, and duplicate() hands out a second connection. Running the adapter +// against a real Redis is a separate, optional CI job — the contract this +// fake encodes is what the adapter actually depends on. +class FakeRedis extends EventEmitter { + constructor(bus = new EventEmitter()) { + super(); + bus.setMaxListeners(0); + bus.duplicates = bus.duplicates ?? []; + this.bus = bus; + this.channels = new Set(); + this.published = []; + this.failPublish = false; + this.#listen(); + } + + #listen() { + this.bus.on('publish', (channel, message) => { + if (!this.channels.has(channel)) return; + this.emit('message', channel, message); + }); + } + + duplicate() { + const sub = new FakeRedis(this.bus); + this.bus.duplicates.push(sub); + return sub; + } + + async publish(channel, message) { + if (this.failPublish) throw new Error('redis is down'); + this.published.push([channel, message]); + this.bus.emit('publish', channel, message); + return 1; + } + + async subscribe(channel) { + this.channels.add(channel); + return this.channels.size; + } + + async unsubscribe(channel) { + this.channels.delete(channel); + return this.channels.size; + } + + async quit() { + this.quitCalls = (this.quitCalls ?? 0) + 1; + return 'OK'; + } +} + +test('createRedisAdapter: injection is validated structurally', async (t) => { + await t.test('a publisher is required', () => { + assert.throws(() => createRedisAdapter({}), TypeError); + assert.throws(() => createRedisAdapter({ pub: {} }), TypeError); + }); + + await t.test('without a subscriber the publisher must support duplicate()', () => { + const pub = { publish: noop }; + assert.throws(() => createRedisAdapter({ pub }), /duplicate/); + }); + + await t.test('an explicit subscriber needs subscribe() and on()', () => { + const pub = { publish: noop }; + assert.throws(() => createRedisAdapter({ pub, sub: { subscribe: noop } }), TypeError); + }); + + await t.test('a well-shaped pair yields a backplane', () => { + const adapter = createRedisAdapter({ pub: new FakeRedis() }); + assert.strictEqual(isBackplane(adapter), true); + assert.strictEqual(adapter.name, 'redis'); + adapter.close(); + }); +}); + +test('createRedisAdapter: publish and subscribe over the fake', async (t) => { + const pub = new FakeRedis(); + const adapter = createRedisAdapter({ pub }); + t.after(() => adapter.close()); + + await t.test('channels are namespaced with the prefix', async () => { + const seen = []; + adapter.subscribe('room:chat', (message) => seen.push(message)); + adapter.publish('room:chat', '{"n":1}'); + await null; + assert.deepStrictEqual(pub.published, [['wrpc:room:chat', '{"n":1}']]); + assert.deepStrictEqual(seen, ['{"n":1}']); + }); + + await t.test('a second handler on one channel subscribes upstream only once', async () => { + const subscriber = adapter; + const first = []; + const second = []; + subscriber.subscribe('fanout', (m) => first.push(m)); + subscriber.subscribe('fanout', (m) => second.push(m)); + adapter.publish('fanout', 'x'); + await null; + assert.deepStrictEqual(first, ['x']); + assert.deepStrictEqual(second, ['x']); + }); + + await t.test('messages on unheld channels are ignored', async () => { + const seen = []; + adapter.subscribe('held', (m) => seen.push(m)); + pub.bus.emit('publish', 'wrpc:not-held', 'x'); + await null; + assert.deepStrictEqual(seen, []); + }); +}); + +test('createRedisAdapter: a custom prefix and an explicit subscriber', async () => { + const pub = new FakeRedis(); + const sub = pub.duplicate(); + const adapter = createRedisAdapter({ pub, sub, prefix: 'app' }); + const seen = []; + adapter.subscribe('room:chat', (message) => seen.push(message)); + assert.ok(sub.channels.has('app:room:chat'), 'the explicit subscriber is the one subscribed'); + adapter.publish('room:chat', 'x'); + await null; + assert.deepStrictEqual(seen, ['x']); + adapter.close(); +}); + +test('createRedisAdapter: unsubscribe releases the upstream channel last', async () => { + const pub = new FakeRedis(); + const sub = pub.duplicate(); + const adapter = createRedisAdapter({ pub, sub }); + const seen = []; + const off = adapter.subscribe('room:chat', (m) => seen.push(m)); + const other = adapter.subscribe('room:chat', noop); + + off(); + assert.ok(sub.channels.has('wrpc:room:chat'), 'a channel with handlers left stays subscribed'); + adapter.publish('room:chat', 'still-held'); + await null; + assert.deepStrictEqual(seen, [], 'the removed handler stops hearing'); + + other(); + assert.strictEqual(sub.channels.size, 0, 'the last handler releases the channel'); + assert.doesNotThrow(other, 'unsubscribing twice is safe'); +}); + +test('createRedisAdapter: failures are isolated, never thrown at the caller', async (t) => { + await t.test('a publish rejection is reported, not propagated', async () => { + const errors = []; + const pub = new FakeRedis(); + pub.failPublish = true; + const adapter = createRedisAdapter({ pub, logger: { ...quiet, error: (e) => errors.push(e) } }); + assert.doesNotThrow(() => adapter.publish('room:chat', 'x')); + await null; + await null; + assert.strictEqual(errors.length, 1); + adapter.close(); + }); + + await t.test('a throwing handler does not stop the others', async () => { + const errors = []; + const pub = new FakeRedis(); + const adapter = createRedisAdapter({ pub, logger: { ...quiet, error: (e) => errors.push(e) } }); + const seen = []; + adapter.subscribe('chan', () => { + throw new Error('handler blew up'); + }); + adapter.subscribe('chan', (m) => seen.push(m)); + adapter.publish('chan', 'x'); + await null; + assert.deepStrictEqual(seen, ['x']); + assert.strictEqual(errors.length, 1); + adapter.close(); + }); +}); + +test('createRedisAdapter: tolerates the thinner ends of the client contract', async (t) => { + await t.test('a subscriber with removeListener but no off still detaches', () => { + const pub = new FakeRedis(); + const sub = pub.duplicate(); + sub.off = undefined; // node's EventEmitter has both; a wrapper may not + const adapter = createRedisAdapter({ pub, sub }); + adapter.subscribe('room:chat', noop); + adapter.close(); + assert.strictEqual(sub.listenerCount('message'), 0); + }); + + await t.test('a subscriber without unsubscribe() drops its handler anyway', async () => { + const pub = new FakeRedis(); + const sub = pub.duplicate(); + sub.unsubscribe = undefined; + const adapter = createRedisAdapter({ pub, sub }); + const seen = []; + const off = adapter.subscribe('room:chat', (m) => seen.push(m)); + off(); + adapter.publish('room:chat', 'x'); + await null; + assert.deepStrictEqual(seen, [], 'the handler is gone even if the channel stays open upstream'); + assert.doesNotThrow(() => adapter.close()); + }); + + await t.test('a synchronously throwing client is reported, not propagated', async () => { + const errors = []; + const logger = { ...quiet, error: (error) => errors.push(error) }; + const sub = { + subscribe() { + throw new Error('subscribe blew up'); + }, + unsubscribe() { + throw new Error('unsubscribe blew up'); + }, + on: noop, + }; + const pub = { + publish() { + throw new Error('publish blew up'); + }, + }; + const adapter = createRedisAdapter({ pub, sub, logger }); + const off = adapter.subscribe('room:chat', noop); + assert.strictEqual(errors.length, 1, 'the failed subscribe is reported'); + adapter.publish('room:chat', 'x'); + assert.strictEqual(errors.length, 2, 'the failed publish is reported'); + off(); + assert.strictEqual(errors.length, 3, 'the failed unsubscribe is reported'); + assert.doesNotThrow(() => adapter.close()); + }); + + await t.test('a non-function handler is rejected', () => { + const adapter = createRedisAdapter({ pub: new FakeRedis() }); + assert.throws(() => adapter.subscribe('chan', null), TypeError); + adapter.close(); + }); + + await t.test('subscribing after close yields an inert unsubscribe', () => { + const adapter = createRedisAdapter({ pub: new FakeRedis() }); + adapter.close(); + const off = adapter.subscribe('chan', noop); + assert.strictEqual(typeof off, 'function'); + assert.doesNotThrow(off); + }); +}); + +test('createRedisAdapter: close releases the subscriber it opened itself', async (t) => { + await t.test('a duplicated subscriber is quit — nobody else can', async () => { + const pub = new FakeRedis(); + const adapter = createRedisAdapter({ pub }); + adapter.subscribe('room:chat', noop); + adapter.close(); + // The duplicate lives only inside the adapter's closure, so leaving it + // connected would strand a Redis connection with no handle to it. + const sub = pub.bus.duplicates.at(-1); + assert.strictEqual(sub.quitCalls, 1); + assert.strictEqual(sub.channels.size, 0); + }); + + await t.test('a client with only disconnect() is disconnected', () => { + const pub = new FakeRedis(); + const created = []; + pub.duplicate = () => { + const sub = new FakeRedis(pub.bus); + sub.quit = undefined; + sub.disconnect = () => void created.push('disconnected'); + return sub; + }; + createRedisAdapter({ pub }).close(); + assert.deepStrictEqual(created, ['disconnected']); + }); + + await t.test('a client with neither is simply released', () => { + const pub = new FakeRedis(); + pub.duplicate = () => { + const sub = new FakeRedis(pub.bus); + sub.quit = undefined; + return sub; + }; + assert.doesNotThrow(() => createRedisAdapter({ pub }).close()); + }); + + await t.test('a throwing quit is reported, not propagated', () => { + const errors = []; + const pub = new FakeRedis(); + pub.duplicate = () => { + const sub = new FakeRedis(pub.bus); + sub.quit = () => { + throw new Error('quit blew up'); + }; + return sub; + }; + const adapter = createRedisAdapter({ pub, logger: { ...quiet, error: (e) => errors.push(e) } }); + assert.doesNotThrow(() => adapter.close()); + assert.strictEqual(errors.length, 1); + }); +}); + +test('createRedisAdapter: close releases subscriptions but not the injected clients', async () => { + const pub = new FakeRedis(); + const sub = pub.duplicate(); + const adapter = createRedisAdapter({ pub, sub }); + const seen = []; + adapter.subscribe('room:chat', (m) => seen.push(m)); + assert.strictEqual(sub.channels.size, 1); + + adapter.close(); + assert.strictEqual(sub.channels.size, 0, 'subscriptions are released'); + assert.strictEqual(sub.listenerCount('message'), 0, 'the message listener is detached'); + + adapter.publish('room:chat', 'x'); + await null; + assert.deepStrictEqual(seen, [], 'a closed adapter publishes nothing'); + assert.deepStrictEqual(pub.published, [], 'the injected clients are still usable'); + assert.strictEqual(typeof adapter.subscribe('room:chat', noop), 'function'); + adapter.close(); // idempotent +}); diff --git a/tests/scaling/subscriptions.test.js b/tests/scaling/subscriptions.test.js new file mode 100644 index 0000000..0196bb3 --- /dev/null +++ b/tests/scaling/subscriptions.test.js @@ -0,0 +1,143 @@ +'use strict'; + +const timers = require('node:timers/promises'); +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { + Server, + WrpcClient, + defineRouter, + procedure, + createEventStream, + createEventLog, + tracked, +} = require('../../index.js'); +const { MemoryBackplane } = require('../../scaling.js'); + +const waitFor = async (predicate, message = 'condition never held') => { + for (let i = 0; i < 300; i++) { + if (predicate()) return; + await timers.setTimeout(5); + } + assert.fail(message); +}; + +// Two instances, one backplane, one logical feed. wrpc supplies the pieces — +// the log for resume, the stream for push->pull — and the application wires +// them to whatever carries messages between processes, exactly as it does +// with rooms. +const createInstance = async (backplane, instanceId) => { + const log = createEventLog({ size: 50, epoch: 'e' }); + const live = new Set(); + + const publish = (text) => { + const message = JSON.stringify({ from: instanceId, text }); + backplane.publish('feed', message); + deliver(message); + }; + + const deliver = (message) => { + const { text } = JSON.parse(message); + const id = log.push(text); + for (const stream of live) stream.push(tracked(id, text)); + }; + + // Everything published by ANOTHER instance arrives here. + backplane.subscribe('feed', (message) => { + if (JSON.parse(message).from === instanceId) return; // echo suppression + deliver(message); + }); + + const router = defineRouter({ + feed: { + messages: procedure.subscription({ + access: 'public', + handler: async function* (_context, _args, { lastEventId, signal }) { + for (const missed of log.since(lastEventId) ?? []) yield missed; + const stream = createEventStream({ signal }); + live.add(stream); + try { + yield* stream; + } finally { + live.delete(stream); + } + }, + }), + say: procedure({ + access: 'public', + handler: async (_context, { text }) => { + publish(text); + return { ok: true }; + }, + }), + }, + }); + + const server = new Server({ + router, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 50 }, + backplane, + instanceId, + }); + await server.listen(); + return { server, port: server.address().port, live, log }; +}; + +test('subscriptions: a feed spans instances through the backplane', async (t) => { + const backplane = new MemoryBackplane(); + const first = await createInstance(backplane, 'node-1'); + const second = await createInstance(backplane, 'node-2'); + t.after(async () => { + await first.server.close(); + await second.server.close(); + backplane.close(); + }); + + const connect = async (port) => { + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/api`, { heartbeat: false }); + t.after(() => void client.close()); + await client.load('feed'); + return client; + }; + const here = await connect(first.port); + const there = await connect(second.port); + + const seenHere = []; + const seenThere = []; + here.api.feed.messages.subscribe({}, { onData: (data) => seenHere.push(data) }); + const remote = there.api.feed.messages.subscribe({}, { onData: (data) => seenThere.push(data) }); + await waitFor(() => first.live.size === 1 && second.live.size === 1, 'the subscriptions never opened'); + + await t.test('a value published on one instance reaches the other', async () => { + await here.api.feed.say({ text: 'hello' }); + await waitFor(() => seenThere.length === 1, 'the value never crossed instances'); + assert.deepStrictEqual(seenThere, ['hello']); + }); + + await t.test('and it reaches the publishing instance exactly once', async () => { + await waitFor(() => seenHere.length === 1, 'the local subscriber missed it'); + await timers.setTimeout(40); + assert.deepStrictEqual(seenHere, ['hello'], 'echo suppression kept it from arriving twice'); + }); + + await t.test('the remote subscriber can resume from its own eventId', async () => { + assert.strictEqual(remote.lastEventId, 'e.0'); + remote.unsubscribe(); + await waitFor(() => second.live.size === 0, 'the unsubscribe never landed'); + + // Two more while that subscriber is away. + await here.api.feed.say({ text: 'second' }); + await here.api.feed.say({ text: 'third' }); + await waitFor(() => second.log.length === 3, 'the instance did not keep receiving'); + + const resumed = []; + there.api.feed.messages.subscribe({}, { lastEventId: 'e.0', onData: (data) => resumed.push(data) }); + await waitFor(() => resumed.length === 2, `only ${resumed.length} replayed`); + assert.deepStrictEqual(resumed, ['second', 'third'], 'exactly what it missed'); + }); +}); diff --git a/tests/server.internals.test.js b/tests/server.internals.test.js index f94e2ea..cfdd95a 100644 --- a/tests/server.internals.test.js +++ b/tests/server.internals.test.js @@ -5,139 +5,190 @@ const { randomUUID } = require('node:crypto'); const { test } = require('node:test'); const assert = require('node:assert'); -const { Server, Client } = require('../src/server.js'); -const { WrpcClient } = require('../src/client.js'); +const { Server } = require('../src/server.js'); +const { Client } = require('../src/rpc/core.js'); +const { SessionManager } = require('../src/rpc/sessions.js'); +const { defineRouter, procedure } = require('../src/rpc/router.js'); +const { handleRpc, handleMessage, handleBinary } = require('../src/rpc/dispatcher.js'); const { chunkEncode } = require('../src/chunks.js'); -const { emitWarning } = process; -process.emitWarning = (warning, type, ...args) => { - if (type === 'ExperimentalWarning') return; - emitWarning(warning, type, ...args); -}; - const noop = () => {}; - -const fakeContext = () => ({ - console: { log: noop, info: noop, warn: noop, error: noop, debug: noop }, - auth: { saveSession: async () => {} }, -}); +const quiet = { log: noop, info: noop, warn: noop, error: noop, debug: noop }; const fakeHttpTransport = () => ({ source: '127.0.0.1', connection: undefined, - error: noop, - send: noop, + sent: [], + errors: [], + cookies: [], + send(obj) { + this.sent.push(obj); + }, + error(code, { id = '', error = null } = {}) { + this.errors.push({ code, id, error }); + }, close: noop, - sendSessionCookie: () => { - throw new Error('sendSessionCookie should not be reachable from this transport in this test'); + sendSessionCookie(cookie) { + this.cookies.push(cookie); }, - removeSessionCookie: noop, }); const fakeWsTransport = () => ({ source: '127.0.0.1', connection: {}, - error: noop, - send: noop, + sent: [], + errors: [], + send(obj) { + this.sent.push(obj); + }, + error(code, { id = '', error = null } = {}) { + this.errors.push({ code, id, error }); + }, close: noop, - sendSessionCookie: noop, - removeSessionCookie: noop, + once: noop, + sendSessionCookie: () => { + throw new Error('sendSessionCookie should not be reachable from a ws transport'); + }, }); +const createClient = (transport, sessions = new SessionManager({}, quiet), log = quiet) => + new Client(transport, { sessions, log }); + +const recordingStore = () => { + const map = new Map(); + const calls = []; + return { + calls, + async get(token) { + return map.get(token) ?? null; + }, + async set(token, data) { + calls.push({ token, data: { ...data } }); + map.set(token, data); + }, + async delete(token) { + map.delete(token); + }, + }; +}; + test('Client over HTTP transport', async (t) => { await t.test('emit (non-close) throws: HTTP has no persistent connection', () => { - const client = new Client(fakeHttpTransport(), fakeContext()); - assert.throws(() => client.emit('ping', {}), /Can't send wrpc event to http transport/); + const client = createClient(fakeHttpTransport()); + assert.throws(() => client.sendEvent('ping', {}), /Can't send wrpc event to http transport/); }); await t.test('emit("close") does not throw', () => { - const client = new Client(fakeHttpTransport(), fakeContext()); + const client = createClient(fakeHttpTransport()); assert.doesNotThrow(() => client.emit('close')); }); + await t.test('emit is the LOCAL Emitter emit; sendEvent owns the wire', async () => { + const closing = createClient(fakeHttpTransport()).emit('close'); + assert.ok(closing instanceof Promise); + await closing; + // `client.on(...)`/`client.emit(...)` behave like any Emitter — nothing + // reaches the transport. That substitutability is the point of the + // rename; the network send is spelled `sendEvent`, as it always was. + const transport = fakeWsTransport(); + const client = createClient(transport); + const seen = []; + client.on('room/event', (data) => seen.push(data)); + await client.emit('room/event', { x: 1 }); + assert.deepStrictEqual(seen, [{ x: 1 }]); + assert.deepStrictEqual(transport.sent, [], 'a local emit must not touch the wire'); + client.sendEvent('room/event', { x: 2 }); + assert.deepStrictEqual(transport.sent, [{ type: 'event', name: 'room/event', data: { x: 2 } }]); + }); + await t.test('getStream throws over HTTP', () => { - const client = new Client(fakeHttpTransport(), fakeContext()); + const client = createClient(fakeHttpTransport()); assert.throws(() => client.getStream('id'), /Can't receive stream from http transport/); }); await t.test('createStream throws over HTTP', () => { - const client = new Client(fakeHttpTransport(), fakeContext()); + const client = createClient(fakeHttpTransport()); assert.throws(() => client.createStream('name', 10), /Can't send wrpc streams to http transport/); }); - await t.test('startSession sends a session cookie for HTTP clients', () => { - let cookieSent = null; + await t.test('startSession sends a session cookie via sendSessionCookie', async () => { const transport = fakeHttpTransport(); - transport.sendSessionCookie = (token) => (cookieSent = token); - const client = new Client(transport, fakeContext()); + const client = createClient(transport); const token = randomUUID(); - client.startSession(token, {}); - assert.strictEqual(cookieSent, token); - client.finalizeSession(); + assert.strictEqual(client.startSession(token, {}), true); + assert.strictEqual(transport.cookies.length, 1); + assert.ok(transport.cookies[0].startsWith(`token=${token}; `)); + assert.match(transport.cookies[0], /HttpOnly/); + await client.finalizeSession(); }); }); test('Client over WS transport', async (t) => { await t.test('createStream validates name and size', () => { - const client = new Client(fakeWsTransport(), fakeContext()); + const client = createClient(fakeWsTransport()); assert.throws(() => client.createStream('', 10), /Stream name is not provided/); assert.throws(() => client.createStream('name', 0), /Stream size is not provided/); }); - await t.test('createStream registers a WrpcWritable', () => { - const client = new Client(fakeWsTransport(), fakeContext()); + await t.test('createStream registers a WrpcWritable and sends the init packet', () => { + const transport = fakeWsTransport(); + const client = createClient(transport); const stream = client.createStream('upload', 10); assert.strictEqual(client.getStream(stream.id), stream); + assert.deepStrictEqual(transport.sent, [{ type: 'stream', id: stream.id, name: 'upload', size: 10 }]); }); await t.test('getStream throws for an unknown id', () => { - const client = new Client(fakeWsTransport(), fakeContext()); + const client = createClient(fakeWsTransport()); assert.throws(() => client.getStream('missing'), /Stream missing is not initialized/); }); - await t.test('startSession does not send a cookie (persistent connection)', () => { - const client = new Client(fakeWsTransport(), fakeContext()); + await t.test('startSession does not send a cookie (persistent connection)', async () => { + const client = createClient(fakeWsTransport()); const token = randomUUID(); assert.doesNotThrow(() => client.startSession(token, {})); - client.finalizeSession(); + await client.finalizeSession(); }); - await t.test('session lifecycle: start, restore, finalize', () => { - const owner = new Client(fakeWsTransport(), fakeContext()); + await t.test('session lifecycle: initialize, restore, finalize', async () => { + const sessions = new SessionManager({}, quiet); + const owner = createClient(fakeWsTransport(), sessions); const token = randomUUID(); assert.strictEqual(owner.initializeSession(token, { role: 'admin' }), true); assert.strictEqual(owner.session.token, token); - const restorer = new Client(fakeWsTransport(), fakeContext()); - assert.strictEqual(restorer.restoreSession(token), true); + const restorer = createClient(fakeWsTransport(), sessions); + assert.strictEqual(await restorer.restoreSession(token), true); assert.strictEqual(restorer.session.token, token); - assert.strictEqual(restorer.restoreSession('unknown-token'), false); + assert.strictEqual(restorer.session.state.role, 'admin'); + assert.strictEqual(await restorer.restoreSession('unknown-token'), false); - assert.strictEqual(owner.finalizeSession(), true); + assert.strictEqual(await owner.finalizeSession(), true); assert.strictEqual(owner.session, null); - assert.strictEqual(owner.finalizeSession(), false); + assert.strictEqual(await owner.finalizeSession(), false); + // finalizeSession deletes the session from the store + assert.strictEqual(await restorer.restoreSession(token), false); }); - await t.test('session state changes are persisted through auth.saveSession', async () => { - const saved = []; - const context = fakeContext(); - context.auth.saveSession = async (token, data) => void saved.push({ token, data }); - const client = new Client(fakeWsTransport(), context); + await t.test('session state changes are persisted through the store', async () => { + const store = recordingStore(); + const sessions = new SessionManager({ store }, quiet); + const client = createClient(fakeWsTransport(), sessions); const token = randomUUID(); client.initializeSession(token, {}); client.session.state.role = 'admin'; await timers.setImmediate(); - assert.strictEqual(saved.length, 1); - assert.strictEqual(saved[0].token, token); - assert.strictEqual(saved[0].data.role, 'admin'); - client.finalizeSession(); + assert.strictEqual(store.calls.length, 2); + assert.deepStrictEqual(store.calls[0], { token, data: {} }); + assert.deepStrictEqual(store.calls[1], { token, data: { role: 'admin' } }); + assert.deepStrictEqual(await store.get(token), { role: 'admin' }); }); - await t.test('destroy terminates open streams and clears the session', async () => { + await t.test('destroy terminates open streams but keeps the stored session', async () => { const errors = []; - const context = fakeContext(); - context.console.error = (error) => errors.push(error); - const client = new Client(fakeWsTransport(), context); + const log = { ...quiet, error: (error) => errors.push(error) }; + const sessions = new SessionManager({}, quiet); + const client = createClient(fakeWsTransport(), sessions, log); const token = randomUUID(); client.initializeSession(token, {}); @@ -154,149 +205,242 @@ test('Client over WS transport', async (t) => { assert.strictEqual(client.streams.size, 0); assert.strictEqual(errors.length, 1); assert.match(errors[0].message, /boom/); - assert.strictEqual(client.restoreSession(token), false); + // NEW semantics: a dropped connection does not delete the session from + // the store — a reconnecting client can restore it. + const survivor = createClient(fakeWsTransport(), sessions); + assert.strictEqual(await survivor.restoreSession(token), true); + assert.strictEqual(survivor.session.token, token); }); }); -const createApplication = (api) => { - const introspect = () => api; - return { - console: { log: noop, info: noop, warn: noop, error: noop, debug: noop }, - static: { constructor: { name: 'Static' } }, - auth: { saveSession: async () => {} }, - getMethod: (unit, _ver, method) => { - if (unit === 'system' && method === 'introspect') { - return { access: 'public', enter: async () => {}, leave: noop, invoke: async () => introspect() }; - } - const def = api[unit]?.[method]; - if (!def) return null; - return { - access: def.access ?? 'public', - enter: async () => {}, - leave: noop, - invoke: (context, args) => def.handler(args, context), - }; - }, - }; -}; +const router = defineRouter({ + math: { + add: procedure({ access: 'public', handler: async (_context, { a, b }) => Number(a) + Number(b) }), + }, + 'calc.v1': { + double: procedure({ access: 'public', handler: async (_context, { x }) => x * 2 }), + }, + secure: { + whoami: procedure({ handler: async (context) => context.session.token }), + }, + fail: { + boom: procedure({ + access: 'public', + handler: async () => { + const error = new Error('domain failure'); + error.code = 422; + return error; + }, + }), + crash: procedure({ + access: 'public', + handler: async () => { + throw new Error('kaput'); + }, + }), + teapot: procedure({ + access: 'public', + handler: async () => { + const error = new Error('teapot'); + error.code = 418; + throw error; + }, + }), + slow: procedure({ + access: 'public', + handler: async () => { + const error = new Error('late'); + error.code = 'ETIMEOUT'; + throw error; + }, + }), + }, +}); -const createServer = async (api, options = {}) => { - const server = new Server(createApplication(api), { - host: '127.0.0.1', - port: 0, - protocol: 'http', - timeouts: { bind: 50 }, - ...options, +test('RPC dispatcher', async (t) => { + await t.test('handleRpc dispatches a call and sends a callback', async () => { + const transport = fakeWsTransport(); + const client = createClient(transport); + await handleRpc(client, { type: 'call', id: '1', method: 'math/add', args: { a: 2, b: 3 } }, router); + assert.deepStrictEqual(transport.sent, [{ type: 'callback', id: '1', result: 5 }]); }); - await server.listen(); - const { port } = server.httpServer.address(); - return { server, port }; -}; -test('Server internals', async (t) => { - await t.test('#request: GET-style RPC via /api/unit/method?params', async (t) => { - const api = { math: { add: { handler: async ({ a, b }) => Number(a) + Number(b) } } }; - const { server, port } = await createServer(api); - t.after(() => server.close()); + await t.test('handleRpc resolves versioned units from the method name', async () => { + const transport = fakeWsTransport(); + const client = createClient(transport); + await handleRpc(client, { type: 'call', id: '2', method: 'calc.v1/double', args: { x: 21 } }, router); + assert.deepStrictEqual(transport.sent, [{ type: 'callback', id: '2', result: 42 }]); + await handleRpc(client, { type: 'call', id: '3', method: 'calc/double', args: { x: 21 } }, router); + assert.deepStrictEqual(transport.errors, [{ code: 404, id: '3', error: null }]); + }); - const res = await fetch(`http://127.0.0.1:${port}/api/math/add?a=2&b=3`); - const body = await res.json(); - assert.strictEqual(body.result, 5); + await t.test('handleRpc: unknown method yields 404', async () => { + const transport = fakeWsTransport(); + const client = createClient(transport); + await handleRpc(client, { type: 'call', id: '4', method: 'nope/nothing', args: {} }, router); + assert.deepStrictEqual(transport.errors, [{ code: 404, id: '4', error: null }]); }); - await t.test('handleRpc: Error-shaped results are surfaced as protocol errors', async (t) => { - const api = { - fail: { - boom: { - handler: async () => { - const error = new Error('domain failure'); - error.code = 422; - return error; - }, - }, - }, - }; - const { server, port } = await createServer(api); - t.after(() => server.close()); + await t.test('handleRpc: a multi-dot unit name misses instead of truncating the version', async () => { + // 'calc.v1.2/double' must look up version 'v1.2' (a miss), never fall + // back to the registered 'calc.v1' + const transport = fakeWsTransport(); + const client = createClient(transport); + await handleRpc(client, { type: 'call', id: '5', method: 'calc.v1.2/double', args: { x: 21 } }, router); + assert.deepStrictEqual(transport.errors, [{ code: 404, id: '5', error: null }]); + assert.deepStrictEqual(transport.sent, []); + }); - const client = await WrpcClient.connect(`http://127.0.0.1:${port}/api`); - t.after(() => client.close()); - await client.load('fail'); - await assert.rejects(client.api.fail.boom(), (error) => error.code === 422); + await t.test('handleRpc: session-access method without a session yields 403', async () => { + const transport = fakeWsTransport(); + const client = createClient(transport); + await handleRpc(client, { type: 'call', id: '5', method: 'secure/whoami', args: {} }, router); + assert.deepStrictEqual(transport.errors, [{ code: 403, id: '5', error: null }]); }); - await t.test('#message: unrecognized packet shape yields a structure error', async (t) => { - const { server, port } = await createServer({}); - t.after(() => server.close()); + await t.test('handleRpc: session-access method works once a session exists', async () => { + const transport = fakeWsTransport(); + const client = createClient(transport); + const token = randomUUID(); + client.initializeSession(token, {}); + await handleRpc(client, { type: 'call', id: '6', method: 'secure/whoami', args: {} }, router); + assert.deepStrictEqual(transport.sent, [{ type: 'callback', id: '6', result: token }]); + }); - const res = await fetch(`http://127.0.0.1:${port}/api`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ nonsense: true }), - }); - const body = await res.json(); - assert.match(body.error.message, /Packet structure error/); + await t.test('handleRpc awaits sessionReady before the access check', async () => { + const sessions = new SessionManager({}, quiet); + const owner = createClient(fakeWsTransport(), sessions); + const token = randomUUID(); + owner.initializeSession(token, {}); + + const transport = fakeWsTransport(); + const client = createClient(transport, sessions); + client.sessionReady = client.restoreSession(token); + await handleRpc(client, { type: 'call', id: '7', method: 'secure/whoami', args: {} }, router); + assert.deepStrictEqual(transport.sent, [{ type: 'callback', id: '7', result: token }]); }); - await t.test('handleStream: rejects an already-initialized stream id', async (t) => { - const { server, port } = await createServer({}); - t.after(() => server.close()); + await t.test('handleRpc: Error-shaped results are surfaced as protocol errors', async () => { + const transport = fakeWsTransport(); + const client = createClient(transport); + await handleRpc(client, { type: 'call', id: '8', method: 'fail/boom', args: {} }, router); + assert.strictEqual(transport.sent.length, 0); + const [{ code, id, error }] = transport.errors; + assert.strictEqual(code, 422); + assert.strictEqual(id, '8'); + assert.match(error.message, /domain failure/); + }); + + await t.test('handleRpc: thrown errors map to 500 by default', async () => { + const transport = fakeWsTransport(); + const client = createClient(transport); + await handleRpc(client, { type: 'call', id: '9', method: 'fail/crash', args: {} }, router); + const [{ code, error }] = transport.errors; + assert.strictEqual(code, 500); + assert.match(error.message, /kaput/); + }); - const socket = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => socket.addEventListener('open', resolve, { once: true })); + await t.test('handleRpc: numeric error codes pass through, ETIMEOUT maps to 408', async () => { + const transport = fakeWsTransport(); + const client = createClient(transport); + await handleRpc(client, { type: 'call', id: '10', method: 'fail/teapot', args: {} }, router); + await handleRpc(client, { type: 'call', id: '11', method: 'fail/slow', args: {} }, router); + assert.strictEqual(transport.errors[0].code, 418); + assert.strictEqual(transport.errors[1].code, 408); + }); - const id = randomUUID(); - const initPacket = JSON.stringify({ type: 'stream', id, name: 'dup', size: 3 }); - socket.send(initPacket); + await t.test('handleMessage: unrecognized packet shape yields a structure error', async () => { + const transport = fakeWsTransport(); + const client = createClient(transport); + handleMessage(client, JSON.stringify({ nonsense: true }), router); + handleMessage(client, 'not json at all', router); + handleMessage(client, JSON.stringify({ type: 'call', method: 'math/add' }), router); + assert.strictEqual(transport.errors.length, 3); + for (const { code, error } of transport.errors) { + assert.strictEqual(code, 500); + assert.match(error.message, /Packet structure error/); + } + }); + + await t.test('handleMessage routes a valid call packet to handleRpc', async () => { + const transport = fakeWsTransport(); + const client = createClient(transport); + handleMessage(client, JSON.stringify({ type: 'call', id: '12', method: 'math/add', args: { a: 1, b: 1 } }), router); await timers.setImmediate(); - socket.send(initPacket); + assert.deepStrictEqual(transport.sent, [{ type: 'callback', id: '12', result: 2 }]); + }); - const raw = await new Promise((resolve) => { - socket.addEventListener('message', (event) => resolve(event.data), { once: true }); - }); - const response = JSON.parse(raw.toString()); - assert.match(response.error.message, /already initialized/); - - // Close the client side and wait for it to fully finish before this test - // returns, so the server-side connection is never left dangling for the - // `server.close()` cleanup registered above to race against. - await new Promise((resolve) => { - socket.addEventListener('close', resolve, { once: true }); - socket.close(); - }); + await t.test('handleStream: rejects an already-initialized stream id', async () => { + const transport = fakeWsTransport(); + const client = createClient(transport); + const id = randomUUID(); + const packet = JSON.stringify({ type: 'stream', id, name: 'dup', size: 3 }); + handleMessage(client, packet, router); + await timers.setImmediate(); + assert.ok(client.streams.has(id)); + handleMessage(client, packet, router); + await timers.setImmediate(); + const [{ code, error }] = transport.errors; + assert.strictEqual(code, 400); + assert.match(error.message, /already initialized/); }); - await t.test('handleBinary: chunk for an unknown stream id yields an error', async (t) => { - const { server, port } = await createServer({}); - t.after(() => server.close()); + await t.test('handleStream: malformed stream packet yields a structure error', async () => { + const transport = fakeWsTransport(); + const client = createClient(transport); + handleMessage(client, JSON.stringify({ type: 'stream', id: 'x', name: 'up', size: 'nope' }), router); + await timers.setImmediate(); + const [{ code, error }] = transport.errors; + assert.strictEqual(code, 400); + assert.match(error.message, /Stream packet structure error/); + }); - const socket = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => socket.addEventListener('open', resolve, { once: true })); + await t.test('handleBinary: chunk for an unknown stream id yields an error', async () => { + const transport = fakeWsTransport(); + const client = createClient(transport); + await handleBinary(client, chunkEncode('missing-stream', new Uint8Array([1, 2, 3]))); + const [{ code, id, error }] = transport.errors; + assert.strictEqual(code, 400); + assert.strictEqual(id, 'missing-stream'); + assert.match(error.message, /is not initialized/); + }); +}); - const chunk = chunkEncode('missing-stream', new Uint8Array([1, 2, 3])); - socket.send(chunk); +const createServer = async (options = {}) => { + const server = new Server({ + router, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 50 }, + ...options, + }); + await server.listen(); + const { port } = server.address(); + return { server, port }; +}; - const raw = await new Promise((resolve) => { - socket.addEventListener('message', (event) => resolve(event.data), { once: true }); - }); - const response = JSON.parse(raw.toString()); - assert.match(response.error.message, /is not initialized/); +test('Server internals', async (t) => { + await t.test('REST-style RPC via /api/unit/method?params', async (t) => { + const { server, port } = await createServer(); + t.after(() => server.close()); - await new Promise((resolve) => { - socket.addEventListener('close', resolve, { once: true }); - socket.close(); - }); + const res = await fetch(`http://127.0.0.1:${port}/api/math/add?a=2&b=3`); + const body = await res.json(); + assert.strictEqual(body.result, 5); }); await t.test('listen: retries on EADDRINUSE and eventually rejects when exhausted', async (t) => { - const blocker = await createServer({}); + const blocker = await createServer(); t.after(() => blocker.server.close()); - const application = createApplication({}); - const server = new Server(application, { + const server = new Server({ + router, host: '127.0.0.1', port: blocker.port, protocol: 'http', + logger: false, timeouts: { bind: 20 }, retry: 2, }); diff --git a/tests/server.test.js b/tests/server.test.js index cbf322e..3c74660 100644 --- a/tests/server.test.js +++ b/tests/server.test.js @@ -5,7 +5,7 @@ const { randomUUID } = require('node:crypto'); const { test } = require('node:test'); const assert = require('node:assert'); -const { Server } = require('../src/server.js'); +const { Server, defineRouter, procedure } = require('../index.js'); const { ProtocolClient } = require('./websocket/protocolClient.js'); const parseStatusCode = (statusLine) => { @@ -21,53 +21,32 @@ process.emitWarning = (warning, type, ...args) => { emitWarning(warning, type, ...args); }; -class ProcedureMock { - constructor({ access, ...options }) { - this.options = options; - this.access = access; - } - - // eslint-disable-next-line class-methods-use-this - async enter() {} - // eslint-disable-next-line class-methods-use-this - leave() {} - invoke(_context, args) { - return this.options.handler(args); - } -} +const router = defineRouter({ + test: { + hello: procedure({ + access: 'public', + handler: async (_context, { name }) => { + await timers.setTimeout(10); + return `Hello, ${name}`; + }, + }), + }, +}); test('Server / calls', async (t) => { - const api = { - test: { - hello: { - access: 'public', - handler: async ({ name }) => { - await timers.setTimeout(10); - return `Hello, ${name}`; - }, - }, - }, - }; - const noop = () => {}; const options = { + router, host: 'localhost', port: 8003, protocol: 'http', + logger: false, timeouts: { bind: 100 }, - queue: { concurrency: 100, size: 100, timeout: 5_000 }, - generateId: randomUUID, - }; - const application = { - console: { log: noop, info: noop, warn: noop, error: noop, debug: noop }, - static: { constructor: { name: 'Static' } }, - auth: { saveSession: async () => {} }, - getMethod: (unit, _version, method) => new ProcedureMock(api[unit][method]), }; let server; t.beforeEach(async () => { - server = new Server(application, options); + server = new Server(options); await server.listen(); }); @@ -121,7 +100,37 @@ test('Server / calls', async (t) => { assert.strictEqual(response.result, `Hello, ${args.name}`); }); + await t.test('responds 404 on non-/api HTTP path instead of hanging', async () => { + const res = await fetch(`http://${options.host}:${options.port}/health`); + assert.strictEqual(res.status, 404); + const packet = await res.json(); + assert.strictEqual(packet.type, 'callback'); + assert.strictEqual(packet.error.code, 404); + }); + + await t.test('listen() works without the timeouts option', async () => { + const extra = new Server({ router, host: 'localhost', port: 0, protocol: 'http', logger: false }); + await extra.listen(); + await extra.close(); + }); + + await t.test('listen() retry path works without the timeouts option', async () => { + // The bind-retry handler used to dereference options.timeouts.bind, so a + // server without `timeouts` crashed with a TypeError on EADDRINUSE. + const blocker = new Server({ router, host: 'localhost', port: 0, protocol: 'http', logger: false }); + await blocker.listen(); + const { port } = blocker.address(); + const extra = new Server({ router, host: 'localhost', port, protocol: 'http', logger: false }); + const listening = extra.listen(); + await timers.setTimeout(50); // first bind fails with EADDRINUSE, a retry is scheduled + await blocker.close(); // free the port so the scheduled retry succeeds + await listening; + await extra.close(); + }); + await t.test('rejects websocket upgrade on invalid path', async () => { + // The default verifyClient gate only accepts '/' and the RPC base paths, + // so the engine aborts the upgrade with 403 Forbidden. const res = await ProtocolClient.attemptHandshake({ host: options.host, port: options.port, @@ -136,4 +145,46 @@ test('Server / calls', async (t) => { }); assert.strictEqual(parseStatusCode(res.statusLine), 403); }); + + await t.test('ws.path relocates the websocket endpoint (regression: default gate 403ed it)', async () => { + const custom = new Server({ + router, + host: 'localhost', + port: 0, + protocol: 'http', + logger: false, + ws: { path: '/socket' }, + }); + await custom.listen(); + const { port } = custom.address(); + + const socket = new ProtocolClient(`ws://localhost:${port}/socket`); + const opened = await new Promise((resolve) => { + socket.on('open', () => resolve(true)); + socket.on('close', () => resolve(false)); + }); + assert.strictEqual(opened, true); + + const packet = { type: 'call', id: 'ws-path', method: 'test/hello', args: { name: 'Path' } }; + socket.send(JSON.stringify(packet)); + const resPacket = await new Promise((resolve) => socket.once('message', resolve)); + assert.strictEqual(JSON.parse(resPacket.toString()).result, 'Hello, Path'); + socket.close(); + + // the engine's own path filter still rejects everything else + const res = await ProtocolClient.attemptHandshake({ + host: 'localhost', + port, + path: '/api', + headers: { + Upgrade: 'websocket', + Connection: 'Upgrade', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Key': Buffer.from('0123456789abcdef').toString('base64'), + }, + timeoutMs: 600, + }); + assert.strictEqual(parseStatusCode(res.statusLine), 404); + await custom.close(); + }); }); diff --git a/tests/sse.test-d.ts b/tests/sse.test-d.ts new file mode 100644 index 0000000..9254f85 --- /dev/null +++ b/tests/sse.test-d.ts @@ -0,0 +1,46 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import * as sse from '../sse.js'; +import type { ClientSseTransport, SseChannels, SseEvent, SseOptions, SseParser, SseWriter } from '../sse.js'; +import type { HttpCall, RpcServerOptions, Router } from '../index.js'; + +expectType(sse.SseParser); +expectType(sse.ClientSseTransport); +expectType(sse.SseChannels); +expectType(sse.CHANNEL_HEADER); +expectType>(sse.SSE_HEADERS); + +// The parser is incremental: a chunk yields the events it completed +const parser = new sse.SseParser(); +expectType>(parser.push('data: hi\n\n')); +const [event] = parser.push(''); +expectType(event.id); +expectType(event.event); +expectType(event.data); + +// The client transport is registered on require, and named at connect time. +// No channelId property: the id is server-minted and learned from the +// `ready` frame, never generated (or exposed) client-side. +const transport = new sse.ClientSseTransport('https://host/api'); +expectType(transport.eventsUrl); +expectType(transport.active); +expectAssignable<{ transport?: string }>({ transport: 'sse' }); +expectError(new sse.ClientSseTransport()); + +// SSE is configured through the core's `sse` option, or turned off +declare const router: Router; +expectAssignable({ router, sse: { retention: 60_000, replay: 200, heartbeat: 0 } }); +expectAssignable({ router, sse: false }); +expectError({ router, sse: { retention: 'soon' } }); +expectAssignable({ retry: 1000 }); + +// A call that can stream is what the events endpoint needs +expectAssignable({ + method: 'GET', + url: '/api/events', + headers: {}, + respond: () => {}, + stream: () => null, +}); +declare const writer: SseWriter; +expectType(writer.write('data: x\n\n')); +expectType(writer.end()); diff --git a/tests/sse/sse.test.js b/tests/sse/sse.test.js new file mode 100644 index 0000000..56cf909 --- /dev/null +++ b/tests/sse/sse.test.js @@ -0,0 +1,638 @@ +'use strict'; + +const timers = require('node:timers/promises'); +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { Server, WrpcClient, defineRouter, procedure, createEventStream, tracked } = require('../../index.js'); +const { SseParser, CHANNEL_HEADER } = require('../../sse.js'); + +const waitFor = async (predicate, message = 'condition never held') => { + for (let i = 0; i < 300; i++) { + if (predicate()) return; + await timers.setTimeout(5); + } + assert.fail(message); +}; + +// --------------------------------------------------------------------------- +// The parser, on its own + +test('SseParser: the event-stream format', async (t) => { + await t.test('a simple event', () => { + const parser = new SseParser(); + assert.deepStrictEqual(parser.push('data: hello\n\n'), [{ id: null, event: 'message', data: 'hello' }]); + }); + + await t.test('fields split across chunks', () => { + const parser = new SseParser(); + assert.deepStrictEqual(parser.push('id: 7\nda'), []); + assert.deepStrictEqual(parser.push('ta: {"a":'), []); + assert.deepStrictEqual(parser.push('1}\n\n'), [{ id: '7', event: 'message', data: '{"a":1}' }]); + }); + + await t.test('several events in one chunk', () => { + const parser = new SseParser(); + const events = parser.push('data: one\n\ndata: two\n\n'); + assert.deepStrictEqual( + events.map((event) => event.data), + ['one', 'two'], + ); + }); + + await t.test('multi-line data joins with newlines', () => { + const parser = new SseParser(); + assert.deepStrictEqual(parser.push('data: a\ndata: b\n\n')[0].data, 'a\nb'); + }); + + await t.test('comments are ignored — that is the proxy heartbeat', () => { + const parser = new SseParser(); + assert.deepStrictEqual(parser.push(': ping\n\n'), []); + assert.deepStrictEqual(parser.push(': ping\ndata: real\n\n')[0].data, 'real'); + }); + + await t.test('a named event carries its name', () => { + const parser = new SseParser(); + assert.deepStrictEqual(parser.push('event: ready\ndata: {}\n\n'), [{ id: null, event: 'ready', data: '{}' }]); + }); + + await t.test('CRLF and bare CR are line endings too', () => { + const parser = new SseParser(); + assert.deepStrictEqual(parser.push('data: crlf\r\n\r\n')[0].data, 'crlf'); + const split = new SseParser(); + assert.deepStrictEqual(split.push('data: wait\r'), [], 'a trailing CR might still be half of a CRLF'); + assert.deepStrictEqual(split.push('\n\r\n')[0].data, 'wait'); + }); + + await t.test('the id sticks until the next one', () => { + const parser = new SseParser(); + assert.strictEqual(parser.push('id: 1\ndata: a\n\n')[0].id, '1'); + assert.strictEqual(parser.push('data: b\n\n')[0].id, '1', 'the last id stays current'); + }); + + await t.test('an id containing U+0000 is ignored, per the spec', () => { + const nul = String.fromCharCode(0); + const parser = new SseParser(); + assert.strictEqual(parser.push(`id: b${nul}ad\ndata: a\n\n`)[0].id, null); + assert.strictEqual(parser.push('id: has spaces\ndata: b\n\n')[0].id, 'has spaces', 'only NUL is special'); + }); + + await t.test('a field with no value, and a dispatch with no data', () => { + const parser = new SseParser(); + assert.deepStrictEqual(parser.push('data\n\n'), [{ id: null, event: 'message', data: '' }]); + assert.deepStrictEqual(parser.push('\n'), [], 'a blank line with nothing buffered dispatches nothing'); + assert.deepStrictEqual(parser.push('event: lonely\n\n'), [], 'no data means no event'); + }); + + await t.test('random noise never throws', () => { + const parser = new SseParser(); + const alphabet = ['\n', '\r', ':', ' ', 'a', 'data', 'id', 'event', '{', '}', 'é']; + let seed = 12345; + const next = () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return alphabet[seed % alphabet.length]; + }; + for (let i = 0; i < 2000; i++) { + assert.doesNotThrow(() => parser.push(next())); + } + }); +}); + +// --------------------------------------------------------------------------- +// End to end over a real node:http server + +const state = { log: [], live: new Set(), closed: 0 }; + +const router = defineRouter({ + test: { + hello: procedure({ access: 'public', handler: async (_context, { name }) => `Hello, ${name}` }), + peekMeta: procedure({ + access: 'public', + handler: async (context) => ({ + url: context.meta.url, + hasHeaders: Object.keys(context.meta.headers).length > 0, + appVersion: context.meta.headers['x-app-version'] ?? null, + }), + }), + fail: procedure({ + access: 'public', + handler: async () => { + const error = new Error('Boom'); + error.code = 418; + throw error; + }, + }), + notify: procedure({ + access: 'public', + handler: async (context) => { + context.client.sendEvent('test/ping', { ping: true }); + return { ok: true }; + }, + }), + upload: procedure({ + access: 'public', + handler: async (context) => context.client.createStream('x', 1) && 'unreachable', + }), + messages: procedure.subscription({ + access: 'public', + handler: async function* (_context, _args, { signal }) { + const stream = createEventStream({ signal }); + state.live.add(stream); + try { + yield* stream; + } finally { + state.live.delete(stream); + state.closed++; + } + }, + }), + }, + auth: { + // Signing in happens over a PLAIN POST — the one half of this transport + // that can set a cookie, since a channel's transport looks persistent. + signin: procedure({ + access: 'public', + handler: async (context) => { + context.client.startSession(undefined, { user: 'ada' }); + return { ok: true }; + }, + }), + // `access` defaults to 'session': this is 403 without one. + whoami: procedure({ handler: async (context) => context.session.state.user }), + }, +}); + +const publish = (text) => { + const id = String(state.log.push(text) - 1); + for (const stream of state.live) stream.push(tracked(id, text)); +}; + +const createServer = async (options = {}) => { + const server = new Server({ + router, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 50 }, + ...options, + }); + await server.listen(); + return { server, port: server.address().port }; +}; + +test('sse: a full RPC session over POST + event stream', async (t) => { + const { server, port } = await createServer(); + t.after(() => server.close()); + const client = await WrpcClient.connect(`http://127.0.0.1:${port}/api`, { + transport: 'sse', + heartbeat: false, + headers: { 'X-App-Version': '7.7' }, + }); + t.after(() => void client.close()); + + await t.test('load and call go out as POSTs and answer on the stream', async () => { + await client.load('test'); + assert.strictEqual(await client.api.test.hello({ name: 'SSE' }), 'Hello, SSE'); + }); + + await t.test('errors keep their code', async () => { + await assert.rejects(client.api.test.fail(), (error) => error.code === 418); + }); + + await t.test('the channel client carries the meta of the GET that opened it', async () => { + const meta = await client.call('test/peekMeta'); + assert.ok(meta.url.includes('/api/events'), `meta.url is the opening GET's (got '${meta.url}')`); + assert.strictEqual(meta.hasHeaders, true); + assert.strictEqual(meta.appVersion, '7.7', 'declared headers ride the stream GET as real ones'); + }); + + await t.test('server events reach the stream', async () => { + const ping = new Promise((resolve) => client.api.test.once('ping', resolve)); + assert.deepStrictEqual(await client.api.test.notify(), { ok: true }); + assert.deepStrictEqual(await ping, { ping: true }); + }); + + await t.test('subscriptions work: opened by a POST, delivered on the stream', async () => { + const seen = []; + const handle = client.api.test.messages.subscribe({}, { onData: (data) => seen.push(data) }); + await waitFor(() => state.live.size === 1, 'the subscription never opened'); + publish('first'); + publish('second'); + await waitFor(() => seen.length === 2, `only ${seen.length} values arrived`); + assert.deepStrictEqual(seen, ['first', 'second']); + assert.strictEqual(handle.lastEventId, '1'); + handle.unsubscribe(); + await waitFor(() => state.live.size === 0, 'the unsubscribe never reached the server'); + }); + + await t.test('binary streams are refused rather than corrupted', async () => { + await assert.rejects(client.api.test.upload(), (error) => /text-only/.test(error.message)); + }); + + await t.test('the app-level heartbeat round-trips over POST and stream', async () => { + // The ping goes out as a POST, the pong comes back on the stream — which + // is the only liveness signal this transport has. + const beat = await new Promise((resolve) => { + const socketClient = client; + socketClient.send({ type: 'ping' }); + setTimeout(() => resolve(socketClient.active), 60); + }); + assert.strictEqual(beat, true); + }); +}); + +test('sse: the channel is what ties the two halves together', async (t) => { + const { server, port } = await createServer(); + t.after(() => server.close()); + const base = `http://127.0.0.1:${port}/api`; + + let channelId = null; + + await t.test('a POST with an unknown channel is a 409', async () => { + const res = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json', [CHANNEL_HEADER]: 'nope' }, + body: JSON.stringify({ type: 'call', id: '1', method: 'test/hello', args: { name: 'x' } }), + }); + const body = await res.json(); + assert.strictEqual(res.status, 409); + assert.strictEqual(body.error.code, 409); + }); + + await t.test('a client-proposed id is a 409 too: the server mints them', async () => { + const res = await fetch(`${base}/events?channel=probe`, { headers: { accept: 'text/event-stream' } }); + assert.strictEqual(res.status, 409); + await res.body?.cancel?.(); + }); + + await t.test('the stream mints the channel id and sets streaming headers', async () => { + const controller = new AbortController(); + t.after(() => controller.abort()); + const res = await fetch(`${base}/events`, { + headers: { accept: 'text/event-stream' }, + signal: controller.signal, + }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers.get('content-type'), 'text/event-stream'); + assert.strictEqual(res.headers.get('cache-control'), 'no-cache, no-transform'); + assert.strictEqual(res.headers.get('x-accel-buffering'), 'no'); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + const parser = new SseParser(); + const events = []; + while (events.length === 0) { + const { value } = await reader.read(); + events.push(...parser.push(decoder.decode(value, { stream: true }))); + } + assert.strictEqual(events[0].event, 'ready'); + channelId = JSON.parse(events[0].data).channel; + assert.match(channelId, /^[0-9a-f][0-9a-f-]{34}[0-9a-f]$/, 'the id comes from the server, not the request'); + assert.strictEqual(server.rpc.sse.size, 1); + }); + + await t.test('a POST on that channel answers 202 and replies on the stream', async () => { + const res = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json', [CHANNEL_HEADER]: channelId }, + body: JSON.stringify({ type: 'call', id: 'c1', method: 'test/hello', args: { name: 'Channel' } }), + }); + assert.strictEqual(res.status, 202); + assert.strictEqual((await res.text()).length, 0, 'the answer travels on the stream, not here'); + }); +}); + +test('sse: Last-Event-ID replays what the dropped stream missed', async (t) => { + const { server, port } = await createServer({ sse: { heartbeat: 0, retention: 5000 } }); + t.after(() => server.close()); + const base = `http://127.0.0.1:${port}/api`; + + const open = async (channel = null, headers = {}) => { + const controller = new AbortController(); + const query = channel ? `?channel=${encodeURIComponent(channel)}` : ''; + const res = await fetch(`${base}/events${query}`, { + headers: { accept: 'text/event-stream', ...headers }, + signal: controller.signal, + }); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + const parser = new SseParser(); + const events = []; + const pump = (async () => { + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + events.push(...parser.push(decoder.decode(value, { stream: true }))); + } + } catch { + // aborted + } + })(); + return { controller, events, pump }; + }; + + const first = await open(); + await waitFor(() => first.events.length >= 1, 'the ready event never arrived'); + const keep = JSON.parse(first.events[0].data).channel; + await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json', [CHANNEL_HEADER]: keep }, + body: JSON.stringify({ type: 'call', id: 'r1', method: 'test/hello', args: { name: 'One' } }), + }); + await waitFor(() => first.events.length >= 2, 'the answer never arrived'); + const answer = first.events.at(-1); + assert.strictEqual(JSON.parse(answer.data).result, 'Hello, One'); + + // The stream drops; the channel (and its client) is held for `retention`. + first.controller.abort(); + await first.pump; + await timers.setTimeout(30); + assert.strictEqual(server.rpc.sse.size, 1, 'a blip must not destroy the channel'); + + // A second answer produced while nobody is attached goes into the buffer. + await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json', [CHANNEL_HEADER]: keep }, + body: JSON.stringify({ type: 'call', id: 'r2', method: 'test/hello', args: { name: 'Two' } }), + }); + await timers.setTimeout(30); + + const second = await open(keep, { 'last-event-id': answer.id }); + t.after(() => second.controller.abort()); + await waitFor(() => second.events.some((event) => event.data.includes('Two')), 'the replay never arrived'); + const replayed = second.events.filter((event) => event.event === 'message').map((event) => JSON.parse(event.data)); + assert.deepStrictEqual( + replayed.map((packet) => packet.result), + ['Hello, Two'], + 'only what was missed is replayed, not what was already seen', + ); +}); + +// A reader that pumps one event stream into an array, for the tests that +// need to watch what came back rather than drive a whole client. +const readStream = async (url, headers = {}) => { + const controller = new AbortController(); + const res = await fetch(url, { headers: { accept: 'text/event-stream', ...headers }, signal: controller.signal }); + const events = []; + const pump = (async () => { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + const parser = new SseParser(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + events.push(...parser.push(decoder.decode(value, { stream: true }))); + } + } catch { + // aborted + } + })(); + return { res, controller, events, pump }; +}; + +test('sse: a channel restores the session its GET arrived with', async (t) => { + const { server, port } = await createServer(); + t.after(() => server.close()); + const base = `http://127.0.0.1:${port}/api`; + + const login = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'call', id: 'in', method: 'auth/signin', args: {} }), + }); + const [cookie] = login.headers.getSetCookie(); + const token = cookie.match(/^token=([^;]+)/)[1]; + + const openChannel = async (headers = {}) => { + const stream = await readStream(`${base}/events`, headers); + await waitFor(() => stream.events.some((event) => event.event === 'ready'), 'the ready event never arrived'); + stream.channel = JSON.parse(stream.events.find((event) => event.event === 'ready').data).channel; + return stream; + }; + + const call = async (channel, id, method, headers = {}) => { + const res = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json', [CHANNEL_HEADER]: channel, ...headers }, + body: JSON.stringify({ type: 'call', id, method, args: {} }), + }); + assert.strictEqual(res.status, 202); + }; + + await t.test('a cookie on the stream signs the channel in', async () => { + const stream = await openChannel({ cookie: `token=${token}` }); + t.after(() => stream.controller.abort()); + await call(stream.channel, 'w1', 'auth/whoami', { cookie: `token=${token}` }); + await waitFor(() => stream.events.some((event) => event.event === 'message'), 'nothing came back'); + const answer = JSON.parse(stream.events.find((event) => event.event === 'message').data); + assert.strictEqual(answer.error, undefined, 'a valid cookie must not have to sign in again per channel'); + assert.strictEqual(answer.result, 'ada'); + }); + + await t.test('without one the channel is anonymous and a session call is 403', async () => { + const stream = await openChannel(); + t.after(() => stream.controller.abort()); + await call(stream.channel, 'w2', 'auth/whoami'); + await waitFor(() => stream.events.some((event) => event.event === 'message'), 'nothing came back'); + const answer = JSON.parse(stream.events.find((event) => event.event === 'message').data); + assert.strictEqual(answer.error.code, 403); + }); + + await t.test('a POST without the binding cookie is 403 — the id is not a bearer token', async () => { + const stream = await openChannel({ cookie: `token=${token}` }); + t.after(() => stream.controller.abort()); + const res = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json', [CHANNEL_HEADER]: stream.channel }, + body: JSON.stringify({ type: 'call', id: 'w3', method: 'auth/whoami', args: {} }), + }); + assert.strictEqual(res.status, 403); + assert.strictEqual((await res.json()).error.code, 403); + }); + + await t.test('a re-attach without the binding cookie is 403 too', async () => { + const stream = await openChannel({ cookie: `token=${token}` }); + stream.controller.abort(); + await stream.pump; + const res = await fetch(`${base}/events?channel=${encodeURIComponent(stream.channel)}`, { + headers: { accept: 'text/event-stream' }, + }); + assert.strictEqual(res.status, 403); + await res.body?.cancel?.(); + }); +}); + +test('sse: channel creation is capped', async (t) => { + await t.test('per address', async () => { + const { server, port } = await createServer({ sse: { maxChannelsPerAddress: 2 } }); + t.after(() => server.close()); + const base = `http://127.0.0.1:${port}/api`; + const first = await readStream(`${base}/events`); + t.after(() => first.controller.abort()); + const second = await readStream(`${base}/events`); + t.after(() => second.controller.abort()); + const res = await fetch(`${base}/events`, { headers: { accept: 'text/event-stream' } }); + assert.strictEqual(res.status, 429); + await res.body?.cancel?.(); + }); + + await t.test('globally', async () => { + const { server, port } = await createServer({ sse: { maxChannels: 1 } }); + t.after(() => server.close()); + const base = `http://127.0.0.1:${port}/api`; + const only = await readStream(`${base}/events`); + t.after(() => only.controller.abort()); + const res = await fetch(`${base}/events`, { headers: { accept: 'text/event-stream' } }); + assert.strictEqual(res.status, 503); + await res.body?.cancel?.(); + }); +}); + +test('sse: a cross-origin channel is granted, not silently blocked', async (t) => { + const origin = 'https://app.example'; + const cors = { origins: [origin], credentials: true }; + const { server, port } = await createServer({ cors }); + t.after(() => server.close()); + const base = `http://127.0.0.1:${port}/api`; + + await t.test('the preflight allows the headers this transport actually sends', async () => { + const res = await fetch(base, { + method: 'OPTIONS', + headers: { origin, 'access-control-request-method': 'POST', 'access-control-request-headers': CHANNEL_HEADER }, + }); + assert.strictEqual(res.status, 200); + const allowed = res.headers.get('access-control-allow-headers').toLowerCase(); + // Neither is CORS-safelisted, so a browser never sends the request at all + // unless the preflight names them. + assert.match(allowed, /x-wrpc-channel/); + assert.match(allowed, /last-event-id/); + assert.strictEqual(res.headers.get('access-control-allow-origin'), origin); + }); + + await t.test('the stream and the POST that feeds it are both readable', async () => { + const stream = await readStream(`${base}/events`, { origin }); + t.after(() => stream.controller.abort()); + assert.strictEqual(stream.res.headers.get('access-control-allow-origin'), origin); + assert.strictEqual(stream.res.headers.get('access-control-allow-credentials'), 'true'); + await waitFor(() => stream.events.some((event) => event.event === 'ready'), 'the ready event never arrived'); + const channel = JSON.parse(stream.events.find((event) => event.event === 'ready').data).channel; + + const res = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json', origin, [CHANNEL_HEADER]: channel }, + body: JSON.stringify({ type: 'call', id: 'x1', method: 'test/hello', args: { name: 'Cross' } }), + }); + assert.strictEqual(res.status, 202); + assert.strictEqual(res.headers.get('access-control-allow-origin'), origin, 'the 202 is unreadable without this'); + assert.strictEqual(res.headers.get('access-control-allow-credentials'), 'true'); + await waitFor(() => stream.events.some((event) => event.event === 'message'), 'the answer never arrived'); + }); + + await t.test('an unknown channel refuses in a way the page can read', async () => { + const res = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json', origin, [CHANNEL_HEADER]: 'gone' }, + body: JSON.stringify({ type: 'call', id: 'x2', method: 'test/hello', args: { name: 'x' } }), + }); + assert.strictEqual(res.status, 409); + assert.strictEqual(res.headers.get('access-control-allow-origin'), origin); + }); + + await t.test('a disallowed origin is refused outright, not merely denied the headers', async () => { + const res = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json', origin: 'https://evil.example' }, + body: JSON.stringify({ type: 'call', id: 'x3', method: 'test/hello', args: { name: 'x' } }), + }); + assert.strictEqual(res.status, 403, 'the call must not run for a disallowed origin'); + assert.strictEqual(res.headers.get('access-control-allow-origin'), null); + assert.strictEqual(res.headers.get('vary'), 'Origin'); + }); +}); + +test('sse: a host that cannot stream says so', async (t) => { + const { server } = await createServer(); + t.after(() => server.close()); + // The abstract call the core consumes; a host with no `stream` is exactly + // what an adapter that cannot keep a response open looks like. + const answers = []; + await server.rpc.handleHttpCall({ + method: 'GET', + url: '/api/events', + headers: {}, + respond: (response) => answers.push(response), + }); + assert.strictEqual(answers[0].status, 501); + assert.match(JSON.parse(answers[0].body).error.message, /unsupported/i); +}); + +test('sse: a resume past the replay buffer gets an honest gap frame', async (t) => { + const { server, port } = await createServer({ sse: { heartbeat: 0, retention: 5000, replay: 2 } }); + t.after(() => server.close()); + const base = `http://127.0.0.1:${port}/api`; + + const first = await readStream(`${base}/events`); + await waitFor(() => first.events.some((event) => event.event === 'ready'), 'ready never arrived'); + const channel = JSON.parse(first.events.find((event) => event.event === 'ready').data).channel; + + const call = (id) => + fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json', [CHANNEL_HEADER]: channel }, + body: JSON.stringify({ type: 'call', id, method: 'test/hello', args: { name: id } }), + }); + await call('g1'); + await waitFor(() => first.events.some((event) => event.event === 'message'), 'the first answer never arrived'); + const seenId = first.events.find((event) => event.event === 'message').id; + first.controller.abort(); + await first.pump; + + // Three more answers while nobody is attached: with replay=2 the first of + // them falls out of the buffer, so resuming from `seenId` cannot be honest. + await call('g2'); + await call('g3'); + await call('g4'); + await timers.setTimeout(30); + + const second = await readStream(`${base}/events?channel=${encodeURIComponent(channel)}`, { + 'last-event-id': seenId, + }); + t.after(() => second.controller.abort()); + await waitFor(() => second.events.some((event) => event.event === 'gap'), 'the gap frame never arrived'); + const gap = second.events.find((event) => event.event === 'gap'); + assert.ok(JSON.parse(gap.data).oldest > Number(seenId), 'the gap names what IS still available'); + assert.ok( + !second.events.some((event) => event.event === 'message'), + 'a resume that cannot be complete must not silently replay a truncated history', + ); +}); + +test('sse: the replay buffer is capped by bytes, not only frames', async (t) => { + const { server, port } = await createServer({ sse: { heartbeat: 0, replay: 100, replayBytes: 512 } }); + t.after(() => server.close()); + const base = `http://127.0.0.1:${port}/api`; + + const stream = await readStream(`${base}/events`); + t.after(() => stream.controller.abort()); + await waitFor(() => stream.events.some((event) => event.event === 'ready'), 'ready never arrived'); + const channel = JSON.parse(stream.events.find((event) => event.event === 'ready').data).channel; + + // Each answer is ~300 bytes; after several the byte budget must evict old + // frames long before the 100-frame cap would. + for (let i = 0; i < 5; i++) { + await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json', [CHANNEL_HEADER]: channel }, + body: JSON.stringify({ type: 'call', id: `b${i}`, method: 'test/hello', args: { name: 'x'.repeat(200) } }), + }); + } + await waitFor(() => stream.events.filter((event) => event.event === 'message').length === 5, 'answers arrived'); + const held = server.rpc.sse.get(channel); + assert.ok(held.bytes <= 512, `the buffer holds ${held.bytes} bytes, over the 512 budget`); + // `count` is the ring's live-entry counter (buffer.length is the ring's + // preallocated capacity, constant by design). + assert.ok(held.count < 5, 'older frames were evicted by the byte budget'); +}); diff --git a/tests/streams.internals.test.js b/tests/streams.internals.test.js index 2c005d5..34db23e 100644 --- a/tests/streams.internals.test.js +++ b/tests/streams.internals.test.js @@ -79,22 +79,39 @@ test('WrpcReadable.pipe surfaces finalize() rejections as an error event', async assert.match(error.message, /sink exploded/); }); -test('WrpcReadable.push applies backpressure once the high water mark is exceeded', async () => { +test('WrpcReadable.push applies backpressure once a consumer attached and the high water mark is exceeded', async () => { const readable = new WrpcReadable('id', 'name', 10, { highWaterMark: 1 }); + // The high-water mark only applies after the first read: the consumer + // attaches, and PULL_EVENTs become possible. await readable.push(Buffer.from('a')); + assert.strictEqual((await readable.read()).toString(), 'a'); + await readable.push(Buffer.from('b')); + await readable.push(Buffer.from('c')); - let thirdResolved = false; - const third = readable.push(Buffer.from('c')).then(() => (thirdResolved = true)); + let fourthResolved = false; + const fourth = readable.push(Buffer.from('d')).then(() => (fourthResolved = true)); await new Promise((resolve) => setImmediate(resolve)); - assert.strictEqual(thirdResolved, false); + assert.strictEqual(fourthResolved, false); - assert.strictEqual((await readable.read()).toString(), 'a'); - await third; - assert.strictEqual(thirdResolved, true); assert.strictEqual((await readable.read()).toString(), 'b'); + await fourth; + assert.strictEqual(fourthResolved, true); assert.strictEqual((await readable.read()).toString(), 'c'); + assert.strictEqual((await readable.read()).toString(), 'd'); +}); + +test('WrpcReadable.push buffers freely before any consumer attaches', async () => { + const readable = new WrpcReadable('id', 'name', 10, { highWaterMark: 1 }); + // No consumer yet: pushes far beyond the high-water mark must resolve + // immediately — blocking here would deadlock the upload-then-call wire + // pattern (chunks arrive before the call that starts the consumer). + for (let i = 0; i < 40; i++) { + await readable.push(Buffer.from(String(i))); + } + assert.strictEqual(readable.queue.length, 40); + assert.strictEqual((await readable.read()).toString(), '0'); }); test('WrpcReadable.terminate marks the stream terminated without waiting for all bytes', async () => { @@ -106,6 +123,8 @@ test('WrpcReadable.terminate marks the stream terminated without waiting for all test('WrpcReadable.checkStreamLimits grows the high water mark under listener pressure', async () => { const readable = new WrpcReadable('id', 'name', 100, { highWaterMark: 1 }); + await readable.push(Buffer.from('0')); + assert.strictEqual((await readable.read()).toString(), '0'); // attach a consumer await readable.push(Buffer.from('a')); await readable.push(Buffer.from('b')); @@ -117,13 +136,19 @@ test('WrpcReadable.checkStreamLimits grows the high water mark under listener pr } await new Promise((resolve) => setImmediate(resolve)); - // An 11th waiter trips checkStreamLimits' threshold (raising highWaterMark) - // and then immediately hits the Emitter's own identical default maxListeners - // cap when it tries to register itself, rejecting this specific push(). - const eleventh = await readable.push(Buffer.from('x')).catch((error) => error); + // An 11th waiter trips checkStreamLimits' threshold (raising highWaterMark). + // Since F2 the Emitter's listener cap only warns instead of throwing, so + // the push stalls like the others rather than rejecting. + const warnings = []; + const originalWarn = globalThis.console.warn; + globalThis.console.warn = (message) => warnings.push(message); + const eleventh = readable.push(Buffer.from('x')); + await new Promise((resolve) => setImmediate(resolve)); + globalThis.console.warn = originalWarn; + assert.strictEqual(readable.highWaterMark, 2); - assert.match(eleventh.message, /MaxListenersExceededWarning/); + assert.ok(warnings.some((message) => /MaxListenersExceededWarning/.test(message))); - for (let i = 0; i < 12; i++) await readable.read(); - await Promise.all(stalled); + for (let i = 0; i < 13; i++) await readable.read(); + await Promise.all([...stalled, eleventh]); }); diff --git a/tests/telemetry.propagation.test.js b/tests/telemetry.propagation.test.js new file mode 100644 index 0000000..15da2d8 --- /dev/null +++ b/tests/telemetry.propagation.test.js @@ -0,0 +1,373 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const otelApi = require('@opentelemetry/api'); +const { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-base'); +const { AsyncLocalStorageContextManager } = require('@opentelemetry/context-async-hooks'); +const { W3CTraceContextPropagator } = require('@opentelemetry/core'); + +const { Server, WrpcClient, defineRouter, procedure } = require('../index.js'); +const { createServerTelemetry, TRACEPARENT } = require('../src/telemetry/index.js'); + +// Client and server each get their own exporter, exactly as two processes +// would: if the ids line up across them, propagation genuinely crossed the +// wire rather than leaking through a shared in-process context. +const createTracing = () => { + const exporter = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); + return { provider, tracer: provider.getTracer('@alexify/wrpc'), spans: () => exporter.getFinishedSpans() }; +}; + +const createRouter = () => + defineRouter({ + probe: { + echo: procedure({ access: 'public', handler: async (_context, args) => args }), + }, + }); + +const boot = async (t, telemetry) => { + const server = new Server({ + router: createRouter(), + host: '127.0.0.1', + port: 0, + protocol: 'http', + timeouts: { bind: 100 }, + logger: false, + telemetry, + }); + await server.listen(); + t.after(() => server.close()); + return `ws://127.0.0.1:${server.address().port}/api`; +}; + +// Both halves of what real propagation needs: a context manager so a span +// opened by startActiveSpan is actually *active*, and a propagator that +// knows the W3C format. wrpc supplies neither — that is the whole point of +// delegating to the injected api. +const withOtelGlobals = (t) => { + const manager = new AsyncLocalStorageContextManager().enable(); + otelApi.context.setGlobalContextManager(manager); + otelApi.propagation.setGlobalPropagator(new W3CTraceContextPropagator()); + t.after(() => { + otelApi.context.disable(); + otelApi.propagation.disable(); + }); +}; + +test('a client span parents the server span across the wire', async (t) => { + withOtelGlobals(t); + const clientSide = createTracing(); + const serverSide = createTracing(); + + // Distinct global providers are impossible in one process, so each side is + // given its tracer directly and shares only the api's propagator. + const url = await boot(t, { + tracer: serverSide.tracer, + propagation: otelApi.propagation, + context: otelApi.context, + }); + const client = await WrpcClient.connect(url, { + reconnect: false, + telemetry: { tracer: clientSide.tracer, propagation: otelApi.propagation, context: otelApi.context }, + }); + await client.load('probe'); + await client.api.probe.echo({ ok: 1 }); + client.close(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const callSpan = clientSide.spans().find((span) => span.name === 'probe/echo'); + const serverSpan = serverSide.spans().find((span) => span.name === 'probe/echo'); + assert.ok(callSpan, 'the client traced the call'); + assert.ok(serverSpan, 'the server traced the call'); + assert.strictEqual(callSpan.kind, 2, 'the client span is a CLIENT span'); + assert.strictEqual(serverSpan.kind, 1, 'the server span is a SERVER span'); + assert.strictEqual(serverSpan.spanContext().traceId, callSpan.spanContext().traceId, 'both sides are in ONE trace'); + assert.strictEqual( + serverSpan.parentSpanContext?.spanId, + callSpan.spanContext().spanId, + 'and the server span hangs off the client span', + ); +}); + +test('the tp field is optional in both directions', async (t) => { + withOtelGlobals(t); + + await t.test('a client without telemetry sends no tp, and the server roots', async () => { + const serverSide = createTracing(); + const url = await boot(t, { + tracer: serverSide.tracer, + propagation: otelApi.propagation, + context: otelApi.context, + }); + const sent = []; + const client = await WrpcClient.connect(url, { + reconnect: false, + proxy: null, + }); + const originalSend = client.send.bind(client); + client.send = (packet) => { + sent.push(packet); + return originalSend(packet); + }; + await client.load('probe'); + await client.api.probe.echo({ ok: 1 }); + client.close(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.ok(sent.length > 0, 'packets were captured'); + for (const packet of sent) { + assert.strictEqual(packet[TRACEPARENT], undefined, 'no trace context was injected'); + } + const serverSpan = serverSide.spans().find((span) => span.name === 'probe/echo'); + assert.ok(serverSpan, 'the server still traced the call'); + assert.strictEqual(serverSpan.parentSpanContext, undefined, 'as a root span'); + }); + + await t.test('a tp a server does not understand is inert', async () => { + // No telemetry at all on the server: the extra field must be ignored, + // not answered with an error. + const url = await boot(t, null); + const client = await WrpcClient.connect(url, { reconnect: false }); + await client.load('probe'); + assert.deepStrictEqual(await client.api.probe.echo({ ok: 1 }), { ok: 1 }); + client.close(); + }); +}); + +test('trustRemoteContext', async (t) => { + withOtelGlobals(t); + + const injected = () => { + // A syntactically valid W3C traceparent from a peer we may or may not + // trust: version-traceId-spanId-flags. + return '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; + }; + + await t.test('trusted by default: the remote span becomes the parent', () => { + const { tracer, spans } = createTracing(); + const otel = createServerTelemetry({ tracer, propagation: otelApi.propagation, context: otelApi.context }); + const packet = { type: 'call', id: '1', [TRACEPARENT]: injected() }; + otel.withSpan({ client: { source: 'x', persistent: true, transportKind: 'ws' }, packet, target: 'a/b' }, (h) => + otel.endSpan(h), + ); + const [span] = spans(); + assert.strictEqual(span.spanContext().traceId, '4bf92f3577b34da6a3ce929d0e0e4736'); + assert.strictEqual(span.parentSpanContext?.spanId, '00f067aa0ba902b7'); + }); + + await t.test('trustRemoteContext:false ignores it and roots the span', () => { + const { tracer, spans } = createTracing(); + const otel = createServerTelemetry({ + tracer, + propagation: otelApi.propagation, + context: otelApi.context, + trustRemoteContext: false, + }); + const packet = { type: 'call', id: '1', [TRACEPARENT]: injected() }; + otel.withSpan({ client: { source: 'x', persistent: true, transportKind: 'ws' }, packet, target: 'a/b' }, (h) => + otel.endSpan(h), + ); + const [span] = spans(); + assert.notStrictEqual(span.spanContext().traceId, '4bf92f3577b34da6a3ce929d0e0e4736'); + assert.strictEqual(span.parentSpanContext, undefined); + }); + + await t.test('a malformed tp is ignored rather than fatal', () => { + const { tracer, spans } = createTracing(); + const otel = createServerTelemetry({ tracer, propagation: otelApi.propagation, context: otelApi.context }); + const packet = { type: 'call', id: '1', [TRACEPARENT]: 'not-a-traceparent' }; + otel.withSpan({ client: { source: 'x', persistent: true, transportKind: 'ws' }, packet, target: 'a/b' }, (h) => + otel.endSpan(h), + ); + assert.strictEqual(spans().length, 1); + assert.strictEqual(spans()[0].parentSpanContext, undefined); + }); + + await t.test('without a propagator, an inbound tp does nothing', () => { + const { tracer, spans } = createTracing(); + // { tracer } alone: injecting or extracting W3C context is impossible + // without a propagator, and wrpc refuses to hand-roll the format. + const otel = createServerTelemetry({ tracer }); + const packet = { type: 'call', id: '1', [TRACEPARENT]: injected() }; + otel.withSpan({ client: { source: 'x', persistent: true, transportKind: 'ws' }, packet, target: 'a/b' }, (h) => + otel.endSpan(h), + ); + assert.strictEqual(spans()[0].parentSpanContext, undefined); + }); +}); + +test('the startActiveSpan arity guard', async (t) => { + withOtelGlobals(t); + + await t.test('a 3-argument startActiveSpan still runs its callback', () => { + const calls = []; + // Exactly the trap: handing four arguments to this would silently never + // invoke the callback, and the call would answer nothing. + const tracer = { + startActiveSpan(name, options, fn) { + calls.push(name); + return fn({ end() {}, setAttribute() {} }); + }, + }; + const otel = createServerTelemetry({ tracer, propagation: otelApi.propagation, context: otelApi.context }); + const packet = { + type: 'call', + id: '1', + [TRACEPARENT]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + }; + let ran = false; + const result = otel.withSpan( + { client: { source: 'x', persistent: true, transportKind: 'ws' }, packet, target: 'a/b' }, + (handle) => { + ran = true; + assert.ok(handle.span, 'the span reached the callback'); + return 'answered'; + }, + ); + assert.strictEqual(ran, true); + assert.strictEqual(result, 'answered'); + assert.deepStrictEqual(calls, ['a/b']); + }); +}); + +test('batching keeps trace context per packet, not per frame', async (t) => { + withOtelGlobals(t); + const clientSide = createTracing(); + const serverSide = createTracing(); + const url = await boot(t, { + tracer: serverSide.tracer, + propagation: otelApi.propagation, + context: otelApi.context, + }); + const client = await WrpcClient.connect(url, { + reconnect: false, + batch: true, + telemetry: { tracer: clientSide.tracer, propagation: otelApi.propagation, context: otelApi.context }, + }); + await client.load('probe'); + // Issued in one tick, so they leave as ONE frame carrying two packets. + await Promise.all([client.api.probe.echo({ n: 1 }), client.api.probe.echo({ n: 2 })]); + client.close(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const clientSpans = clientSide.spans().filter((span) => span.name === 'probe/echo'); + const serverSpans = serverSide.spans().filter((span) => span.name === 'probe/echo'); + assert.strictEqual(clientSpans.length, 2); + assert.strictEqual(serverSpans.length, 2); + const clientIds = clientSpans.map((span) => span.spanContext().spanId).sort(); + const parentIds = serverSpans.map((span) => span.parentSpanContext?.spanId).sort(); + assert.deepStrictEqual(parentIds, clientIds, 'each batched packet kept its own parent'); +}); + +test('subscribe and event packets carry trace context too', async (t) => { + withOtelGlobals(t); + const clientSide = createTracing(); + + // The wire is observed directly: what matters here is the packet fields, + // not a server span — so a plain router without telemetry suffices. + const router = defineRouter({ + feed: { + ticks: procedure.subscription({ + access: 'public', + handler: async function* () { + yield { n: 1 }; + }, + }), + on: { + nudge: procedure({ access: 'public', handler: async () => {} }), + }, + }, + }); + const server = new Server({ + router, + host: '127.0.0.1', + port: 0, + protocol: 'http', + timeouts: { bind: 100 }, + logger: false, + }); + await server.listen(); + t.after(() => server.close()); + + const url = `ws://127.0.0.1:${server.address().port}/api`; + const client = await WrpcClient.connect(url, { + reconnect: false, + telemetry: { tracer: clientSide.tracer, propagation: otelApi.propagation, context: otelApi.context }, + }); + t.after(() => void client.close()); + await client.load('feed'); + + const sent = []; + const rawSend = client.send.bind(client); + client.send = (packet) => { + sent.push(packet); + return rawSend(packet); + }; + + await clientSide.tracer.startActiveSpan('outer', async (span) => { + await new Promise((resolve) => { + client.api.feed.ticks.subscribe({}, { onEnd: resolve }); + }); + client.sendEvent('feed/nudge', { poke: true }); + span.end(); + }); + + const subscribe = sent.find((packet) => packet.type === 'subscribe'); + assert.ok(subscribe, 'the subscribe packet was captured'); + assert.match(subscribe[TRACEPARENT], /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/); + const event = sent.find((packet) => packet.type === 'event'); + assert.ok(event, 'the event packet was captured'); + assert.match(event[TRACEPARENT], /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/); +}); + +test('the REST leg propagates context through real traceparent headers', async (t) => { + withOtelGlobals(t); + const clientSide = createTracing(); + const serverSide = createTracing(); + const definition = defineRouter({ + things: { + get: procedure({ + access: 'public', + http: { method: 'GET', path: '/things/:id' }, + handler: async (_context, args) => ({ id: args.params.id }), + }), + }, + }); + const server = new Server({ + router: definition, + host: '127.0.0.1', + port: 0, + protocol: 'http', + timeouts: { bind: 100 }, + logger: false, + telemetry: { tracer: serverSide.tracer, propagation: otelApi.propagation, context: otelApi.context }, + }); + await server.listen(); + t.after(() => server.close()); + const client = await WrpcClient.connect(`http://127.0.0.1:${server.address().port}/api`, { + transport: 'http', + logger: false, + reconnect: false, + telemetry: { tracer: clientSide.tracer, propagation: otelApi.propagation, context: otelApi.context }, + }); + t.after(() => void client.close()); + await client.load('things'); + // The mapped procedure goes out as a REAL REST request on the http + // transport — the leg that used to be a trace break in both directions. + const result = await client.api.things.get({ params: { id: '7' } }); + assert.deepStrictEqual(result, { id: '7' }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const callSpan = clientSide.spans().find((span) => span.name === 'things/get'); + const serverSpan = serverSide.spans().find((span) => span.name === 'things/get'); + assert.ok(callSpan, 'the REST leg must open a client span'); + assert.ok(serverSpan, 'the server must span the REST call'); + assert.strictEqual(serverSpan.spanContext().traceId, callSpan.spanContext().traceId, 'one trace across the leg'); + assert.strictEqual( + serverSpan.parentSpanContext?.spanId, + callSpan.spanContext().spanId, + 'the server span hangs off the REST client span', + ); +}); diff --git a/tests/telemetry.test.js b/tests/telemetry.test.js new file mode 100644 index 0000000..3e82377 --- /dev/null +++ b/tests/telemetry.test.js @@ -0,0 +1,779 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const otelApi = require('@opentelemetry/api'); +const { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-base'); +const { + AggregationTemporality, + InMemoryMetricExporter, + MeterProvider, + PeriodicExportingMetricReader, +} = require('@opentelemetry/sdk-metrics'); +const { AsyncLocalStorageContextManager } = require('@opentelemetry/context-async-hooks'); + +const { createServerTelemetry, createClientTelemetry, SCOPE_NAME, TRACEPARENT } = require('../src/telemetry/index.js'); + +// A real SDK, exporting into memory. Anything less would not prove that what +// wrpc emits is actually a valid span or a valid metric. +const createTracing = () => { + const exporter = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); + return { provider, tracer: provider.getTracer(SCOPE_NAME), spans: () => exporter.getFinishedSpans() }; +}; + +const createMetrics = () => { + const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); + // A very long interval: the reader is flushed by hand so the assertions + // are not racing a background timer. + const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 3_600_000 }); + const provider = new MeterProvider({ readers: [reader] }); + const collect = async () => { + await reader.forceFlush(); + const batches = exporter.getMetrics(); + const metrics = []; + for (const batch of batches) { + for (const scope of batch.scopeMetrics) metrics.push(...scope.metrics); + } + return metrics; + }; + return { provider, meter: provider.getMeter(SCOPE_NAME), collect, reader }; +}; + +const fakeClient = (overrides = {}) => ({ + source: '10.0.0.1', + persistent: true, + transportKind: 'ws', + ...overrides, +}); + +test('createServerTelemetry: what disables it', async (t) => { + await t.test('nothing, a non-object, or an empty bag is disabled', () => { + for (const value of [undefined, null, false, 'otel', 42, {}]) { + assert.strictEqual(createServerTelemetry(value).enabled, false, String(value)); + } + }); + + await t.test('an api module with neither trace nor metrics is disabled', () => { + assert.strictEqual(createServerTelemetry({ api: { trace: {}, metrics: {} } }).enabled, false); + }); + + await t.test('a getter that throws is disabled rather than fatal', () => { + const telemetry = { + get api() { + throw new Error('exploded while resolving'); + }, + }; + assert.strictEqual(createServerTelemetry(telemetry).enabled, false); + }); + + await t.test('the disabled writer still runs the callback', () => { + const otel = createServerTelemetry(null); + let handle = 'untouched'; + const result = otel.withSpan({ client: fakeClient(), packet: {}, target: 'a/b' }, (value) => { + handle = value; + return 'returned'; + }); + assert.strictEqual(result, 'returned'); + assert.strictEqual(handle, null); + assert.doesNotThrow(() => otel.recordCall('a/b', 'ok', 200, 1)); + }); +}); + +test('spans', async (t) => { + await t.test('a call span carries the rpc semconv attributes', () => { + const { tracer, spans } = createTracing(); + const otel = createServerTelemetry({ tracer }); + otel.withSpan({ client: fakeClient(), packet: { type: 'call', id: 'p1' }, target: 'chat.v2/send' }, (handle) => + otel.endSpan(handle, { 'wrpc.status': 'ok' }), + ); + const [span] = spans(); + assert.strictEqual(span.name, 'chat.v2/send'); + assert.strictEqual(span.attributes['rpc.system'], 'wrpc'); + assert.strictEqual(span.attributes['rpc.service'], 'chat.v2'); + assert.strictEqual(span.attributes['rpc.method'], 'send'); + assert.strictEqual(span.attributes['wrpc.packet.type'], 'call'); + assert.strictEqual(span.attributes['wrpc.packet.id'], 'p1'); + assert.strictEqual(span.attributes['wrpc.transport'], 'ws'); + assert.strictEqual(span.attributes['wrpc.status'], 'ok'); + assert.strictEqual(span.instrumentationScope.name, SCOPE_NAME); + }); + + await t.test('an error sets status 2 and records the exception', () => { + const { tracer, spans } = createTracing(); + const otel = createServerTelemetry({ tracer }); + const boom = new TypeError('bad args'); + otel.withSpan({ client: fakeClient(), packet: { type: 'call' }, target: 'a/b' }, (handle) => { + otel.recordError(handle, boom, 400); + otel.endSpan(handle); + }); + const [span] = spans(); + assert.strictEqual(span.status.code, 2); + assert.strictEqual(span.status.message, 'bad args'); + assert.strictEqual(span.attributes['error.type'], 'TypeError'); + assert.strictEqual(span.attributes['rpc.wrpc.status_code'], 400); + assert.strictEqual(span.events[0].name, 'exception'); + }); + + await t.test('nested spans parent correctly through startActiveSpan', (t) => { + // Without a context manager `startActiveSpan` opens a span but nothing + // sees it as active — which is the whole reason wrpc prefers that method. + const manager = new AsyncLocalStorageContextManager().enable(); + otelApi.context.setGlobalContextManager(manager); + t.after(() => otelApi.context.disable()); + const { provider, tracer, spans } = createTracing(); + const otel = createServerTelemetry({ tracer }); + otel.withSpan({ client: fakeClient(), packet: { type: 'call' }, target: 'a/b' }, (handle) => { + const inner = provider.getTracer('inner').startSpan('handler-work'); + inner.end(); + otel.endSpan(handle); + }); + const [inner, outer] = spans(); + assert.strictEqual(inner.name, 'handler-work'); + assert.strictEqual(inner.parentSpanContext?.spanId, outer.spanContext().spanId); + }); + + await t.test('the identity attribute is gated, the session token never appears', () => { + const { tracer, spans } = createTracing(); + const otel = createServerTelemetry({ tracer, includeIdentity: false }); + otel.withSpan({ client: fakeClient(), packet: { type: 'call' }, target: 'a/b' }, (handle) => otel.endSpan(handle)); + const [span] = spans(); + assert.strictEqual(span.attributes['network.peer.address'], undefined); + assert.strictEqual( + Object.keys(span.attributes).some((key) => key.includes('token')), + false, + ); + }); +}); + +test('a broken tracer never breaks a call', async (t) => { + await t.test('a tracer that throws before the callback still runs it', () => { + const otel = createServerTelemetry({ + tracer: { + startActiveSpan() { + throw new Error('tracer exploded'); + }, + }, + }); + let ran = false; + const result = otel.withSpan({ client: fakeClient(), packet: {}, target: 'a/b' }, (handle) => { + ran = true; + assert.strictEqual(handle.span, null); + return 'value'; + }); + assert.strictEqual(ran, true); + assert.strictEqual(result, 'value'); + }); + + await t.test('an error thrown by the callback propagates untouched', () => { + const { tracer } = createTracing(); + const otel = createServerTelemetry({ tracer }); + const boom = new Error('handler exploded'); + assert.throws( + () => + otel.withSpan({ client: fakeClient(), packet: {}, target: 'a/b' }, () => { + throw boom; + }), + (error) => error === boom, + ); + }); + + await t.test('a tracer without startActiveSpan falls back to startSpan', () => { + const started = []; + const otel = createServerTelemetry({ + tracer: { + startSpan(name) { + started.push(name); + return { end() {} }; + }, + }, + }); + otel.withSpan({ client: fakeClient(), packet: {}, target: 'a/b' }, (handle) => { + assert.ok(handle.span); + otel.endSpan(handle); + }); + assert.deepStrictEqual(started, ['a/b']); + }); + + await t.test('a startSpan that throws yields a null span, not a failed call', () => { + const otel = createServerTelemetry({ + tracer: { + startSpan() { + throw new Error('startSpan exploded'); + }, + }, + }); + let ran = false; + otel.withSpan({ client: fakeClient(), packet: {}, target: 'a/b' }, (handle) => { + ran = true; + assert.strictEqual(handle.span, null); + }); + assert.strictEqual(ran, true); + }); + + await t.test('a tracer with neither method yields a null span', () => { + const otel = createServerTelemetry({ tracer: { name: 'useless' } }); + otel.withSpan({ client: fakeClient(), packet: {}, target: 'a/b' }, (handle) => { + assert.strictEqual(handle.span, null); + assert.doesNotThrow(() => otel.endSpan(handle, { 'wrpc.status': 'ok' })); + assert.doesNotThrow(() => otel.recordError(handle, new Error('x'), 500)); + }); + }); + + await t.test('a span whose methods throw is contained', () => { + const explode = () => { + throw new Error('span exploded'); + }; + const otel = createServerTelemetry({ + tracer: { + startSpan: () => ({ + setAttribute: explode, + setStatus: explode, + recordException: explode, + end: explode, + }), + }, + }); + otel.withSpan({ client: fakeClient(), packet: {}, target: 'a/b' }, (handle) => { + assert.doesNotThrow(() => otel.recordError(handle, new Error('x'), 500)); + assert.doesNotThrow(() => otel.endSpan(handle, { 'wrpc.status': 'error' })); + }); + }); +}); + +test('metrics', async (t) => { + await t.test('calls and duration are recorded together', async () => { + const { meter, collect, provider } = createMetrics(); + t.after(() => provider.shutdown()); + const otel = createServerTelemetry({ meter }); + otel.recordCall('chat/send', 'ok', 200, 12.5); + otel.recordCall('chat/send', 'error', 500, 3); + const metrics = await collect(); + const calls = metrics.find((metric) => metric.descriptor.name === 'wrpc.server.calls'); + const duration = metrics.find((metric) => metric.descriptor.name === 'rpc.server.duration'); + assert.strictEqual(calls.dataPoints.length, 2); + assert.strictEqual(calls.dataPoints[0].attributes['wrpc.status'], 'ok'); + assert.strictEqual(calls.dataPoints[1].attributes['rpc.wrpc.status_code'], 500); + assert.strictEqual(duration.dataPoints[0].value.count, 1); + assert.strictEqual(duration.descriptor.unit, 'ms'); + }); + + await t.test('the gauges go up and back down', async () => { + const { meter, collect, provider } = createMetrics(); + t.after(() => provider.shutdown()); + const otel = createServerTelemetry({ meter }); + otel.recordConnection(1, 'ws'); + otel.recordConnection(1, 'ws'); + otel.recordConnection(-1, 'ws'); + otel.recordSseChannel(1); + const metrics = await collect(); + const connections = metrics.find((metric) => metric.descriptor.name === 'wrpc.server.connections'); + const channels = metrics.find((metric) => metric.descriptor.name === 'wrpc.server.sse.channels'); + assert.strictEqual(connections.dataPoints[0].value, 1); + assert.strictEqual(connections.dataPoints[0].attributes['wrpc.transport'], 'ws'); + assert.strictEqual(channels.dataPoints[0].value, 1); + }); + + await t.test('broadcast, stream, backpressure and session counters', async () => { + const { meter, collect, provider } = createMetrics(); + t.after(() => provider.shutdown()); + const otel = createServerTelemetry({ meter }); + otel.recordBroadcast('msg', 3, true); + otel.recordStreamBytes('receive', 2048); + otel.recordBackpressure('ws'); + otel.recordSession('restore', 'hit'); + otel.recordSubscription(1, 'feed/live'); + otel.recordSubscriptionValues(7, 'feed/live'); + const names = (await collect()).map((metric) => metric.descriptor.name); + for (const name of [ + 'wrpc.server.broadcasts', + 'wrpc.server.broadcast.recipients', + 'wrpc.server.stream.bytes', + 'wrpc.server.backpressure', + 'wrpc.server.sessions', + 'wrpc.server.subscriptions', + 'wrpc.server.subscription.values', + ]) { + assert.ok(names.includes(name), `${name} was exported`); + } + }); + + await t.test('a zero count is not recorded', async () => { + const { meter, collect, provider } = createMetrics(); + t.after(() => provider.shutdown()); + const otel = createServerTelemetry({ meter }); + otel.recordSubscriptionValues(0, 'feed/live'); + const names = (await collect()).map((metric) => metric.descriptor.name); + assert.strictEqual(names.includes('wrpc.server.subscription.values'), false); + }); +}); + +test('partial and broken meters', async (t) => { + await t.test('a meter without createUpDownCounter keeps its counters', () => { + const created = []; + const otel = createServerTelemetry({ + meter: { + createCounter: (name) => { + created.push(name); + return { add() {} }; + }, + createHistogram: (name) => { + created.push(name); + return { record() {} }; + }, + }, + }); + assert.strictEqual(otel.enabled, true); + assert.ok(created.includes('wrpc.server.calls'), 'counters were still built'); + assert.doesNotThrow(() => otel.recordCall('a/b', 'ok', 200, 1)); + assert.doesNotThrow(() => otel.recordConnection(1, 'ws')); + }); + + await t.test('a meter whose factories throw disables its instruments, not the writer', () => { + const otel = createServerTelemetry({ + meter: { + createCounter() { + throw new Error('meter exploded'); + }, + createHistogram: () => ({ record() {} }), + createUpDownCounter: () => ({ add() {} }), + }, + }); + assert.strictEqual(otel.enabled, true); + assert.doesNotThrow(() => otel.recordCall('a/b', 'ok', 200, 1)); + }); + + await t.test('an up/down counter factory that throws leaves the counters alone', () => { + const otel = createServerTelemetry({ + meter: { + createCounter: () => ({ add() {} }), + createHistogram: () => ({ record() {} }), + createUpDownCounter() { + throw new Error('gauge factory exploded'); + }, + }, + }); + assert.strictEqual(otel.enabled, true); + assert.doesNotThrow(() => otel.recordCall('a/b', 'ok', 200, 1)); + assert.doesNotThrow(() => otel.recordConnection(1, 'ws')); + }); + + await t.test('an instrument that throws on use is contained', () => { + const explode = () => { + throw new Error('instrument exploded'); + }; + const otel = createServerTelemetry({ + meter: { + createCounter: () => ({ add: explode }), + createHistogram: () => ({ record: explode }), + createUpDownCounter: () => ({ add: explode }), + }, + }); + assert.doesNotThrow(() => otel.recordCall('a/b', 'ok', 200, 1)); + assert.doesNotThrow(() => otel.recordConnection(1, 'ws')); + assert.doesNotThrow(() => otel.recordBroadcast('msg', 1, false)); + assert.doesNotThrow(() => otel.recordStreamBytes('receive', 1)); + assert.doesNotThrow(() => otel.recordBackpressure('ws')); + assert.doesNotThrow(() => otel.recordSession('create', 'ok')); + assert.doesNotThrow(() => otel.recordSseChannel(1)); + assert.doesNotThrow(() => otel.recordSubscription(1, 'a/b')); + assert.doesNotThrow(() => otel.recordSubscriptionValues(1, 'a/b')); + }); +}); + +test('the { api } injection mode', async (t) => { + await t.test('tracer and meter are derived with the wrpc scope', async () => { + const tracing = createTracing(); + const metrics = createMetrics(); + otelApi.trace.setGlobalTracerProvider(tracing.provider); + otelApi.metrics.setGlobalMeterProvider(metrics.provider); + t.after(() => { + otelApi.trace.disable(); + otelApi.metrics.disable(); + return metrics.provider.shutdown(); + }); + + const otel = createServerTelemetry({ api: otelApi }); + assert.strictEqual(otel.enabled, true); + otel.withSpan({ client: fakeClient(), packet: { type: 'call' }, target: 'a/b' }, (handle) => otel.endSpan(handle)); + otel.recordCall('a/b', 'ok', 200, 1); + + const [span] = tracing.spans(); + assert.strictEqual(span.instrumentationScope.name, SCOPE_NAME); + const exported = await metrics.collect(); + assert.ok(exported.some((metric) => metric.descriptor.name === 'wrpc.server.calls')); + }); +}); + +test('tracer-only and meter-only both work', async (t) => { + await t.test('tracer only: spans, no metric calls throw', () => { + const { tracer, spans } = createTracing(); + const otel = createServerTelemetry({ tracer }); + otel.withSpan({ client: fakeClient(), packet: {}, target: 'a/b' }, (handle) => otel.endSpan(handle)); + assert.strictEqual(spans().length, 1); + assert.doesNotThrow(() => otel.recordCall('a/b', 'ok', 200, 1)); + }); + + await t.test('meter only: metrics, and withSpan still runs its callback', async () => { + const { meter, collect, provider } = createMetrics(); + t.after(() => provider.shutdown()); + const otel = createServerTelemetry({ meter }); + let ran = false; + otel.withSpan({ client: fakeClient(), packet: {}, target: 'a/b' }, (handle) => { + ran = true; + assert.strictEqual(handle.span, null); + otel.endSpan(handle); + }); + otel.recordCall('a/b', 'ok', 200, 1); + assert.strictEqual(ran, true); + assert.ok((await collect()).some((metric) => metric.descriptor.name === 'wrpc.server.calls')); + }); +}); + +test('a live server emits spans and metrics for real traffic', async (t) => { + const { Server, WrpcClient, defineRouter, procedure } = require('../index.js'); + const tracing = createTracing(); + const metrics = createMetrics(); + t.after(() => metrics.provider.shutdown()); + + const server = new Server({ + router: defineRouter({ + probe: { + echo: procedure({ access: 'public', handler: async (_context, args) => args }), + boom: procedure({ + access: 'public', + handler: async () => { + throw new Error('handler exploded'); + }, + }), + feed: procedure({ + access: 'public', + subscription: true, + handler: async function* () { + yield 1; + yield 2; + }, + }), + }, + }), + host: '127.0.0.1', + port: 0, + protocol: 'http', + timeouts: { bind: 100 }, + logger: false, + telemetry: { tracer: tracing.tracer, meter: metrics.meter }, + }); + await server.listen(); + t.after(() => server.close()); + + const client = await WrpcClient.connect(`ws://127.0.0.1:${server.address().port}/api`, { reconnect: false }); + await client.load('probe'); + await client.api.probe.echo({ ok: 1 }); + await assert.rejects(client.api.probe.boom()); + const values = []; + await new Promise((resolve) => { + client.api.probe.feed.subscribe({}, { onData: (value) => values.push(value), onEnd: resolve }); + }); + client.close(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + await t.test('the successful call has an ok span', () => { + const span = tracing.spans().find((entry) => entry.name === 'probe/echo'); + assert.ok(span, 'probe/echo was traced'); + assert.strictEqual(span.attributes['wrpc.status'], 'ok'); + assert.strictEqual(span.attributes['rpc.service'], 'probe'); + assert.strictEqual(span.attributes['wrpc.transport'], 'ws'); + }); + + await t.test('the failing call has an error span with status 2', () => { + const span = tracing.spans().find((entry) => entry.name === 'probe/boom'); + assert.ok(span, 'probe/boom was traced'); + assert.strictEqual(span.status.code, 2); + assert.strictEqual(span.attributes['wrpc.status'], 'error'); + assert.strictEqual(span.attributes['rpc.wrpc.status_code'], 500); + }); + + await t.test('the subscription span counts what it yielded', () => { + const span = tracing.spans().find((entry) => entry.name === 'probe/feed subscribe'); + assert.ok(span, 'the subscription was traced'); + assert.deepStrictEqual(values, [1, 2]); + assert.strictEqual(span.attributes['wrpc.subscription.values'], 2); + assert.strictEqual(span.attributes['wrpc.subscription.terminal'], 'complete'); + }); + + await t.test('durations and connection counts reached the meter', async () => { + const exported = await metrics.collect(); + const duration = exported.find((metric) => metric.descriptor.name === 'rpc.server.duration'); + const connections = exported.find((metric) => metric.descriptor.name === 'wrpc.server.connections'); + assert.ok(duration, 'call durations were recorded'); + assert.ok( + duration.dataPoints.some( + (point) => point.attributes['rpc.service'] === 'probe' && point.attributes['rpc.method'] === 'echo', + ), + 'including the echo call — split per the RPC semconv, matching the spans', + ); + assert.ok(connections, 'the connection gauge was recorded'); + assert.strictEqual(connections.dataPoints[0].attributes['wrpc.transport'], 'ws'); + }); +}); + +test('createClientTelemetry', async (t) => { + await t.test('what disables it', () => { + for (const value of [undefined, null, false, 'otel', 42, {}]) { + assert.strictEqual(createClientTelemetry(value).enabled, false, String(value)); + } + assert.strictEqual(createClientTelemetry({ api: { trace: {}, metrics: {} } }).enabled, false); + const throwing = { + get api() { + throw new Error('exploded'); + }, + }; + assert.strictEqual(createClientTelemetry(throwing).enabled, false); + }); + + await t.test('the disabled writer answers to everything the client calls', () => { + const otel = createClientTelemetry(null); + assert.strictEqual( + otel.withSpan({ packet: {}, target: 'a/b' }, () => 'value'), + 'value', + ); + const packet = { type: 'call' }; + assert.doesNotThrow(() => otel.inject(packet)); + assert.deepStrictEqual(packet, { type: 'call' }, 'no fields were added'); + assert.doesNotThrow(() => otel.recordCall('a/b', 'ok', 1)); + assert.doesNotThrow(() => otel.recordReconnect('recovered', 2)); + assert.doesNotThrow(() => otel.recordConnection(1)); + }); + + await t.test('a call span is a CLIENT span with the rpc attributes', () => { + const { tracer, spans } = createTracing(); + const otel = createClientTelemetry({ tracer }); + otel.withSpan({ packet: { type: 'call', id: 'p9' }, target: 'chat/send' }, (handle) => + otel.endSpan(handle, { 'wrpc.status': 'ok' }), + ); + const [span] = spans(); + assert.strictEqual(span.name, 'chat/send'); + assert.strictEqual(span.kind, 2); + assert.strictEqual(span.attributes['rpc.system'], 'wrpc'); + assert.strictEqual(span.attributes['rpc.service'], 'chat'); + assert.strictEqual(span.attributes['rpc.method'], 'send'); + assert.strictEqual(span.attributes['wrpc.packet.id'], 'p9'); + assert.strictEqual(span.attributes['wrpc.status'], 'ok'); + }); + + await t.test('an error sets status 2 and the error type', () => { + const { tracer, spans } = createTracing(); + const otel = createClientTelemetry({ tracer }); + otel.withSpan({ packet: { type: 'call' }, target: 'a/b' }, (handle) => { + otel.recordError(handle, new RangeError('too big')); + otel.endSpan(handle); + }); + const [span] = spans(); + assert.strictEqual(span.status.code, 2); + assert.strictEqual(span.attributes['error.type'], 'RangeError'); + }); + + await t.test('inject without a propagator writes nothing', () => { + const { tracer } = createTracing(); + const otel = createClientTelemetry({ tracer }); + const packet = { type: 'call', id: '1' }; + otel.inject(packet); + assert.strictEqual(packet[TRACEPARENT], undefined); + }); + + await t.test('a propagator that throws is contained', () => { + const { tracer } = createTracing(); + const otel = createClientTelemetry({ + tracer, + propagation: { + inject() { + throw new Error('propagator exploded'); + }, + extract: () => undefined, + }, + }); + assert.doesNotThrow(() => otel.inject({ type: 'call' })); + }); + + await t.test('a tracer that throws before the callback still runs it', () => { + const otel = createClientTelemetry({ + tracer: { + startActiveSpan() { + throw new Error('tracer exploded'); + }, + }, + }); + let ran = false; + const result = otel.withSpan({ packet: {}, target: 'a/b' }, (handle) => { + ran = true; + assert.strictEqual(handle.span, null); + return 'value'; + }); + assert.strictEqual(ran, true); + assert.strictEqual(result, 'value'); + }); + + await t.test('an error thrown by the callback propagates untouched', () => { + const { tracer } = createTracing(); + const otel = createClientTelemetry({ tracer }); + const boom = new Error('caller exploded'); + assert.throws( + () => + otel.withSpan({ packet: {}, target: 'a/b' }, () => { + throw boom; + }), + (error) => error === boom, + ); + }); + + await t.test('startSpan fallback, and a startSpan that throws', () => { + const names = []; + const viaStartSpan = createClientTelemetry({ + tracer: { + startSpan(name) { + names.push(name); + return { end() {}, setAttribute() {} }; + }, + }, + }); + viaStartSpan.withSpan({ packet: {}, target: 'a/b' }, (handle) => { + assert.ok(handle.span); + viaStartSpan.endSpan(handle, { 'wrpc.status': 'ok' }); + }); + assert.deepStrictEqual(names, ['a/b']); + + const broken = createClientTelemetry({ + tracer: { + startSpan() { + throw new Error('startSpan exploded'); + }, + }, + }); + broken.withSpan({ packet: {}, target: 'a/b' }, (handle) => { + assert.strictEqual(handle.span, null); + assert.doesNotThrow(() => broken.recordError(handle, new Error('x'))); + assert.doesNotThrow(() => broken.endSpan(handle, { 'wrpc.status': 'error' })); + }); + + const useless = createClientTelemetry({ tracer: { name: 'nothing' } }); + useless.withSpan({ packet: {}, target: 'a/b' }, (handle) => assert.strictEqual(handle.span, null)); + }); + + await t.test('client metrics reach the meter', async () => { + const { meter, collect, provider } = createMetrics(); + t.after(() => provider.shutdown()); + const otel = createClientTelemetry({ meter }); + otel.recordCall('chat/send', 'ok', 9); + otel.recordReconnect('recovered', 3); + otel.recordConnection(1); + const names = (await collect()).map((metric) => metric.descriptor.name); + for (const name of ['rpc.client.duration', 'wrpc.client.reconnects', 'wrpc.client.connections']) { + assert.ok(names.includes(name), `${name} was exported`); + } + }); + + await t.test('broken and partial meters are contained', () => { + const explode = () => { + throw new Error('meter exploded'); + }; + const factoriesThrow = createClientTelemetry({ + meter: { createHistogram: explode, createCounter: explode, createUpDownCounter: explode }, + }); + assert.strictEqual(factoriesThrow.enabled, true); + assert.doesNotThrow(() => factoriesThrow.recordCall('a/b', 'ok', 1)); + assert.doesNotThrow(() => factoriesThrow.recordConnection(1)); + + const instrumentsThrow = createClientTelemetry({ + meter: { + createHistogram: () => ({ record: explode }), + createCounter: () => ({ add: explode }), + createUpDownCounter: () => ({ add: explode }), + }, + }); + assert.doesNotThrow(() => instrumentsThrow.recordCall('a/b', 'ok', 1)); + assert.doesNotThrow(() => instrumentsThrow.recordReconnect('exhausted', 5)); + assert.doesNotThrow(() => instrumentsThrow.recordConnection(-1)); + + const noGauge = createClientTelemetry({ + meter: { createHistogram: () => ({ record() {} }), createCounter: () => ({ add() {} }) }, + }); + assert.strictEqual(noGauge.enabled, true); + assert.doesNotThrow(() => noGauge.recordConnection(1)); + + const gaugeFactoryThrows = createClientTelemetry({ + meter: { + createHistogram: () => ({ record() {} }), + createCounter: () => ({ add() {} }), + createUpDownCounter: explode, + }, + }); + assert.doesNotThrow(() => gaugeFactoryThrows.recordConnection(1)); + }); + + await t.test('recordCall without an elapsed value records nothing', async () => { + const { meter, collect, provider } = createMetrics(); + t.after(() => provider.shutdown()); + const otel = createClientTelemetry({ meter }); + otel.recordCall('a/b', 'ok'); + const names = (await collect()).map((metric) => metric.descriptor.name); + assert.strictEqual(names.includes('rpc.client.duration'), false); + }); +}); + +test('unresolved names never mint metric series or spans', async (t) => { + const { Server, WrpcClient, defineRouter, procedure } = require('../index.js'); + const { InMemorySpanExporter, BasicTracerProvider, SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-base'); + const { + MeterProvider, + InMemoryMetricExporter, + PeriodicExportingMetricReader, + AggregationTemporality, + } = require('@opentelemetry/sdk-metrics'); + const spanExporter = new InMemorySpanExporter(); + const tracerProvider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(spanExporter)] }); + const metricExporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); + const reader = new PeriodicExportingMetricReader({ exporter: metricExporter, exportIntervalMillis: 60_000 }); + const meterProvider = new MeterProvider({ readers: [reader] }); + + const server = new Server({ + router: defineRouter({ probe: { echo: procedure({ access: 'public', handler: async (_c, a) => a }) } }), + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger: false, + timeouts: { bind: 100 }, + telemetry: { + tracer: tracerProvider.getTracer('@alexify/wrpc'), + meter: meterProvider.getMeter('@alexify/wrpc'), + }, + }); + await server.listen(); + t.after(() => server.close()); + const client = await WrpcClient.connect(`ws://127.0.0.1:${server.address().port}/api`, { + heartbeat: false, + reconnect: false, + }); + t.after(() => void client.close()); + + // A scanner spraying random names: every one must land in ONE bucket. + for (let i = 0; i < 30; i++) { + client.send({ type: 'call', id: `spray-${i}`, method: `guess_${i}/m${i}`, args: {} }); + client.send({ type: 'event', name: `guess_${i}/e${i}`, data: {} }); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + + await reader.forceFlush(); + const metrics = metricExporter.getMetrics().flatMap((rm) => rm.scopeMetrics.flatMap((sm) => sm.metrics)); + const duration = metrics.find((metric) => metric.descriptor.name === 'rpc.server.duration'); + const services = new Set( + (duration?.dataPoints ?? []).map((point) => point.attributes['rpc.service']).filter(Boolean), + ); + for (const service of services) { + assert.ok(!service.startsWith('guess_'), `peer-controlled name '${service}' minted a metric series`); + } + assert.ok(services.has('') || services.size <= 2, 'unresolved calls collapse into the bucket'); + + const sprayedSpans = spanExporter.getFinishedSpans().filter((span) => span.name.includes('guess_')); + assert.strictEqual(sprayedSpans.length, 0, 'an unresolved name must not become a span name'); +}); diff --git a/tests/transport.test.js b/tests/transport.test.js index 6db5962..e77670c 100644 --- a/tests/transport.test.js +++ b/tests/transport.test.js @@ -3,173 +3,315 @@ const { test } = require('node:test'); const assert = require('node:assert'); -const { ServerTransport, buildHeaders } = require('../src/transport.js'); +const { ServerTransport, buildHeaders, parseCookies } = require('../src/transport.js'); +const { Emitter } = require('../src/utils.js'); const { http: ServerHttpTransport, ws: ServerWsTransport, event: ServerEventTransport } = ServerTransport.transport; -const fakeSocket = () => ({ remoteAddress: '127.0.0.1', destroy: () => {} }); +class FakeConnection extends Emitter { + constructor() { + super(); + this.remoteAddress = '10.0.0.1'; + this.sent = []; + this.sendResult = true; + this.terminated = false; + } -const fakeReq = (overrides = {}) => ({ - socket: fakeSocket(), - method: 'POST', - headers: {}, - on: () => {}, - ...overrides, -}); + send(data) { + this.sent.push(data); + return this.sendResult; + } + + terminate() { + this.terminated = true; + } +} -const fakeRes = () => { - const res = { - writableEnded: false, - headersSent: false, - headers: null, - statusCode: null, - ended: false, - cookies: [], - writeHead(code, headers) { - res.statusCode = code; - res.headers = headers; - res.headersSent = true; - }, - end(buf) { - res.ended = true; - res.body = buf; - }, - setHeader(name, value) { - if (name === 'Set-Cookie') res.cookies.push(value); - }, +const fakeCall = (overrides = {}) => { + const responses = []; + return { + responses, + method: 'POST', + url: '/api', + headers: {}, + body: null, + remoteAddress: '127.0.0.1', + respond: (response) => responses.push(response), + ...overrides, }; - return res; }; -test('buildHeaders', () => { - const defaults = buildHeaders(); - assert.strictEqual(defaults['Access-Control-Allow-Origin'], '*'); +test('buildHeaders', async (t) => { + await t.test('defaults to a wildcard origin without a cors option', () => { + const defaults = buildHeaders(); + assert.strictEqual(defaults['Access-Control-Allow-Origin'], '*'); + assert.strictEqual(defaults['Access-Control-Allow-Methods'], 'POST, GET, OPTIONS'); + // The SSE transport's own headers are in the default: neither is + // CORS-safelisted, so leaving them out fails the preflight. + assert.strictEqual( + defaults['Access-Control-Allow-Headers'], + 'Content-Type, x-wrpc-channel, last-event-id, x-wrpc-meta', + ); + assert.strictEqual(defaults['Vary'], undefined); + assert.strictEqual(defaults['Content-Type'], 'application/json'); + + const withoutOrigins = buildHeaders({}, 'https://example.com'); + assert.strictEqual(withoutOrigins['Access-Control-Allow-Origin'], '*'); + }); + + await t.test('origins list: allowed origin is echoed with Vary: Origin', () => { + const cors = { origins: ['https://a.com', 'https://b.com'] }; + const allowed = buildHeaders(cors, 'https://a.com'); + assert.strictEqual(allowed['Access-Control-Allow-Origin'], 'https://a.com'); + assert.strictEqual(allowed['Vary'], 'Origin'); + assert.strictEqual(allowed['Access-Control-Allow-Credentials'], undefined); + }); - const withoutOrigin = buildHeaders({}); - assert.strictEqual(withoutOrigin['Access-Control-Allow-Origin'], '*'); + await t.test('origins list: disallowed origin gets no ACAO header', () => { + const cors = { origins: ['https://a.com'] }; + const denied = buildHeaders(cors, 'https://evil.com'); + assert.strictEqual(denied['Access-Control-Allow-Origin'], undefined); + assert.strictEqual(denied['Vary'], 'Origin'); + const missing = buildHeaders(cors, undefined); + assert.strictEqual(missing['Access-Control-Allow-Origin'], undefined); + }); - const withCors = buildHeaders({ origin: 'https://example.com' }); - assert.strictEqual(withCors['Access-Control-Allow-Origin'], 'https://example.com'); + await t.test('origins as a predicate function', () => { + const cors = { origins: (origin) => origin.endsWith('.good.com') }; + assert.strictEqual( + buildHeaders(cors, 'https://app.good.com')['Access-Control-Allow-Origin'], + 'https://app.good.com', + ); + assert.strictEqual(buildHeaders(cors, 'https://app.bad.com')['Access-Control-Allow-Origin'], undefined); + assert.strictEqual(buildHeaders(cors, undefined)['Access-Control-Allow-Origin'], undefined); + }); + + await t.test('credentials are echoed only for allowed origins', () => { + const cors = { origins: ['https://a.com'], credentials: true }; + const allowed = buildHeaders(cors, 'https://a.com'); + assert.strictEqual(allowed['Access-Control-Allow-Credentials'], 'true'); + const denied = buildHeaders(cors, 'https://evil.com'); + assert.strictEqual(denied['Access-Control-Allow-Credentials'], undefined); + }); + + await t.test('methods and headers overrides', () => { + const headers = buildHeaders({ methods: 'PUT, PATCH', headers: 'Content-Type, X-Token' }); + assert.strictEqual(headers['Access-Control-Allow-Methods'], 'PUT, PATCH'); + assert.strictEqual(headers['Access-Control-Allow-Headers'], 'Content-Type, X-Token'); + }); }); test('ServerHttpTransport', async (t) => { - await t.test('responds to OPTIONS requests immediately', () => { - const res = fakeRes(); - const req = fakeReq({ method: 'OPTIONS' }); - const transport = new ServerHttpTransport(req, res); - assert.strictEqual(res.statusCode, 200); - assert.strictEqual(res.ended, true); - assert.ok(transport); + await t.test('write responds exactly once and emits close after the write', () => { + const call = fakeCall(); + const transport = new ServerHttpTransport(call); + const events = []; + transport.on('close', () => events.push('close')); + + assert.strictEqual(transport.responded, false); + transport.send({ ok: true }); + assert.strictEqual(transport.responded, true); + assert.deepStrictEqual(events, ['close']); + + assert.strictEqual(call.responses.length, 1); + const [{ status, headers, body }] = call.responses; + assert.strictEqual(status, 200); + assert.strictEqual(headers['Content-Type'], 'application/json'); + assert.strictEqual(headers['Content-Length'], body.length); + assert.deepStrictEqual(JSON.parse(body.toString()), { ok: true }); + + transport.send({ tooLate: true }); + assert.strictEqual(call.responses.length, 1); }); - await t.test('options() is a no-op once headers are already sent', () => { - const res = fakeRes(); - res.headersSent = true; - const req = fakeReq({ method: 'OPTIONS' }); - const transport = new ServerHttpTransport(req, res); - assert.strictEqual(res.statusCode, null); - transport.options(); - assert.strictEqual(res.statusCode, null); + await t.test('write passes an existing Buffer through unchanged', () => { + const call = fakeCall(); + const transport = new ServerHttpTransport(call); + const buf = Buffer.from('already a buffer'); + transport.write(buf); + assert.strictEqual(call.responses[0].body, buf); + }); + + await t.test('custom headers from options are used for the response', () => { + const call = fakeCall(); + const transport = new ServerHttpTransport(call, { headers: { 'X-Custom': 'yes' } }); + transport.write('data'); + const [{ headers }] = call.responses; + assert.strictEqual(headers['X-Custom'], 'yes'); + }); + + await t.test('error() sends a callback packet with the HTTP status text', () => { + const call = fakeCall(); + const transport = new ServerHttpTransport(call); + transport.error(500); + const [{ status, body }] = call.responses; + assert.strictEqual(status, 500); + const packet = JSON.parse(body.toString()); + assert.deepStrictEqual(packet, { + type: 'callback', + id: '', + error: { message: 'Internal Server Error', code: 500 }, + }); + }); + + await t.test('error() falls back to "Unknown error" for an unknown status code', () => { + const call = fakeCall(); + const transport = new ServerHttpTransport(call); + transport.error(999); + const packet = JSON.parse(call.responses[0].body.toString()); + assert.strictEqual(packet.error.message, 'Unknown error'); + }); + + await t.test('error() prefers the given error message and keeps the packet id', () => { + const call = fakeCall(); + const transport = new ServerHttpTransport(call); + transport.error(422, { id: '42', error: new Error('domain failure') }); + const packet = JSON.parse(call.responses[0].body.toString()); + assert.deepStrictEqual(packet, { type: 'callback', id: '42', error: { message: 'domain failure', code: 422 } }); + }); + + await t.test('error() carries details on a 4xx', () => { + const call = fakeCall(); + const transport = new ServerHttpTransport(call); + const error = new Error('invalid'); + error.details = { issues: [{ message: 'name required', path: ['name'] }] }; + transport.error(400, { id: '1', error }); + const packet = JSON.parse(call.responses[0].body.toString()); + assert.deepStrictEqual(packet.error, { + message: 'invalid', + code: 400, + details: { issues: [{ message: 'name required', path: ['name'] }] }, + }); + }); + + await t.test('error() strips details from a 5xx', () => { + const call = fakeCall(); + const transport = new ServerHttpTransport(call); + const error = new Error('secret internals'); + error.details = { query: 'SELECT *' }; + transport.error(500, { id: '1', error }); + const packet = JSON.parse(call.responses[0].body.toString()); + assert.strictEqual(packet.error.message, 'Internal Server Error'); + assert.strictEqual('details' in packet.error, false); + }); + + await t.test('error() keeps 5xx details when the error opts in with expose', () => { + const call = fakeCall(); + const transport = new ServerHttpTransport(call); + const error = new Error('queue full'); + error.expose = true; + error.details = { waiting: 100 }; + transport.error(503, { id: '1', error }); + const packet = JSON.parse(call.responses[0].body.toString()); + assert.deepStrictEqual(packet.error, { message: 'queue full', code: 503, details: { waiting: 100 } }); + }); + + await t.test('error() omits the details key entirely when there are none', () => { + const call = fakeCall(); + const transport = new ServerHttpTransport(call); + transport.error(404, { id: '1', error: new Error('nope') }); + const packet = JSON.parse(call.responses[0].body.toString()); + assert.strictEqual('details' in packet.error, false); }); await t.test('getCookies returns {} when there is no cookie header', () => { - const transport = new ServerHttpTransport(fakeReq(), fakeRes()); + const transport = new ServerHttpTransport(fakeCall()); assert.deepStrictEqual(transport.getCookies(), {}); }); await t.test('getCookies parses the cookie header', () => { - const req = fakeReq({ headers: { cookie: 'token=abc123; other=value' } }); - const transport = new ServerHttpTransport(req, fakeRes()); + const call = fakeCall({ headers: { cookie: 'token=abc123; other=value' } }); + const transport = new ServerHttpTransport(call); assert.deepStrictEqual(transport.getCookies(), { token: 'abc123', other: 'value' }); }); await t.test('getCookies treats a valueless cookie item as an empty string', () => { - const req = fakeReq({ headers: { cookie: 'flag' } }); - const transport = new ServerHttpTransport(req, fakeRes()); + const call = fakeCall({ headers: { cookie: 'flag' } }); + const transport = new ServerHttpTransport(call); assert.deepStrictEqual(transport.getCookies(), { flag: '' }); }); - await t.test('error() falls back to the HTTP status text when no Error is given', () => { - const transport = new ServerHttpTransport(fakeReq(), fakeRes()); - let sent = null; - transport.send = (obj) => (sent = obj); - transport.error(500); - assert.strictEqual(sent.error.message, 'Internal Server Error'); + await t.test('sendSessionCookie accumulates Set-Cookie headers for the response', () => { + const call = fakeCall(); + const transport = new ServerHttpTransport(call); + transport.sendSessionCookie('token=abc; Path=/; HttpOnly'); + transport.sendSessionCookie('theme=dark; Path=/'); + transport.send({ ok: true }); + const [{ headers }] = call.responses; + assert.deepStrictEqual(headers['Set-Cookie'], ['token=abc; Path=/; HttpOnly', 'theme=dark; Path=/']); }); - await t.test('write() is a no-op once the response has already ended', () => { - const res = fakeRes(); - res.writableEnded = true; - const transport = new ServerHttpTransport(fakeReq(), res); - transport.write('too late'); - assert.strictEqual(res.ended, false); + await t.test('no Set-Cookie header when no cookie was sent', () => { + const call = fakeCall(); + const transport = new ServerHttpTransport(call); + transport.send({ ok: true }); + assert.strictEqual('Set-Cookie' in call.responses[0].headers, false); }); - await t.test('write() passes an existing Buffer through unchanged', () => { - const res = fakeRes(); - const transport = new ServerHttpTransport(fakeReq(), res); - const buf = Buffer.from('already a buffer'); - transport.write(buf); - assert.strictEqual(res.body, buf); + await t.test('close() responds with a 503 error packet', () => { + const call = fakeCall(); + const transport = new ServerHttpTransport(call); + transport.close(); + const [{ status, body }] = call.responses; + assert.strictEqual(status, 503); + const packet = JSON.parse(body.toString()); + assert.strictEqual(packet.error.code, 503); + assert.strictEqual(packet.error.message, 'Service Unavailable'); }); - await t.test('error() falls back to "Unknown error" for a status code with no known text', () => { - const transport = new ServerHttpTransport(fakeReq(), fakeRes()); - let sent = null; - transport.send = (obj) => (sent = obj); - transport.error(999); - assert.strictEqual(sent.error.message, 'Unknown error'); + await t.test('source comes from the call remoteAddress', () => { + assert.strictEqual(new ServerHttpTransport(fakeCall()).source, '127.0.0.1'); + assert.strictEqual(new ServerHttpTransport(fakeCall({ remoteAddress: undefined })).source, ''); }); +}); - await t.test('sendSessionCookie sets a Set-Cookie header scoped to the request host', () => { - const req = fakeReq({ headers: { host: 'localhost:8000' } }); - const res = fakeRes(); - const transport = new ServerHttpTransport(req, res); - transport.sendSessionCookie('my-token'); - assert.strictEqual(res.cookies.length, 1); - assert.match(res.cookies[0], /^token=my-token;.*Domain=localhost$/); +test('ServerWsTransport', async (t) => { + await t.test('source prefers meta.remoteAddress, falls back to the connection', () => { + const connection = new FakeConnection(); + assert.strictEqual(new ServerWsTransport(connection, { remoteAddress: '192.168.0.7' }).source, '192.168.0.7'); + assert.strictEqual(new ServerWsTransport(new FakeConnection()).source, '10.0.0.1'); + const anonymous = new FakeConnection(); + anonymous.remoteAddress = undefined; + assert.strictEqual(new ServerWsTransport(anonymous).source, ''); }); - await t.test('sendSessionCookie falls back when the host header is missing', () => { - const req = fakeReq({ headers: {} }); - const res = fakeRes(); - const transport = new ServerHttpTransport(req, res); - transport.sendSessionCookie('my-token'); - assert.match(res.cookies[0], /Domain=no-host-name-in-http-headers$/); + await t.test('forwards close and drain from the connection', async () => { + const connection = new FakeConnection(); + const transport = new ServerWsTransport(connection, {}); + const events = []; + transport.on('close', () => events.push('close')); + transport.on('drain', () => events.push('drain')); + await connection.emit('drain'); + await connection.emit('close'); + assert.deepStrictEqual(events, ['drain', 'close']); }); - await t.test('removeSessionCookie clears the session cookie', () => { - const req = fakeReq({ headers: { host: 'localhost' } }); - const res = fakeRes(); - const transport = new ServerHttpTransport(req, res); - transport.removeSessionCookie(); - assert.match(res.cookies[0], /^token=deleted;.*Domain=localhost$/); + await t.test('write() passes the connection backpressure boolean through', () => { + const connection = new FakeConnection(); + const transport = new ServerWsTransport(connection, {}); + assert.strictEqual(transport.write('fits'), true); + connection.sendResult = false; + assert.strictEqual(transport.write('stalls'), false); + assert.deepStrictEqual(connection.sent, ['fits', 'stalls']); }); - await t.test('close sends a 503 and destroys the socket', () => { - const res = fakeRes(); - let destroyed = false; - const req = fakeReq({ socket: { ...fakeSocket(), destroy: () => (destroyed = true) } }); - const transport = new ServerHttpTransport(req, res); - transport.close(); - assert.strictEqual(res.statusCode, 503); - assert.strictEqual(destroyed, true); + await t.test('write() converts typed arrays to Buffers, keeps strings and Buffers', () => { + const connection = new FakeConnection(); + const transport = new ServerWsTransport(connection, {}); + const buf = Buffer.from('as-is'); + transport.write(buf); + assert.strictEqual(connection.sent[0], buf); + transport.write(new Uint8Array([1, 2, 3])); + assert.ok(Buffer.isBuffer(connection.sent[1])); + assert.deepStrictEqual(connection.sent[1], Buffer.from([1, 2, 3])); }); -}); -test('ServerWsTransport forwards close from the connection', () => { - const listeners = {}; - const connection = { - on: (event, fn) => (listeners[event] = fn), - send: () => {}, - terminate: () => {}, - }; - const req = fakeReq(); - const transport = new ServerWsTransport(req, connection); - let closed = false; - transport.on('close', () => (closed = true)); - listeners.close(); - assert.strictEqual(closed, true); + await t.test('close() terminates the connection', () => { + const connection = new FakeConnection(); + const transport = new ServerWsTransport(connection, {}); + transport.close(); + assert.strictEqual(connection.terminated, true); + }); }); test('ServerEventTransport', async (t) => { @@ -199,3 +341,48 @@ test('ServerEventTransport', async (t) => { assert.strictEqual(closed, true); }); }); + +test('parseCookies', async (t) => { + await t.test('splits pairs on the first "=" only — values may contain "="', () => { + const cookies = parseCookies('token=YWJjZGVm==; other=1'); + assert.strictEqual(cookies.token, 'YWJjZGVm=='); + assert.strictEqual(cookies.other, '1'); + const jwt = parseCookies('token=a.b.c=; x=y=z'); + assert.strictEqual(jwt.token, 'a.b.c='); + assert.strictEqual(jwt.x, 'y=z'); + }); + + await t.test('tolerates malformed items', () => { + const cookies = parseCookies('lonely; =value; spaced = padded '); + assert.strictEqual(cookies.lonely, ''); + assert.strictEqual(cookies.spaced, 'padded'); + }); +}); + +test('buildHeaders: cors.metaHeaders names the per-key meta headers CORS cannot wildcard', () => { + const { buildHeaders } = require('../src/transport.js'); + const allow = (cors) => buildHeaders(cors, 'http://app.example')['Access-Control-Allow-Headers']; + + // Appended to the default, never replacing it: dropping x-wrpc-channel + // here would silently disable cross-origin SSE. + assert.strictEqual( + allow({ metaHeaders: ['locale'] }), + 'Content-Type, x-wrpc-channel, last-event-id, x-wrpc-meta, x-wrpc-meta-locale', + ); + + // Normalized with the SAME rule the client uses, so a config written in + // camelCase still grants the name that will actually arrive. + assert.strictEqual(allow({ metaHeaders: ['userId'] }).endsWith(', x-wrpc-meta-user-id'), true); + + // An explicit list composes with metaHeaders rather than fighting it. + assert.strictEqual( + allow({ headers: 'Content-Type', metaHeaders: ['userId', 'traceId'] }), + 'Content-Type, x-wrpc-meta-user-id, x-wrpc-meta-trace-id', + ); + + // The array form of `headers` is the same value, spelled as a list. + assert.strictEqual(allow({ headers: ['Content-Type', 'X-Token'] }), 'Content-Type, X-Token'); + + // Absent or empty changes nothing. + assert.strictEqual(allow({ metaHeaders: [] }), 'Content-Type, x-wrpc-channel, last-event-id, x-wrpc-meta'); +}); diff --git a/tests/utils.test.js b/tests/utils.test.js index ee3adc3..f829925 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.js @@ -3,7 +3,7 @@ const { test } = require('node:test'); const assert = require('node:assert'); -const { Emitter, jsonParse } = require('../src/utils.js'); +const { Emitter, jsonParse, Semaphore, toKebab } = require('../src/utils.js'); test('Emitter', async (t) => { await t.test('emit resolves without listeners for non-error events', async () => { @@ -53,10 +53,19 @@ test('Emitter', async (t) => { assert.throws(() => emitter.on('data', listener), /Duplicate listeners detected/); }); - await t.test('addListener throws past maxListeners', () => { + await t.test('addListener warns past maxListeners instead of throwing', () => { const emitter = new Emitter({ maxListeners: 1 }); emitter.on('data', () => {}); - assert.throws(() => emitter.on('data', () => {}), /MaxListenersExceededWarning/); + const warnings = []; + const originalWarn = globalThis.console.warn; + globalThis.console.warn = (message) => warnings.push(message); + try { + assert.doesNotThrow(() => emitter.on('data', () => {})); + } finally { + globalThis.console.warn = originalWarn; + } + assert.strictEqual(emitter.listenerCount('data'), 2); + assert.ok(warnings.some((message) => /MaxListenersExceededWarning/.test(message))); }); await t.test('off removes a specific listener', async () => { @@ -133,6 +142,107 @@ test('Emitter', async (t) => { emitter.on('close', () => {}); assert.deepStrictEqual(emitter.eventNames().sort(), ['close', 'data']); }); + + // The contract every emit() shape must preserve, characterized here because + // `void emitter.emit('close')` is the house style: a listener's SYNCHRONOUS + // throw has to surface as a rejection, never as a throw escaping into a + // socket handler. Written against the pre-fast-path implementation. + await t.test('a synchronously throwing listener rejects instead of throwing', async () => { + const emitter = new Emitter(); + emitter.on('boom', () => { + throw new Error('sync boom'); + }); + let escaped = null; + let promise = null; + try { + promise = emitter.emit('boom', 1); + } catch (error) { + escaped = error; + } + assert.strictEqual(escaped, null, 'emit must not throw synchronously'); + await assert.rejects(promise, /sync boom/); + }); + + await t.test('a throwing listener does not stop the listeners after it', async () => { + const emitter = new Emitter(); + const ran = []; + emitter.on('boom', () => { + ran.push('a'); + throw new Error('sync boom'); + }); + emitter.on('boom', () => ran.push('b')); + await assert.rejects(emitter.emit('boom'), /sync boom/); + assert.deepStrictEqual(ran, ['a', 'b']); + }); + + await t.test('an async listener rejection surfaces on the emit promise', async () => { + const emitter = new Emitter(); + emitter.on('boom', async () => { + throw new Error('async boom'); + }); + await assert.rejects(emitter.emit('boom'), /async boom/); + }); + + await t.test('emit resolves to undefined whatever a listener returns', async () => { + const emitter = new Emitter(); + emitter.on('value', () => 42); + assert.strictEqual(await emitter.emit('value'), undefined); + const asyncEmitter = new Emitter(); + asyncEmitter.on('value', async () => 42); + assert.strictEqual(await asyncEmitter.emit('value'), undefined); + }); + + await t.test('emit awaits an async listener before resolving', async () => { + const emitter = new Emitter(); + let done = false; + emitter.on('slow', async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + done = true; + }); + await emitter.emit('slow'); + assert.strictEqual(done, true); + }); + + // off() splices without deleting an emptied record, so an event with a live + // entry and zero listeners is reachable and must still resolve. + await t.test('emit on an event emptied by off resolves', async () => { + const emitter = new Emitter(); + const listener = () => {}; + emitter.on('data', listener); + emitter.off('data', listener); + assert.strictEqual(emitter.listenerCount('data'), 0); + await assert.doesNotReject(emitter.emit('data', 1)); + }); + + await t.test('a lone once listener fires exactly once and clears the event', async () => { + const emitter = new Emitter(); + let calls = 0; + emitter.once('tick', () => calls++); + await emitter.emit('tick'); + await emitter.emit('tick'); + assert.strictEqual(calls, 1); + assert.deepStrictEqual(emitter.eventNames(), []); + }); + + await t.test('a once listener that throws still rejects and is still removed', async () => { + const emitter = new Emitter(); + let calls = 0; + emitter.once('boom', () => { + calls++; + throw new Error('once boom'); + }); + await assert.rejects(emitter.emit('boom'), /once boom/); + await emitter.emit('boom'); + assert.strictEqual(calls, 1); + }); + + await t.test('an error event with a listener does not throw', async () => { + const emitter = new Emitter(); + const seen = []; + emitter.on('error', (error) => seen.push(error.message)); + await emitter.emit('error', new Error('handled')); + assert.deepStrictEqual(seen, ['handled']); + }); }); test('jsonParse', () => { @@ -140,3 +250,88 @@ test('jsonParse', () => { assert.strictEqual(jsonParse(null), null); assert.strictEqual(jsonParse('not json'), null); }); + +test('Semaphore', async (t) => { + await t.test('grants slots up to concurrency without waiting', async () => { + const semaphore = new Semaphore({ concurrency: 2 }); + await semaphore.enter(); + await semaphore.enter(); + assert.strictEqual(semaphore.empty, false); + semaphore.leave(); + semaphore.leave(); + assert.strictEqual(semaphore.empty, true); + }); + + await t.test('queues waiters and wakes them on leave in FIFO order', async () => { + const semaphore = new Semaphore({ concurrency: 1, size: 2 }); + await semaphore.enter(); + const order = []; + const second = semaphore.enter().then(() => order.push('second')); + const third = semaphore.enter().then(() => order.push('third')); + semaphore.leave(); + await second; + semaphore.leave(); + await third; + assert.deepStrictEqual(order, ['second', 'third']); + semaphore.leave(); + }); + + await t.test('rejects when the queue is full', async () => { + const semaphore = new Semaphore({ concurrency: 1, size: 0 }); + await semaphore.enter(); + await assert.rejects(semaphore.enter(), /Semaphore queue is full/); + semaphore.leave(); + }); + + await t.test('rejects a queued waiter after the timeout', async () => { + const semaphore = new Semaphore({ concurrency: 1, size: 1, timeout: 20 }); + await semaphore.enter(); + await assert.rejects(semaphore.enter(), /Semaphore timeout/); + semaphore.leave(); + // the timed-out waiter must not have been left in the queue + await semaphore.enter(); + semaphore.leave(); + }); + + await t.test('leave without waiters restores capacity, never above concurrency', async () => { + const semaphore = new Semaphore({ concurrency: 1 }); + semaphore.leave(); + semaphore.leave(); + await semaphore.enter(); + assert.strictEqual(semaphore.empty, false); + semaphore.leave(); + assert.strictEqual(semaphore.empty, true); + }); +}); + +test('toKebab', async (t) => { + // The contract table. Both ends of the wire run this exact function, so a + // change here is a change to what `schema.headers` must be written against. + const cases = [ + ['userId', 'user-id'], + ['userID', 'user-id'], // collides with userId ON PURPOSE — documented, last write wins + ['user-id', 'user-id'], // already kebab + ['user_id', 'user_id'], // snake left alone, deliberately + ['XMLHttpRequest', 'xml-http-request'], // the acronym pass earns its keep here + ['HTTPServer', 'http-server'], + ['ABC', 'abc'], + ['a1B2', 'a1-b2'], // a digit is a boundary left-hand side + ['x-app-version', 'x-app-version'], + ['authorization', 'authorization'], + ['', ''], + ]; + await t.test('the contract table', () => { + for (const [input, expected] of cases) assert.strictEqual(toKebab(input), expected, input); + }); + + await t.test('idempotent: the server may normalize what the client already did', () => { + for (const [input] of cases) { + const once = toKebab(input); + assert.strictEqual(toKebab(once), once, input); + } + }); + + await t.test('output is always lowercase, so a second pass can never split again', () => { + for (const [input] of cases) assert.strictEqual(toKebab(input), toKebab(input).toLowerCase(), input); + }); +}); diff --git a/tests/uws.test-d.ts b/tests/uws.test-d.ts new file mode 100644 index 0000000..de0f846 --- /dev/null +++ b/tests/uws.test-d.ts @@ -0,0 +1,115 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import * as uws from '../uws.js'; +import type { + UwsApp, + UwsAttachOptions, + UwsEngine, + UwsEngineConnectionSource, + UwsEngineOptions, + UwsModule, + UwsSocket, + UpgradeRequest, +} from '../uws.js'; +import type { Engine, WrpcSocket } from '../engine.js'; + +declare const uwsModule: UwsModule; +declare const uwsApp: UwsApp; + +const engine = uws.createUwsEngine({ uws: uwsModule }); +expectType(engine); +// A uws engine is a plain Engine as far as the Server shell is concerned +expectAssignable(engine); + +// Identity: the standalone kind of the engine port +expectType<'uws'>(engine.name); +expectType(engine.standalone); +expectType(engine.app); + +// Capabilities are plain booleans (values are decided at runtime: uws has no +// ping/pause, and deflate only when `compression` is passed) +expectType(engine.capabilities.backpressure); +expectType(engine.capabilities.ping); +expectType(engine.capabilities.deflate); +expectType(engine.capabilities.cork); +expectType(engine.capabilities.pause); + +// Engine options: module injection, an existing app, uws tuning knobs +expectAssignable({ uws: uwsModule }); +expectAssignable({ app: uwsApp }); +expectAssignable({ + uws: uwsModule, + ssl: { key_file_name: 'key.pem', cert_file_name: 'cert.pem' }, + idleTimeout: 60, + maxPayloadLength: 1024, + maxBackpressure: 2048, + closeOnBackpressureLimit: true, + maxLifetime: 0, + compression: 1, + sendPingsAutomatically: false, + maxBodySize: 4096, +}); +expectAssignable({ uws: uwsModule, ssl: null, compression: null }); +expectError(uws.createUwsEngine({ uws: 5 })); +expectError(uws.createUwsEngine({ app: 'app' })); +expectError({ uws: uwsModule, idleTimeout: '60' }); + +// attach(): no `server` — this engine owns the network stack +const source = engine.attach(); +expectType(source); +expectType(engine.attach({ path: '/ws' })); +expectAssignable({ + path: '/*', + protocols: ['wrpc'], + verifyClient: (info) => { + expectType(info.req); + expectType(info.socket); + expectType(info.head); + return info.req.headers.origin === 'https://app.example'; + }, + handleProtocols: (offered, req) => { + expectType>(offered); + expectType(req); + return offered[0] ?? false; + }, + onHttpCall: (call) => { + expectType(call.method); + expectType(call.url); + expectType>(call.headers); + }, +}); +expectError({ server: uwsApp }); + +// The 'connection' listener gets a UwsSocket plus the synthesized request +source.on('connection', (socket, req) => { + expectType(socket); + expectType(req); + expectType(req.method); + expectType(req.url); + expectType>(req.headers); + expectType(req.socket.remoteAddress); + expectType(socket.send('x')); + expectType(socket.send(Buffer.alloc(1))); + expectType(socket.bufferedAmount); + expectType(socket.closed); + socket.close(1000, 'done'); + socket.terminate(); +}); +source.on('close', () => {}); + +// A UwsSocket satisfies the engine port's socket contract +declare const socket: UwsSocket; +expectAssignable(socket); +expectType(socket.markClosed(1006, 'gone')); +expectType(uws.UwsSocket); +expectAssignable(new uws.UwsSocket({}, { remoteAddress: '127.0.0.1', protocol: 'wrpc' })); + +// listen(): standalone engines own the listener and report the bound address +expectType>(engine.listen()); +expectType>(engine.listen({ host: '127.0.0.1', port: 0 })); +expectType(engine.close()); +expectType(engine.close({ code: 1001, reason: 'bye' })); + +// uws send() status codes +expectType<0>(uws.BACKPRESSURE); +expectType<1>(uws.SUCCESS); +expectType<2>(uws.DROPPED); diff --git a/tests/websocket/backpressure.test.js b/tests/websocket/backpressure.test.js new file mode 100644 index 0000000..88af5ec --- /dev/null +++ b/tests/websocket/backpressure.test.js @@ -0,0 +1,229 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); + +const { Connection, FrameParser } = require('#ws'); +const { MockSocket } = require('./mockSocket.js'); +const { ServerTransport } = require('../../src/transport.js'); +const { WrpcWritable } = require('../../src/streams.js'); + +const ServerWsTransport = ServerTransport.transport.ws; + +const wsTransport = (connection) => new ServerWsTransport(connection, { remoteAddress: '127.0.0.1' }); + +test('Connection: bufferedAmount mirrors the socket writable buffer', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0)); + assert.strictEqual(conn.bufferedAmount, 0); + socket.writableLength = 1234; + assert.strictEqual(conn.bufferedAmount, 1234); + conn.terminate(); +}); + +test('Connection: data sends report socket pressure and drain restores flow', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0)); + + assert.strictEqual(conn.sendText('fits'), true); + + socket.writeResult = false; + assert.strictEqual(conn.sendText('overflows'), false); + assert.strictEqual(conn.sendBinary(Buffer.from('overflows')), false); + + let drained = 0; + conn.on('drain', () => drained++); + socket.drain(); + assert.strictEqual(drained, 1); + + // drain is only re-emitted after another failed write + socket.drain(); + assert.strictEqual(drained, 1); + + assert.strictEqual(conn.sendText('fits again'), true); + conn.terminate(); +}); + +test('Connection: maxBackpressure cap terminates the connection', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { maxBackpressure: 100 }); + const errors = []; + conn.on('error', (error) => errors.push(error)); + + socket.writableLength = 101; + assert.strictEqual(conn.sendText('too much'), false); + assert.strictEqual(errors.length, 1); + assert.match(errors[0].message, /Backpressure limit exceeded/); + assert.strictEqual(socket.destroyed, true); +}); + +test('Connection: pause/resume delegate to the socket', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0)); + conn.pause(); + assert.strictEqual(socket.paused, true); + conn.resume(); + assert.strictEqual(socket.paused, false); + conn.terminate(); +}); + +test('ServerWsTransport: write returns connection acceptance and re-emits drain', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0)); + const transport = wsTransport(conn); + + assert.strictEqual(transport.write('data'), true); + socket.writeResult = false; + assert.strictEqual(transport.write('data'), false); + + let drained = 0; + transport.on('drain', () => drained++); + socket.drain(); + assert.strictEqual(drained, 1); + conn.terminate(); +}); + +test('WrpcWritable: write reports transport pressure and emits drain once released', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0)); + const transport = wsTransport(conn); + const writable = new WrpcWritable('stream-1', 'name', 100, transport); + + assert.strictEqual(writable.write(new Uint8Array([1, 2, 3])), true); + + socket.writeResult = false; + assert.strictEqual(writable.write(new Uint8Array([4, 5, 6])), false); + + let drained = 0; + writable.on('drain', () => drained++); + socket.drain(); + assert.strictEqual(drained, 1); + + assert.strictEqual(writable.write(new Uint8Array([7])), true); + conn.terminate(); +}); + +test('WrpcWritable: eleven concurrently stalled streams on one transport do not throw', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0)); + const transport = wsTransport(conn); + + socket.writeResult = false; + const writables = []; + for (let i = 0; i < 12; i++) { + const writable = new WrpcWritable(`stream-${i}`, 'name', 100, transport); + writables.push(writable); + // Regression: the 11th stalled stream used to hit the transport + // Emitter's default maxListeners cap of 10 and throw synchronously. + assert.doesNotThrow(() => { + assert.strictEqual(writable.write(new Uint8Array([i])), false); + }); + } + + let drained = 0; + for (const writable of writables) writable.on('drain', () => drained++); + socket.drain(); + assert.strictEqual(drained, 12); + conn.terminate(); +}); + +test('WrpcWritable: transport close releases a stalled stream instead of hanging', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0)); + const transport = wsTransport(conn); + const writable = new WrpcWritable('stream-1', 'name', 100, transport); + + socket.writeResult = false; + assert.strictEqual(writable.write(new Uint8Array([1])), false); + + const events = []; + writable.on('drain', () => events.push('drain')); + writable.on('close', () => events.push('close')); + transport.emit('close'); // the peer went away mid-stall + + assert.deepStrictEqual(events, ['drain', 'close']); + assert.strictEqual(writable.closed, true); + // After close, writes are refused and never arm another drain wait + socket.writeResult = true; + assert.strictEqual(writable.write(new Uint8Array([2])), false); + conn.terminate(); +}); + +test('Connection: fragmented send splits payload at fragmentThreshold', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { fragmentThreshold: 4 }); + + const message = '0123456789'; // 10 bytes -> 4 + 4 + 2 + assert.strictEqual(conn.sendText(message), true); + assert.strictEqual(socket.writtenData.length, 3); + + const frames = socket.writtenData.map((buf) => FrameParser.parse(buf).value.frame); + assert.deepStrictEqual( + frames.map((f) => ({ fin: f.fin, opcode: f.opcode })), + [ + { fin: false, opcode: 0x01 }, + { fin: false, opcode: 0x00 }, + { fin: true, opcode: 0x00 }, + ], + ); + const payload = Buffer.concat(frames.map((f) => f.payload)).toString(); + assert.strictEqual(payload, message); + conn.terminate(); +}); + +test('Connection: payload at the threshold stays a single frame', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { fragmentThreshold: 10 }); + conn.sendText('0123456789'); + assert.strictEqual(socket.writtenData.length, 1); + const frame = FrameParser.parse(socket.writtenData[0]).value.frame; + assert.strictEqual(frame.fin, true); + assert.strictEqual(frame.opcode, 0x01); + conn.terminate(); +}); + +test('Connection: emits ping event for inbound pings and still auto-pongs', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0)); + const pings = []; + conn.on('ping', (payload) => pings.push(Buffer.from(payload))); + + const { Frame } = require('#ws'); + const ping = Frame.ping(Buffer.from('hb')); + ping.maskPayload(); + socket.emit('data', ping.toBuffer()); + + assert.strictEqual(pings.length, 1); + assert.strictEqual(pings[0].toString(), 'hb'); + const pong = FrameParser.parse(socket.writtenData.at(-1)).value.frame; + assert.strictEqual(pong.opcode, 0x0a); + assert.strictEqual(pong.payload.toString(), 'hb'); + conn.terminate(); +}); + +test('Connection: a ping flood cannot buffer pongs without limit', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { maxBackpressure: 100 }); + const errors = []; + conn.on('error', (error) => errors.push(error)); + + // The peer floods pings but never reads: the write queue grows past the + // cap and the pong path must terminate instead of buffering forever. + socket.writableLength = 101; + assert.strictEqual(conn.sendPong(), false); + assert.strictEqual(errors.length, 1); + assert.match(errors[0].message, /Backpressure limit exceeded/); + assert.strictEqual(socket.destroyed, true); +}); + +test('Connection: maxBackpressure defaults to maxBuffer, finite out of the box', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { maxBuffer: 1000 }); + const errors = []; + conn.on('error', (error) => errors.push(error)); + + socket.writableLength = 1001; + assert.strictEqual(conn.sendText('past the default cap'), false); + assert.strictEqual(errors.length, 1); + assert.strictEqual(socket.destroyed, true); +}); diff --git a/tests/websocket/connection.test.js b/tests/websocket/connection.test.js index 3c20609..37898e1 100644 --- a/tests/websocket/connection.test.js +++ b/tests/websocket/connection.test.js @@ -75,6 +75,45 @@ test('Connection: sendClose triggers socket end after CLOSE_TIMEOUT', () => { }); }); +test('Connection: answering a peer close hangs up immediately (RFC 6455 5.5.1)', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { closeTimeout: 60_000 }); + + const close = Frame.close(1000, 'bye'); + close.maskPayload(); + socket.emit('data', close.toBuffer()); + + const echo = FrameParser.parse(socket.writtenData.at(-1)).value.frame; + assert.strictEqual(echo.opcode, OPCODES.CLOSE); + // The point of the change: the answering side does not wait out + // closeTimeout for the initiator to hang up. Both peers waiting is what + // used to cost a full second on every graceful disconnect. + assert.strictEqual(socket.ended, true, 'the socket is half-closed as soon as the echo is written'); + conn.terminate(); +}); + +test('Connection: a frame pipelined behind a peer close is not acted on', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { closeTimeout: 50 }); + const messages = []; + conn.on('message', (data) => void messages.push(data)); + + // One TCP segment carrying CLOSE then PING then a data frame. Answering + // the ping would write past the end() the close echo just performed. + const close = Frame.close(1000, 'bye'); + close.maskPayload(); + const ping = Frame.ping(Buffer.from('hi')); + ping.maskPayload(); + const text = Frame.text('late'); + text.maskPayload(); + socket.emit('data', Buffer.concat([close.toBuffer(), ping.toBuffer(), text.toBuffer()])); + + const written = socket.writtenData.map((data) => FrameParser.parse(data).value.frame.opcode); + assert.deepStrictEqual(written, [OPCODES.CLOSE], 'the close echo is the last thing written'); + assert.deepStrictEqual(messages, [], 'and nothing queued behind the close is delivered'); + conn.terminate(); +}); + test('Connection: rejects fragmented message exceeding maxBuffer', () => { const socket = new MockSocket(); const conn = new Connection(socket, Buffer.alloc(0), { maxBuffer: 10 }); @@ -161,6 +200,30 @@ test('Connection: client-mode sendPing/sendPong use the masked empty-frame fast conn.terminate(); }); +test('Connection: send methods return uniform booleans', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { closeTimeout: 50 }); + + assert.strictEqual(conn.sendPing(), true); + assert.strictEqual(conn.sendPing(Buffer.from('x')), true); + assert.strictEqual(conn.sendPong(), true); + assert.strictEqual(conn.sendPong(Buffer.from('x')), true); + assert.strictEqual(conn.sendText('x'), true); + assert.strictEqual(conn.sendBinary(Buffer.from('x')), true); + + conn.sendClose(1000, 'bye'); + + assert.strictEqual(conn.sendPing(), false); + assert.strictEqual(conn.sendPing(Buffer.from('x')), false); + assert.strictEqual(conn.sendText('x'), false); + assert.strictEqual(conn.sendBinary(Buffer.from('x')), false); + // Pong stays available during the close handshake (RFC 6455 5.5.3) + assert.strictEqual(conn.sendPong(), true); + assert.strictEqual(conn.sendPong(Buffer.from('x')), true); + + conn.terminate(); +}); + test('Connection: send() rejects non-string/non-Buffer payloads', () => { const socket = new MockSocket(); const conn = new Connection(socket, Buffer.alloc(0)); diff --git a/tests/websocket/deflate.test.js b/tests/websocket/deflate.test.js new file mode 100644 index 0000000..fb9e8ac --- /dev/null +++ b/tests/websocket/deflate.test.js @@ -0,0 +1,235 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const zlib = require('node:zlib'); + +const { Connection, Frame, FrameParser, RSV1, OPCODES, CLOSE_CODES } = require('#ws'); +const { negotiate, parseExtensions, compress, decompress } = require('../../src/websocket/permessageDeflate.js'); +const { MockSocket } = require('./mockSocket.js'); + +const DEFLATE = { response: '', threshold: 1, windowBits: 15 }; + +const deliverCompressedText = (socket, text) => { + const payload = compress(Buffer.from(text)); + const frame = new Frame(true, OPCODES.TEXT, false, payload, null, RSV1); + frame.maskPayload(); + socket.emit('data', frame.toBuffer()); +}; + +test('negotiate: plain offer is accepted with both no-context-takeover params', () => { + const result = negotiate('permessage-deflate', {}); + assert.strictEqual(result.response, 'permessage-deflate; server_no_context_takeover; client_no_context_takeover'); + assert.strictEqual(result.windowBits, 15); + assert.strictEqual(result.threshold, 1024); +}); + +test('negotiate: honors server_max_window_bits and echoes it back', () => { + const result = negotiate('permessage-deflate; server_max_window_bits=10', { threshold: 64 }); + assert.match(result.response, /server_max_window_bits=10/); + assert.strictEqual(result.windowBits, 10); + assert.strictEqual(result.threshold, 64); +}); + +test('negotiate: skips unusable offers and falls back to the next one', () => { + const header = 'permessage-deflate; server_max_window_bits=8, permessage-deflate'; + const result = negotiate(header, {}); + assert.ok(result); + assert.strictEqual(result.windowBits, 15); +}); + +test('negotiate: rejects unknown params, bad window bits, and other extensions', () => { + assert.strictEqual(negotiate('permessage-deflate; unknown_param', {}), null); + assert.strictEqual(negotiate('permessage-deflate; server_max_window_bits', {}), null); + assert.strictEqual(negotiate('permessage-deflate; server_max_window_bits=99', {}), null); + assert.strictEqual(negotiate('permessage-deflate; client_max_window_bits=abc', {}), null); + assert.strictEqual(negotiate('x-webkit-deflate-frame', {}), null); + assert.strictEqual(negotiate('', {}), null); +}); + +test('parseExtensions: grammar violations return null, duplicates invalidate the offer', () => { + // True grammar violations (RFC 6455 9.1) -> null -> handshake must 400 + assert.strictEqual(parseExtensions('permessage-deflate; ='), null); + assert.strictEqual(parseExtensions('permessage-deflate; a='), null); + assert.strictEqual(parseExtensions(',permessage-deflate'), null); + // Duplicate params are grammar-valid but make the offer unacceptable + // (RFC 7692 7): the offer is declined, not the whole handshake failed + const offers = parseExtensions('permessage-deflate; client_no_context_takeover; client_no_context_takeover'); + assert.strictEqual(offers.length, 1); + assert.strictEqual(offers[0].valid, false); + assert.strictEqual(negotiate('permessage-deflate; client_no_context_takeover; client_no_context_takeover', {}), null); +}); + +test('negotiate: grammar-violating header reports malformed for a 400 handshake failure', () => { + assert.deepStrictEqual(negotiate('permessage-deflate; =', {}), { malformed: true }); + assert.deepStrictEqual(negotiate(',permessage-deflate', {}), { malformed: true }); +}); + +test('compress/decompress: round-trip with stripped trailer', () => { + const source = Buffer.from('data '.repeat(1000)); + const compressed = compress(source); + assert.ok(compressed.length < source.length); + const restored = decompress(compressed, 1024 * 1024); + assert.deepStrictEqual(restored, source); +}); + +test('Connection: inflates a compressed text message', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { deflate: DEFLATE }); + const messages = []; + conn.on('message', (data, isBinary) => messages.push({ data, isBinary })); + + deliverCompressedText(socket, 'compressed hello'); + + assert.strictEqual(messages.length, 1); + assert.strictEqual(messages[0].data.toString(), 'compressed hello'); + assert.strictEqual(messages[0].isBinary, false); + conn.terminate(); +}); + +test('Connection: inflates a fragmented compressed message', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { deflate: DEFLATE }); + const messages = []; + conn.on('message', (data) => messages.push(data)); + + const payload = compress(Buffer.from('fragmented compressed payload')); + const half = Math.ceil(payload.length / 2); + const first = new Frame(false, OPCODES.TEXT, false, payload.subarray(0, half), null, RSV1); + first.maskPayload(); + socket.emit('data', first.toBuffer()); + const second = new Frame(true, OPCODES.CONTINUATION, false, payload.subarray(half), null); + second.maskPayload(); + socket.emit('data', second.toBuffer()); + + assert.strictEqual(messages.length, 1); + assert.strictEqual(messages[0].toString(), 'fragmented compressed payload'); + conn.terminate(); +}); + +test('Connection: compresses outgoing messages above the threshold', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { deflate: { ...DEFLATE, threshold: 8 } }); + + conn.sendText('a very repetitive payload '.repeat(50)); + + const frame = FrameParser.parse(socket.writtenData.at(-1), { allowedRsv: RSV1 }).value.frame; + assert.strictEqual(frame.rsv & RSV1, RSV1); + const inflated = decompress(frame.payload, 1024 * 1024); + assert.strictEqual(inflated.toString(), 'a very repetitive payload '.repeat(50)); + conn.terminate(); +}); + +test('Connection: leaves short outgoing messages uncompressed', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { deflate: { ...DEFLATE, threshold: 1024 } }); + + conn.sendText('short'); + + const frame = FrameParser.parse(socket.writtenData.at(-1)).value.frame; + assert.strictEqual(frame.rsv, 0); + assert.strictEqual(frame.payload.toString(), 'short'); + conn.terminate(); +}); + +test('Connection: invalid deflate stream closes with 1007', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { deflate: DEFLATE }); + conn.on('error', () => {}); + + const frame = new Frame(true, OPCODES.TEXT, false, Buffer.from('not deflate data'), null, RSV1); + frame.maskPayload(); + socket.emit('data', frame.toBuffer()); + + const close = FrameParser.parse(socket.writtenData.at(-1)).value.frame; + assert.strictEqual(close.opcode, OPCODES.CLOSE); + assert.strictEqual(close.payload.readUInt16BE(0), CLOSE_CODES.INVALID_PAYLOAD); +}); + +test('Connection: inflated payload above maxBuffer closes with 1009', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { deflate: DEFLATE, maxBuffer: 1024 }); + conn.on('error', () => {}); + + // 1 MB of zeros deflates to ~1 KB of wire bytes but inflates above maxBuffer + const bomb = zlib.deflateRawSync(Buffer.alloc(1024 * 1024), { + finishFlush: zlib.constants.Z_SYNC_FLUSH, + }); + const payload = bomb.subarray(0, bomb.length - 4); + const frame = new Frame(true, OPCODES.TEXT, false, payload, null, RSV1); + frame.maskPayload(); + socket.emit('data', frame.toBuffer()); + + const close = FrameParser.parse(socket.writtenData.at(-1)).value.frame; + assert.strictEqual(close.opcode, OPCODES.CLOSE); + assert.strictEqual(close.payload.readUInt16BE(0), CLOSE_CODES.MESSAGE_TOO_BIG); +}); + +test('Connection: compressed UTF-8 is validated after inflation', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { deflate: DEFLATE }); + conn.on('error', () => {}); + + const invalid = compress(Buffer.from([0xff, 0xfe, 0xfd])); + const frame = new Frame(true, OPCODES.TEXT, false, invalid, null, RSV1); + frame.maskPayload(); + socket.emit('data', frame.toBuffer()); + + const close = FrameParser.parse(socket.writtenData.at(-1)).value.frame; + assert.strictEqual(close.opcode, OPCODES.CLOSE); + assert.strictEqual(close.payload.readUInt16BE(0), CLOSE_CODES.INVALID_PAYLOAD); +}); + +test('Connection: RSV1 on a continuation frame is a protocol error', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0), { deflate: DEFLATE }); + conn.on('error', () => {}); + + const first = new Frame(false, OPCODES.TEXT, false, compress(Buffer.from('x')), null, RSV1); + first.maskPayload(); + socket.emit('data', first.toBuffer()); + const bad = new Frame(true, OPCODES.CONTINUATION, false, Buffer.from('y'), null, RSV1); + bad.maskPayload(); + socket.emit('data', bad.toBuffer()); + + const close = FrameParser.parse(socket.writtenData.at(-1)).value.frame; + assert.strictEqual(close.opcode, OPCODES.CLOSE); + assert.strictEqual(close.payload.readUInt16BE(0), CLOSE_CODES.PROTOCOL_ERROR); +}); + +test('Connection: RSV1 without negotiated deflate stays a protocol error', () => { + const socket = new MockSocket(); + const conn = new Connection(socket, Buffer.alloc(0)); + conn.on('error', () => {}); + + const frame = new Frame(true, OPCODES.TEXT, false, compress(Buffer.from('x')), null, RSV1); + frame.maskPayload(); + socket.emit('data', frame.toBuffer()); + + const close = FrameParser.parse(socket.writtenData.at(-1)).value.frame; + assert.strictEqual(close.opcode, OPCODES.CLOSE); + assert.strictEqual(close.payload.readUInt16BE(0), CLOSE_CODES.PROTOCOL_ERROR); +}); + +test('Connection: a compression bomb is capped by maxPayload, not maxBuffer', () => { + const socket = new MockSocket(); + // A few KB on the wire inflating to ~4 MB; maxPayload of 1 MB must stop + // the inflation even though maxBuffer (wire bytes) is far larger. + const conn = new Connection(socket, Buffer.alloc(0), { deflate: DEFLATE, maxPayload: 1024 * 1024 }); + const errors = []; + const messages = []; + conn.on('error', (error) => errors.push(error)); + conn.on('message', (data) => messages.push(data)); + + const bomb = compress(Buffer.alloc(4 * 1024 * 1024, 0x61)); + assert.ok(bomb.length < 16 * 1024, 'the bomb must be small on the wire'); + const frame = new Frame(true, OPCODES.TEXT, false, bomb, null, RSV1); + frame.maskPayload(); + socket.emit('data', frame.toBuffer()); + + assert.strictEqual(messages.length, 0, 'the inflated payload must never surface'); + assert.strictEqual(errors.length, 1); + const close = FrameParser.parse(socket.writtenData.at(-1)).value.frame; + assert.strictEqual(close.opcode, OPCODES.CLOSE); + assert.strictEqual(close.payload.readUInt16BE(0), CLOSE_CODES.MESSAGE_TOO_BIG); +}); diff --git a/tests/websocket/frame.test.js b/tests/websocket/frame.test.js index b61c02c..10542e1 100644 --- a/tests/websocket/frame.test.js +++ b/tests/websocket/frame.test.js @@ -5,6 +5,27 @@ const assert = require('node:assert'); const crypto = require('node:crypto'); const { Frame, FrameParser, OPCODES } = require('#ws'); +const { applyMask } = require('../../src/websocket/frame.js'); + +test('applyMask: word-XOR fast path matches the byte-wise reference for every length and alignment', () => { + const reference = (payload, mask) => { + for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3]; + }; + const lengths = [0, 1, 2, 3, 4, 5, 7, 8, 9, 63, 64, 65, 1027]; + for (const length of lengths) { + const mask = crypto.randomBytes(4); + const data = crypto.randomBytes(length); + const expected = Buffer.from(data); + reference(expected, mask); + // subarray views at every alignment — SegmentQueue hands out arbitrary offsets + for (let offset = 0; offset < 4; offset++) { + const buffer = Buffer.from(new ArrayBuffer(length + offset), offset, length); + data.copy(buffer); + applyMask(buffer, mask); + assert.deepStrictEqual(buffer, expected, `length ${length}, byteOffset ${offset}`); + } + } +}); test('Frame: create and parse text frame', () => { const message = 'Hello tinyWS'; diff --git a/tests/websocket/mockSocket.js b/tests/websocket/mockSocket.js index 03e94ec..9b56c23 100644 --- a/tests/websocket/mockSocket.js +++ b/tests/websocket/mockSocket.js @@ -11,6 +11,11 @@ class MockSocket extends EventEmitter { this.writtenData = []; this.ended = false; this.destroyed = false; + this.paused = false; + // Backpressure modelling: write() reports this value, writableLength + // mimics the kernel/stream buffer size, drain() releases the pressure. + this.writeResult = true; + this.writableLength = 0; } cork() { @@ -34,6 +39,21 @@ class MockSocket extends EventEmitter { } else { this.writtenData.push(data); } + return this.writeResult; + } + + pause() { + this.paused = true; + } + + resume() { + this.paused = false; + } + + drain() { + this.writeResult = true; + this.writableLength = 0; + this.emit('drain'); } end(data) { diff --git a/tests/websocket/negotiation.test.js b/tests/websocket/negotiation.test.js new file mode 100644 index 0000000..a6ed800 --- /dev/null +++ b/tests/websocket/negotiation.test.js @@ -0,0 +1,203 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const http = require('node:http'); + +const { WebsocketServer } = require('#ws'); +const { ProtocolClient } = require('./protocolClient.js'); + +const KEY = Buffer.from('0123456789abcdef').toString('base64'); + +const BASE_HEADERS = { + Upgrade: 'websocket', + Connection: 'Upgrade', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Key': KEY, +}; + +const parseStatusCode = (statusLine) => parseInt(statusLine.split(' ')[1], 10); + +// Node 22 aborts a still-pending test the moment the event loop goes idle +// ('Promise resolution is still pending but the event loop has already +// resolved'); Node 24 tolerates it. The ws heartbeat timer is unref'd on +// purpose, so once the handshake sockets are gone the loop is idle by design +// while `httpServer.close()` is still pending. One ref'd handle for the +// file's lifetime keeps the runner from calling that a failure — without it +// the first test aborts and the other nine cascade as cancelledByParent. +let loopHold = null; +test.before(() => void (loopHold = setInterval(() => {}, 1000))); +test.after(() => clearInterval(loopHold)); + +const withServer = async (options, run) => { + const httpServer = http.createServer(); + const wsServer = new WebsocketServer({ server: httpServer, pingInterval: 5000, ...options }); + await new Promise((resolve) => httpServer.listen(0, resolve)); + const port = httpServer.address().port; + try { + await run({ port, wsServer, httpServer }); + } finally { + // An upgraded socket outlives the http server: `close()` waits for it, + // and nothing else reaps it until the (unref'd) heartbeat terminates the + // peer two ping intervals later. That is the ~10 s each of these tests + // used to spend sitting in this finally. Close the ws server first so + // the peers are terminated now. + wsServer.close(); + await new Promise((resolve) => httpServer.close(resolve)); + } +}; + +test('subprotocol: server picks the first client-offered protocol it supports', async () => { + await withServer({ protocols: ['wrpc.v2', 'wrpc.v1'] }, async ({ port, wsServer }) => { + const connectionProtocol = new Promise((resolve) => { + wsServer.once('connection', (ws) => resolve(ws.protocol)); + }); + const res = await ProtocolClient.attemptHandshake({ + host: 'localhost', + port, + path: '/', + headers: { ...BASE_HEADERS, 'Sec-WebSocket-Protocol': 'other, wrpc.v1, wrpc.v2' }, + timeoutMs: 600, + }); + assert.strictEqual(parseStatusCode(res.statusLine), 101); + assert.strictEqual(res.headers['sec-websocket-protocol'], 'wrpc.v1'); + assert.strictEqual(await connectionProtocol, 'wrpc.v1'); + }); +}); + +test('subprotocol: no match connects without a protocol', async () => { + await withServer({ protocols: ['wrpc.v1'] }, async ({ port }) => { + const res = await ProtocolClient.attemptHandshake({ + host: 'localhost', + port, + path: '/', + headers: { ...BASE_HEADERS, 'Sec-WebSocket-Protocol': 'other' }, + timeoutMs: 600, + }); + assert.strictEqual(parseStatusCode(res.statusLine), 101); + assert.strictEqual(res.headers['sec-websocket-protocol'], undefined); + }); +}); + +test('subprotocol: handleProtocols drives the selection', async () => { + await withServer({ handleProtocols: (offered) => offered.at(-1) }, async ({ port }) => { + const res = await ProtocolClient.attemptHandshake({ + host: 'localhost', + port, + path: '/', + headers: { ...BASE_HEADERS, 'Sec-WebSocket-Protocol': 'a, b, c' }, + timeoutMs: 600, + }); + assert.strictEqual(parseStatusCode(res.statusLine), 101); + assert.strictEqual(res.headers['sec-websocket-protocol'], 'c'); + }); +}); + +test('subprotocol: handleProtocols returning false rejects the handshake', async () => { + await withServer({ handleProtocols: () => false }, async ({ port }) => { + const res = await ProtocolClient.attemptHandshake({ + host: 'localhost', + port, + path: '/', + headers: { ...BASE_HEADERS, 'Sec-WebSocket-Protocol': 'a' }, + timeoutMs: 600, + }); + assert.strictEqual(parseStatusCode(res.statusLine), 400); + }); +}); + +test('deflate: server accepts a permessage-deflate offer with no-context-takeover response', async () => { + await withServer({ perMessageDeflate: true }, async ({ port }) => { + const res = await ProtocolClient.attemptHandshake({ + host: 'localhost', + port, + path: '/', + headers: { ...BASE_HEADERS, 'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits' }, + timeoutMs: 600, + }); + assert.strictEqual(parseStatusCode(res.statusLine), 101); + assert.strictEqual( + res.headers['sec-websocket-extensions'], + 'permessage-deflate; server_no_context_takeover; client_no_context_takeover', + ); + }); +}); + +test('deflate: grammar-violating extensions header fails the handshake with 400', async () => { + await withServer({ perMessageDeflate: true }, async ({ port }) => { + const res = await ProtocolClient.attemptHandshake({ + host: 'localhost', + port, + path: '/', + headers: { ...BASE_HEADERS, 'Sec-WebSocket-Extensions': 'permessage-deflate; =' }, + timeoutMs: 600, + }); + assert.strictEqual(parseStatusCode(res.statusLine), 400); + }); +}); + +test('deflate: unacceptable but well-formed offer is declined, not failed', async () => { + await withServer({ perMessageDeflate: true }, async ({ port }) => { + const res = await ProtocolClient.attemptHandshake({ + host: 'localhost', + port, + path: '/', + headers: { ...BASE_HEADERS, 'Sec-WebSocket-Extensions': 'permessage-deflate; unknown_param' }, + timeoutMs: 600, + }); + assert.strictEqual(parseStatusCode(res.statusLine), 101); + assert.strictEqual(res.headers['sec-websocket-extensions'], undefined); + }); +}); + +test('deflate: disabled server ignores the offer', async () => { + await withServer({}, async ({ port }) => { + const res = await ProtocolClient.attemptHandshake({ + host: 'localhost', + port, + path: '/', + headers: { ...BASE_HEADERS, 'Sec-WebSocket-Extensions': 'permessage-deflate' }, + timeoutMs: 600, + }); + assert.strictEqual(parseStatusCode(res.statusLine), 101); + assert.strictEqual(res.headers['sec-websocket-extensions'], undefined); + }); +}); + +test('WebsocketServer: close() notifies peers and rejects new upgrades', async () => { + await withServer({}, async ({ port, wsServer }) => { + const peer = new ProtocolClient(`ws://localhost:${port}`); + await new Promise((resolve) => peer.on('open', resolve)); + assert.strictEqual(wsServer.connections.size, 1); + + const closed = new Promise((resolve) => peer.on('close', (code, reason) => resolve({ code, reason }))); + wsServer.close({ code: 1001, reason: 'maintenance' }); + const { code, reason } = await closed; + assert.strictEqual(code, 1001); + assert.strictEqual(reason.toString(), 'maintenance'); + assert.strictEqual(wsServer.connections.size, 0); + + const res = await ProtocolClient.attemptHandshake({ + host: 'localhost', + port, + path: '/', + headers: { ...BASE_HEADERS }, + timeoutMs: 600, + }); + assert.strictEqual(parseStatusCode(res.statusLine), 503); + + // idempotent + assert.doesNotThrow(() => wsServer.close()); + }); +}); + +test('WebsocketServer: connections getter returns a snapshot', async () => { + await withServer({}, async ({ port, wsServer }) => { + const peer = new ProtocolClient(`ws://localhost:${port}`); + await new Promise((resolve) => peer.on('open', resolve)); + const snapshot = wsServer.connections; + snapshot.clear(); // mutating the snapshot must not affect the server + assert.strictEqual(wsServer.connections.size, 1); + peer.close(); + }); +}); diff --git a/tests/websocket/segments.test.js b/tests/websocket/segments.test.js new file mode 100644 index 0000000..4358193 --- /dev/null +++ b/tests/websocket/segments.test.js @@ -0,0 +1,91 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); + +const { SegmentQueue } = require('../../src/websocket/segments.js'); + +test('SegmentQueue: push/length/peek within a single segment', () => { + const queue = new SegmentQueue(); + assert.strictEqual(queue.length, 0); + assert.strictEqual(queue.peek(10).length, 0); + + queue.push(Buffer.from('hello world')); + assert.strictEqual(queue.length, 11); + assert.strictEqual(queue.peek(5).toString(), 'hello'); + // peek does not consume + assert.strictEqual(queue.length, 11); + // peek beyond the buffered length is clamped + assert.strictEqual(queue.peek(100).toString(), 'hello world'); +}); + +test('SegmentQueue: empty pushes are ignored', () => { + const queue = new SegmentQueue(); + queue.push(Buffer.alloc(0)); + assert.strictEqual(queue.length, 0); +}); + +test('SegmentQueue: peek stitches bytes across segments', () => { + const queue = new SegmentQueue(); + queue.push(Buffer.from('ab')); + queue.push(Buffer.from('cd')); + queue.push(Buffer.from('ef')); + assert.strictEqual(queue.peek(5).toString(), 'abcde'); + assert.strictEqual(queue.length, 6); +}); + +test('SegmentQueue: consume within one segment advances the cursor', () => { + const queue = new SegmentQueue(); + queue.push(Buffer.from('abcdef')); + assert.strictEqual(queue.consume(2).toString(), 'ab'); + assert.strictEqual(queue.consume(2).toString(), 'cd'); + assert.strictEqual(queue.length, 2); + assert.strictEqual(queue.peek(2).toString(), 'ef'); +}); + +test('SegmentQueue: consume across segments copies once', () => { + const queue = new SegmentQueue(); + queue.push(Buffer.from('ab')); + queue.push(Buffer.from('cd')); + queue.push(Buffer.from('efgh')); + assert.strictEqual(queue.consume(5).toString(), 'abcde'); + assert.strictEqual(queue.length, 3); + assert.strictEqual(queue.consume(3).toString(), 'fgh'); + assert.strictEqual(queue.length, 0); +}); + +test('SegmentQueue: consume exactly to a segment boundary', () => { + const queue = new SegmentQueue(); + queue.push(Buffer.from('abc')); + queue.push(Buffer.from('def')); + assert.strictEqual(queue.consume(3).toString(), 'abc'); + assert.strictEqual(queue.consume(3).toString(), 'def'); + assert.strictEqual(queue.length, 0); +}); + +test('SegmentQueue: single-segment consume is a zero-copy view', () => { + const queue = new SegmentQueue(); + const source = Buffer.from('abcdef'); + queue.push(source); + const view = queue.consume(3); + source[0] = 0x7a; // 'z' + assert.strictEqual(view.toString(), 'zbc'); +}); + +test('SegmentQueue: consume(0) and over-consume', () => { + const queue = new SegmentQueue(); + queue.push(Buffer.from('ab')); + assert.strictEqual(queue.consume(0).length, 0); + assert.throws(() => queue.consume(3), RangeError); +}); + +test('SegmentQueue: clear resets state', () => { + const queue = new SegmentQueue(); + queue.push(Buffer.from('abc')); + queue.consume(1); + queue.clear(); + assert.strictEqual(queue.length, 0); + assert.strictEqual(queue.peek(3).length, 0); + queue.push(Buffer.from('xy')); + assert.strictEqual(queue.consume(2).toString(), 'xy'); +}); diff --git a/tests/websocket/server.test.js b/tests/websocket/server.test.js index 004e795..d859b0c 100644 --- a/tests/websocket/server.test.js +++ b/tests/websocket/server.test.js @@ -34,9 +34,150 @@ test('WebsocketServer: accepts new connection after socket error', async () => { await new Promise((resolve) => httpServer.close(resolve)); }); -test('WebsocketServer: constructor requires an http(s) server instance', () => { - assert.throws(() => new WebsocketServer({}), /options\.server .* is required/); - assert.throws(() => new WebsocketServer({ server: {} }), /options\.server .* is required/); +test('WebsocketServer: heartbeat terminates a dead peer and keeps pinging others', async () => { + const httpServer = http.createServer(); + void new WebsocketServer({ server: httpServer, pingInterval: 50 }); + + await new Promise((resolve) => httpServer.listen(0, resolve)); + const port = httpServer.address().port; + + // ProtocolClient never answers pings, so the server must terminate it + // between one and two ping intervals. + const dead = new ProtocolClient(`ws://localhost:${port}`); + await new Promise((resolve) => dead.on('open', resolve)); + await new Promise((resolve) => dead.socket.once('close', resolve)); + + // The heartbeat interval must survive the termination: a fresh peer + // still receives pings on the next ticks. + const live = new ProtocolClient(`ws://localhost:${port}`); + await new Promise((resolve) => live.on('open', resolve)); + await new Promise((resolve) => live.on('ping', resolve)); + + live.close(); + await new Promise((resolve) => httpServer.close(resolve)); +}); + +test('WebsocketServer: heartbeat tick survives a peer with no close event', (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + const { EventEmitter } = require('node:events'); + const { MockSocket } = require('./mockSocket.js'); + + const fakeServer = new EventEmitter(); + void new WebsocketServer({ server: fakeServer, pingInterval: 20 }); + + const socket = new MockSocket(); + const req = { + httpVersion: '1.1', + method: 'GET', + url: '/', + headers: { + host: 'localhost', + upgrade: 'websocket', + connection: 'Upgrade', + 'sec-websocket-version': '13', + 'sec-websocket-key': Buffer.from('0123456789abcdef').toString('base64'), + }, + }; + fakeServer.emit('upgrade', req, socket, Buffer.alloc(0)); + + // Simulate a socket that died without ever emitting 'close': terminate() + // becomes a no-op, so only the heartbeat tick itself can clean the maps. + socket.destroyed = true; + + assert.doesNotThrow(() => t.mock.timers.tick(20)); // ping sent, awaiting = true + assert.doesNotThrow(() => t.mock.timers.tick(20)); // dead-peer tick: terminate + full cleanup + assert.doesNotThrow(() => t.mock.timers.tick(20)); // regression: used to TypeError on stale entry + + fakeServer.emit('close'); +}); + +test('WebsocketServer: heartbeat spares paused connections and resumes checks after resume', (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + const { EventEmitter } = require('node:events'); + const { MockSocket } = require('./mockSocket.js'); + + const fakeServer = new EventEmitter(); + const wsServer = new WebsocketServer({ server: fakeServer, pingInterval: 20 }); + let conn = null; + wsServer.on('connection', (ws) => (conn = ws)); + + const socket = new MockSocket(); + const req = { + httpVersion: '1.1', + method: 'GET', + url: '/', + headers: { + host: 'localhost', + upgrade: 'websocket', + connection: 'Upgrade', + 'sec-websocket-version': '13', + 'sec-websocket-key': Buffer.from('0123456789abcdef').toString('base64'), + }, + }; + fakeServer.emit('upgrade', req, socket, Buffer.alloc(0)); + + t.mock.timers.tick(20); // ping sent, awaiting = true + conn.pause(); // backpressure kicks in before the pong could be read + t.mock.timers.tick(20); // would have terminated pre-fix: awaiting still true + t.mock.timers.tick(20); + assert.strictEqual(socket.destroyed, false); + + conn.resume(); // still no pong read: the next tick may terminate again + t.mock.timers.tick(20); + assert.strictEqual(socket.destroyed, true); + + fakeServer.emit('close'); +}); + +test('WebsocketServer: an explicit server must be an http(s) server instance', () => { + assert.throws(() => new WebsocketServer({ server: {} }), /options\.server must be an http\.Server/); + assert.throws(() => new WebsocketServer({ server: null }), /options\.server must be an http\.Server/); +}); + +test('WebsocketServer: without a server, upgrades are driven by handleUpgrade', async () => { + // Middleware adapters (express) own the 'upgrade' listener themselves. + const wsServer = new WebsocketServer({ pingInterval: 50 }); + const httpServer = http.createServer(); + httpServer.on('upgrade', (req, socket, head) => { + wsServer.handleUpgrade(req, socket, head); + }); + await new Promise((resolve) => httpServer.listen(0, resolve)); + const { port } = httpServer.address(); + + const connected = new Promise((resolve) => wsServer.once('connection', resolve)); + const peer = new ProtocolClient(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => peer.on('open', resolve)); + const conn = await connected; + + const echoed = new Promise((resolve) => peer.once('message', resolve)); + conn.sendText('manual upgrade'); + assert.strictEqual((await echoed).toString(), 'manual upgrade'); + + peer.close(); + wsServer.close(); + await new Promise((resolve) => httpServer.close(resolve)); +}); + +test('WebsocketServer: handleUpgrade answers 500 when the handshake throws', async () => { + const wsServer = new WebsocketServer({ pingInterval: 50 }); + wsServer.on('error', () => {}); // otherwise EventEmitter rethrows + const written = []; + const socket = { + on: () => {}, + // A getter that throws inside #handleUpgrade's header inspection + write: (data) => void written.push(String(data)), + destroy: () => {}, + cork: () => {}, + uncork: () => {}, + }; + const req = { + get httpVersion() { + throw new Error('boom'); + }, + }; + wsServer.handleUpgrade(req, socket, Buffer.alloc(0)); + assert.match(written.join(''), /^HTTP\/1\.1 500 Internal Server Error/); + wsServer.close(); }); test('WebsocketServer: forwards http server errors when it has its own listeners', async () => { diff --git a/tests/ws.test-d.ts b/tests/ws.test-d.ts new file mode 100644 index 0000000..81ed73c --- /dev/null +++ b/tests/ws.test-d.ts @@ -0,0 +1,76 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import * as ws from '../ws.js'; +import type { + WebsocketServer, + Connection, + Frame, + FrameParser, + FrameHeader, + ParseError, + WebsocketServerOptions, +} from '../ws.js'; + +expectType(ws.WebsocketServer); +expectType(ws.Connection); +expectType(ws.Frame); +expectType(ws.FrameParser); +expectType(ws.ParseError); +expectType(ws.MAGIC); +expectType(ws.CLOSE_TIMEOUT); +expectType(ws.RSV1); +expectType<1009>(ws.CLOSE_CODES.MESSAGE_TOO_BIG); +expectType<0x01>(ws.OPCODES.TEXT); + +// Connection send methods: sendClose is void, data sends report acceptance +declare const connection: Connection; +expectType(connection.sendClose()); +expectType(connection.sendClose(1000, 'bye')); +expectType(connection.sendText('x')); +expectType(connection.sendBinary(Buffer.alloc(0))); +expectType(connection.sendPing()); +expectType(connection.sendPong()); +expectType(connection.send('x')); + +// Backpressure / flow-control surface +expectType(connection.bufferedAmount); +expectType(connection.isPaused); +expectType(connection.pause()); +expectType(connection.resume()); +expectType(connection.protocol); +connection.on('drain', () => {}); +connection.on('ping', (payload) => { + expectType(payload); +}); + +// Server options: subprotocols, deflate, backpressure caps +declare const httpServer: import('node:http').Server; +expectAssignable({ server: httpServer }); +expectAssignable({ + server: httpServer, + protocols: ['wrpc'], + maxBackpressure: 1024, + fragmentThreshold: 65536, + perMessageDeflate: { threshold: 512 }, +}); +expectAssignable({ + server: httpServer, + handleProtocols: (offered, req) => { + expectType>(offered); + expectType(req); + return offered[0] ?? false; + }, +}); +expectError({ server: httpServer, perMessageDeflate: { level: 9 } }); + +declare const wss: WebsocketServer; +expectType>(wss.connections); +expectType(wss.close()); +expectType(wss.close({ code: 1001, reason: 'bye' })); + +// FrameParser: header parsing and extension-aware RSV handling +declare const buffer: Buffer; +const headerResult = ws.FrameParser.parseHeader(buffer, { allowedRsv: ws.RSV1 }); +expectType(headerResult.value); +expectType(headerResult.error); +const parseResult = ws.FrameParser.parse(buffer, { allowedRsv: 0 }); +expectType<{ frame: Frame; bytesUsed: number } | null>(parseResult.value); diff --git a/uws.d.ts b/uws.d.ts new file mode 100644 index 0000000..664a4fd --- /dev/null +++ b/uws.d.ts @@ -0,0 +1,105 @@ +import { EventEmitter } from 'node:events'; +import type { Engine, EngineCapabilities, WrpcSocket } from './engine.js'; +import type { HttpCall } from './index.js'; + +/** + * The slice of the uWebSockets.js module this adapter uses. Typed + * structurally on purpose: `uWebSockets.js` is injected by the caller and is + * never a dependency of @alexify/wrpc, so its own types may be absent. + */ +export interface UwsModule { + App(options?: Record): UwsApp; + SSLApp(options: Record): UwsApp; + us_listen_socket_close?(token: unknown): void; + us_socket_local_port?(token: unknown): number; + [key: string]: unknown; +} + +export interface UwsApp { + ws(pattern: string, behavior: Record): UwsApp; + any(pattern: string, handler: (res: unknown, req: unknown) => void): UwsApp; + listen(host: string, port: number, cb: (token: unknown) => void): UwsApp; + close?(): UwsApp; + [key: string]: unknown; +} + +/** A WrpcSocket backed by a uws WebSocket handle. */ +export declare class UwsSocket extends EventEmitter implements WrpcSocket { + readonly closed: boolean; + readonly bufferedAmount: number; + remoteAddress: string; + protocol: string; + constructor(ws: unknown, meta?: { remoteAddress?: string; protocol?: string }); + send(data: string | Buffer): boolean; + close(code?: number, reason?: string): void; + terminate(): void; + /** Called by the engine's close handler; the uws handle is poison after it. */ + markClosed(code: number, reason: string): void; +} + +export interface UwsEngineOptions { + /** `require('uWebSockets.js')` — injected, never a dependency. */ + uws?: UwsModule; + /** An existing app to attach to (fastify-uws) instead of creating one. */ + app?: UwsApp; + /** Passed to SSLApp() instead of App() when present. */ + ssl?: Record | null; + /** Seconds without traffic before uws closes the peer. Default 120. */ + idleTimeout?: number; + maxPayloadLength?: number; + maxBackpressure?: number; + closeOnBackpressureLimit?: boolean; + maxLifetime?: number; + /** A uws compressor constant; null (default) disables permessage-deflate. */ + compression?: number | null; + sendPingsAutomatically?: boolean; + maxBodySize?: number; +} + +export interface UwsAttachOptions { + /** uws route pattern for upgrades. Default '/*'. */ + path?: string; + verifyClient?: (info: { req: UpgradeRequest; socket: null; head: null }) => boolean; + protocols?: Array; + handleProtocols?: (offered: Array, req: UpgradeRequest) => string | false; + /** When present, the engine also owns HTTP and routes calls here. */ + onHttpCall?: (call: HttpCall) => unknown; +} + +/** A node IncomingMessage look-alike synthesized from the uws upgrade. */ +export interface UpgradeRequest { + method: string; + url: string; + headers: Record; + socket: { remoteAddress: string }; +} + +export interface UwsEngineConnectionSource extends EventEmitter { + on(event: 'connection', listener: (socket: UwsSocket, req: UpgradeRequest) => void): this; + on(event: string | symbol, listener: (...args: unknown[]) => void): this; +} + +export interface UwsEngine extends Engine { + name: 'uws'; + standalone: true; + capabilities: EngineCapabilities; + app: UwsApp; + attach(options?: UwsAttachOptions): UwsEngineConnectionSource; + listen(options?: { host?: string; port?: number }): Promise<{ + address: string; + family: string; + port: number; + }>; + close(options?: { code?: number; reason?: string }): void; +} + +/** + * Standalone engine over uWebSockets.js: uws owns the whole network stack, + * so `Server` creates no node:http server and delegates listening. + */ +export declare function createUwsEngine(options?: UwsEngineOptions): UwsEngine; + +/** uws send() status codes, re-exported for adapters and tests. */ +export declare const BACKPRESSURE: 0; +export declare const SUCCESS: 1; +export declare const DROPPED: 2; diff --git a/uws.js b/uws.js new file mode 100644 index 0000000..cc34e70 --- /dev/null +++ b/uws.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./src/adapters/uws.js'); diff --git a/ws.d.ts b/ws.d.ts new file mode 100644 index 0000000..f20a695 --- /dev/null +++ b/ws.d.ts @@ -0,0 +1,228 @@ +import { EventEmitter } from 'node:events'; +import { IncomingMessage, Server as HttpServer } from 'node:http'; +import { Server as HttpsServer } from 'node:https'; +import { Socket } from 'node:net'; +import type { Duplex } from 'node:stream'; + +export declare const MAGIC: string; +export declare const CLOSE_TIMEOUT: number; +export declare const RSV1: number; + +export declare const OPCODES: { + readonly CONTINUATION: 0x00; + readonly TEXT: 0x01; + readonly BINARY: 0x02; + readonly CLOSE: 0x08; + readonly PING: 0x09; + readonly PONG: 0x0a; +}; + +export declare const CLOSE_CODES: { + readonly NORMAL_CLOSE: 1000; + readonly GOING_AWAY: 1001; + readonly PROTOCOL_ERROR: 1002; + readonly UNSUPPORTED_DATA: 1003; + readonly RESERVED: 1004; + readonly NO_CODE_RECEIVED: 1005; + readonly CONNECTION_CLOSED_ABNORMALLY: 1006; + readonly INVALID_PAYLOAD: 1007; + readonly POLICY_VIOLATED: 1008; + readonly MESSAGE_TOO_BIG: 1009; + readonly MANDATORY_EXTENSION: 1010; + readonly INTERNAL_SERVER_ERROR: 1011; + readonly TLS_HANDSHAKE: 1015; +}; + +export interface VerifyClientInfo { + req: IncomingMessage; + socket: Socket; + head: Buffer; +} + +export interface DeflateParams { + response: string; + threshold: number; + windowBits: number; +} + +export interface PerMessageDeflateOptions { + threshold?: number; +} + +export interface WebsocketServerOptions { + /** + * Binds to this server's 'upgrade' event. Omit it to drive handshakes by + * hand through handleUpgrade() — how middleware adapters (express) attach + * to a listener they do not own. + */ + server?: HttpServer | HttpsServer; + pingInterval?: number; + maxBuffer?: number; + /** Inflated-size cap for permessage-deflate messages. Default 16 MiB. */ + maxPayload?: number; + closeTimeout?: number; + /** Outbound-buffer cap; past it the connection is terminated. Defaults to maxBuffer; 0 = unbounded. */ + maxBackpressure?: number; + fragmentThreshold?: number; + path?: string; + verifyClient?: (info: VerifyClientInfo) => boolean; + protocols?: Array; + handleProtocols?: (offered: Array, req: IncomingMessage) => string | false; + perMessageDeflate?: boolean | PerMessageDeflateOptions; +} + +export declare class WebsocketServer extends EventEmitter { + constructor(options?: WebsocketServerOptions); + + /** Snapshot of the live connections (mutations do not affect the server). */ + readonly connections: Set; + + /** + * Performs one handshake on a raw socket. Use it from your own 'upgrade' + * listener when the server was constructed without `options.server`. + */ + handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void; + + close(options?: { code?: number; reason?: string }): void; + + on( + event: 'connection', + listener: (ws: Connection, req: IncomingMessage) => void, + ): this; + + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'close', listener: () => void): this; + on(event: string | symbol, listener: (...args: unknown[]) => void): this; +} + +export interface ConnectionOptions { + isClient?: boolean; + maxBuffer?: number; + /** Inflated-size cap for permessage-deflate messages. Default 16 MiB. */ + maxPayload?: number; + closeTimeout?: number; + /** Outbound-buffer cap; past it the connection is terminated. Defaults to maxBuffer; 0 = unbounded. */ + maxBackpressure?: number; + fragmentThreshold?: number; + protocol?: string; + deflate?: DeflateParams | null; +} + +export declare class Connection extends EventEmitter { + constructor(socket: Socket, head: Buffer, options?: ConnectionOptions); + + /** Subprotocol selected during the handshake, '' when none. */ + protocol: string; + /** Bytes queued in the socket's writable buffer, not yet flushed. */ + readonly bufferedAmount: number; + /** True while socket reads are paused via pause(). */ + readonly isPaused: boolean; + readonly remoteAddress: string | undefined; + + send(data: string | Buffer): boolean; + sendText(message: string): boolean; + sendBinary(buffer: Buffer): boolean; + sendPing(payload?: Buffer | string): boolean; + sendPong(payload?: Buffer | string): boolean; + sendClose(code?: number, reason?: string): void; + /** WrpcSocket engine-contract alias for sendClose. */ + close(code?: number, reason?: string): void; + terminate(): void; + pause(): void; + resume(): void; + + /** + * Received payloads may share memory with the socket receive buffer; + * copy them when retaining beyond the listener call. + */ + on( + event: 'message', + listener: (data: Buffer, isBinary: boolean) => void, + ): this; + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'close', listener: (code: number, reason: string) => void): this; + on(event: 'ping', listener: (payload: Buffer) => void): this; + on(event: 'pong', listener: (payload: Buffer) => void): this; + on(event: 'drain', listener: () => void): this; +} + +export declare class Frame { + fin: boolean; + rsv: number; + opcode: number; + masked: boolean; + payload: Buffer; + mask: Buffer | null; + constructor( + fin: boolean, + opcode: number, + masked: boolean, + payload: Buffer, + mask: Buffer | null, + rsv?: number, + ); + static text(message: string, fin?: boolean, masked?: boolean): Frame; + static binary(buffer: Buffer, fin?: boolean, masked?: boolean): Frame; + static ping(payload?: Buffer | string): Frame; + static pong(payload?: Buffer | string): Frame; + static close(code?: number | null, reason?: string): Frame; + unmaskPayload(): void; + maskPayload(): void; + toString(): string | null; + toBuffer(): Buffer; + readonly header: Buffer; + readonly isControlFrame: boolean; +} + +export declare class ParseError extends Error { + code: string; + constructor(code: string, message: string); +} + +export declare const PARSE_ERR_CODES: { + readonly MESSAGE_TOO_BIG: 'MESSAGE_TOO_BIG'; + readonly PROTOCOL_ERROR_COMMON: 'PROTOCOL_ERROR-COMMON'; + readonly PROTOCOL_ERROR_RSV: 'PROTOCOL_ERROR-RSV'; + readonly PROTOCOL_ERROR_CTRL_TOO_LONG: 'PROTOCOL_ERROR-CTRL_TOO_LONG'; + readonly INVALID_PAYLOAD: 'INVALID_PAYLOAD'; +}; + +export interface FrameHeader { + fin: boolean; + rsv: number; + opcode: number; + masked: boolean; + mask: Buffer | null; + length: number; + headerSize: number; +} + +export interface FrameParserOptions { + /** Bitmask of RSV bits negotiated via extensions (RFC 6455 5.2). */ + allowedRsv?: number; +} + +export declare class FrameParser { + static parseHeader( + buffer: Buffer, + options?: FrameParserOptions, + ): { + value: FrameHeader | null; + error: ParseError | null; + }; + static parse( + buffer: Buffer, + options?: FrameParserOptions, + ): { + value: { frame: Frame; bytesUsed: number } | null; + error: ParseError | null; + }; + static checkControlFrame(frame: Frame): { + value: boolean | null; + error: ParseError | null; + }; + static checkDataFrame(frame: Frame): { + value: boolean | null; + error: ParseError | null; + }; +} diff --git a/ws.js b/ws.js new file mode 100644 index 0000000..f614e29 --- /dev/null +++ b/ws.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./src/websocket/ws.js');