wrpc v1: Enhance WebSocket and RPC features with security, performance, and documentation improvements - #2
Merged
Merged
Conversation
Fix WebSocket heartbeat dead-peer cleanup (removed from #connections synchronously, guard against stale entries in the ping tick, unref the ping timer) and normalize Connection.sendPing/sendPong to uniform boolean returns; keep pong available during the close handshake per RFC 6455 5.5.3. Fix WrpcClientProxy.open() checking a non-existent `connected` flag instead of `active`; respond 404 for HTTP requests outside /api instead of hanging; default listen()'s bind-retry delay so `timeouts` is optional; make Client.emit() return a Promise to match the base Emitter contract. Reconcile index.d.ts with the actual runtime surface: sendClose is void, transport subclasses are type-only exports, the real `proxy` client option replaces the phantom packetHandler/binaryHandler, Server extends Emitter, WrpcClient's transport parameter is typed as ClientTransport, stream methods and transport close() are declared, and the phantom Options.kind/ports and ApplicationContext.static are removed. Add regression tests for all of the above (heartbeat cleanup, retry path without timeouts, Promise-returning emit, etc.), grow the tsd suite from 15 trivial assertions to ~45 including expectError negatives, and fix the stale "not implemented yet" getting-started guide and README quick start (wrap the example in an async function so it's actually runnable).
… completeness (F1) Add end-to-end write backpressure through the whole send path: Connection tracks bufferedAmount and emits 'drain', data sends return honest booleans instead of always true, and a new maxBackpressure option terminates peers that stop reading. The same signal now propagates through WrpcWritable and ServerWsTransport, and the RPC server pauses the socket while binary stream chunks are being consumed so a slow consumer applies pressure to the peer over TCP. WrpcReadable's high-water mark only kicks in once a consumer has attached, since blocking earlier deadlocks the upload-then-call wire pattern (chunks arriving before the call that starts reading them); a closed transport now releases any writable stuck waiting on a 'drain' that will never come. Replace the per-TCP-segment Buffer.concat receive path (O(n^2) on large messages) with a SegmentQueue that copies bytes at most once, parsing frame headers incrementally so oversized frames are rejected before their payload is buffered. Unmask 32 bits at a time through a Uint32Array view (~8x the byte-wise loop, verified in bench/unmask.js), falling back to the byte-wise path on big-endian hosts where the word composition would otherwise corrupt payloads silently. Round out RFC 6455/7692 support: Sec-WebSocket-Protocol negotiation (protocols[] or a handleProtocols callback), outgoing fragmentation via fragmentThreshold, an inbound 'ping' event, WebsocketServer.close() with a graceful peer notification and a connections snapshot getter, and permessage-deflate off by default (both directions pinned to no-context-takeover so one-shot zlib suffices, UTF-8 validated after inflation, malformed extension headers rejected with 400 per RFC 6455 4.2.1 while well-formed-but-unacceptable offers are declined per RFC 7692 7). The heartbeat now skips paused connections instead of killing them for a pong it can't read while paused. Split the engine out of the main barrel into the new @alexify/wrpc/ws subpath (own ws.d.ts), add engine benchmarks, an Autobahn Testsuite harness, a 1 GiB stream memory guard, and a nightly CI workflow to run both without blocking PRs.
CHANGELOG referenced a nightly CI workflow that ran the Autobahn harness and stream memory guard; it was removed, so drop the stale claim. pnpm test:perf and scripts/autobahn/ still exist and can be run manually, they're just no longer wired into CI.
…r-agnostic core
Kill the getMethod/application dispatch model: Server now takes a single
options object built around a Router from defineRouter, with procedures
declared via procedure({ access, handler, input, output, timeout, queue,
meta, signature }) — versions as 'unit.ver' keys, bare-function shorthand,
input/output validation (plain functions or Standard Schema) mapped to
400/500, per-procedure concurrency via a new Semaphore (503 on overflow),
and an auto-registered system/introspect so client.load() works without
hand-rolled introspection.
Split the server into an engine-agnostic RpcServer core (attachSocket,
attachPort, handleHttpCall over an abstract {method, url, headers, body,
respond} call — the seam framework adapters will use) and a batteries-
included Server shell composing it with node:http(s) and a WebSocket
engine. Add the @alexify/wrpc/engine subpath: a WrpcSocket/Engine port
contract with capability flags, createNodeEngine() wrapping the built-in
implementation, and a shared engine contract test suite for later
adapters to reuse.
Replace the module-global session Map with a per-server SessionManager
over a structural SessionStore (MemorySessionStore by default, anything
store-shaped injects via sessions.store). Session cookies are read back
on both HTTP calls and WS upgrades, default to HttpOnly/Secure/SameSite=
Lax, and sessions now survive disconnects. Since sessions outlive their
connection, MemorySessionStore is bounded (LRU + TTL) to prevent
unbounded growth from anonymous connections.
Because HTTP calls now restore sessions from an ambient cookie, add a
CSRF gate: safe methods (GET/HEAD) on the REST endpoint dispatch without
the cookie-restored session unless the request's Sec-Fetch-Site header
proves same-origin intent. Also fix a request/client leak where an HTTP
call that never produced a response (e.g. a misdirected stream packet)
left its Client and socket dangling forever.
Round out basePath handling and CORS (origins allow-list or function,
credentials, Vary: Origin) applied uniformly across HTTP and WS upgrade
gating.
Extend the Engine port with a second kind: a hosted engine (the default)
attaches to a listener someone else owns, while a standalone engine
(uWebSockets.js) owns the whole network stack including node:http — it
is attached without a server and given the core's HTTP entry point as
onHttpCall, and must implement listen(). Server.httpServer is null under
a standalone engine; use the new Server.address() instead of
server.httpServer.address(). WebsocketServer's server option is now
optional, and the new public handleUpgrade(req, socket, head) drives one
handshake by hand — what lets a middleware adapter perform the upgrade
on a listener it does not own.
Add three subpaths, each with a root shim, hand-maintained d.ts, and
tests/<name>.test-d.ts, following the established convention:
- @alexify/wrpc/uws — createUwsEngine() normalizes uWebSockets.js onto
the WrpcSocket contract: tri-state send() collapses to a boolean, a
dropped message fails loudly instead of leaving a hole in the frame
stream, payloads are copied at the callback boundary (uws neuters the
ArrayBuffer on return), remoteAddress is captured during upgrade, and
a poisoned-handle guard keeps every method quiet after close (every
uws call throws past that point). capabilities.ping/pause are false:
uws owns liveness itself and offers no receive-side flow control.
- @alexify/wrpc/fastify — wrpcFastify plugin picks a node or uws backend
by feature detection (a plain fastify() vs fastify-uws's serverFactory,
the latter's app located through its private uws.app symbol). HTTP
calls run through real fastify routes, so hooks and auth run before
wrpc sees the call. Decorates the instance with wrpc and tears it down
on preClose.
- @alexify/wrpc/express — createWrpc() returns { handler, upgrade, close }
middleware that owns no listener; a request outside basePath falls
through to next() instead of answering 404, so wrpc composes with the
rest of the app.
Generalize the shared engine contract suite (tests/engine/engineContract.js)
to boot both engine kinds via hostedHarness/standaloneHarness, and add a
swap test running one behavioral spec against all five ways of standing
wrpc up (Server over each engine, the fastify plugin over each backend,
express middleware).
Fixes found and covered by regression tests while building this out:
a double-close of uws's listen socket that segfaulted at process exit;
onAbort wired to fastify's request stream instead of the response,
which destroyed the HTTP client before its handler even ran; and the
fastify plugin defaulting to fastify's pino logger, which has no .log
method and made every successful WebSocket call answer twice (a result
followed by a spurious 500).
…rowser entry scripts/size.js only ever bundled browser.js, so the CI-visible size table said nothing about the five subpaths added since (ws, engine, uws, fastify, express) or the plain Node main entry. Bundle every package.json#exports entry instead: the browser-condition bundle keeps its node-builtins guard (that is the one that actually ships to a browser), the Node-only subpaths bundle with platform: 'node' and no such guard, since requiring node:http et al. is the point of them. None of the adapters require their host framework at module load — uws, fastify, and express are injected by the caller — so all six Node bundles resolve cleanly with nothing external to stub out.
pnpm bench crashed on bench.js and the wrpc stack of rpc-comparison.js:
wrpc-echo.js still constructed a Server the pre-Ф2 way, new Server(application,
options) with a hand-rolled getMethod()-based application object, which the
router refactor removed entirely in favor of new Server({ router, ... }).
Rebuild createWrpcServer's application shim as a real Router via
defineRouter/procedure instead. The bench-local api shorthand
({ unit: { method: { handler(args, context) } } }) used by bench.js and
rpc-stacks.js is left untouched — the adapter swaps the handler argument
order onto procedure()'s (context, args) and defaults access to 'public',
matching the old ProcedureMock's default. Also drops the queue/generateId
options ServerOptions no longer has, and reads the bound port through the
new server.address() instead of server.httpServer.address().
The express and uws adapters both take a maxBodySize because they read the request stream themselves; the fastify plugin never does — fastify parses the body and hands over request.body — so its own bodyLimit (1 MiB by default) already guards the RPC routes and answers 413 FST_ERR_CTP_BODY_TOO_LARGE before the handler runs. Verified: a 4 KiB call against bodyLimit 512 is rejected with no adapter code involved. So the gap was never the limiting, only the configuration surface: the option existed on two adapters out of three. Map maxBodySize onto fastify's route-level bodyLimit for the RPC routes. Left unset it applies nothing, deliberately — MAX_BODY_SIZE (10 MiB) is NOT used as a default the way it is in the express and uws adapters, because a plugin silently raising the host app's 1 MiB limit would be a security regression the app never asked for. The option can only narrow. The error shape stays fastify's: an oversized body fails in its content-type parser, ahead of the wrpc handler, so it answers fastify's 413 JSON rather than a wrpc callback packet. Intercepting that would mean fighting the framework, and a fastify user expects the framework's error.
…resilience (F4)
Realtime + scaling core, per the F4 implementation plan.
Rooms: RoomRegistry + immutable chainable Broadcast (src/rpc/rooms.js).
client.join/leave/rooms/in, server.to(...).except(...).local().emit(),
server.broadcast(). Layered on the existing `{type:'event'}` packet, no
new wire type. Context.server/Client.server let a handler reach rooms
without closing over a server that couldn't exist yet when the router
was built. to() with no rooms reaches nobody, never everybody.
Client -> server events: a unit's reserved `on` key holds inbound event
handlers (ordinary Procedures, so access/input/queue apply). Client side:
client.sendEvent('unit/event', data); an event that reaches no listener
surfaces as 'unhandled-event' instead of vanishing.
@alexify/wrpc/scaling: structural backplane contract, MemoryBackplane,
and an ioredis-shaped createRedisAdapter({ pub, sub, prefix }) with
clients injected and validated structurally (ioredis stays out of
devDependencies). Cross-instance fan-out publishes { instance, rooms,
name, data } envelopes with echo suppression and per-room channels;
delivery is honestly at-most-once.
Client resilience: reconnect backoff with full jitter (reconnect:
{ minDelay, maxDelay, factor, jitter, retries }), an app-level
ping/pong heartbeat (browsers expose no protocol-level ping), and a
'reconnect' event that rebuilds api units while reusing their Emitter
objects so registered listeners survive the outage.
Also fixes found via adversarial review of this change: event/pong
packets over HTTP used to hang the request and leak the server-side
Client; a non-string packet target could crash the process via an
unhandled rejection; a socket abandoned by the new terminate() could
close its replacement; client-side unit lookup went through
Object.prototype; reconnectTimeout above the default cap silently
reconnected faster than asked; the reconnect timer's unref() let a
process exit mid-outage; the Redis adapter leaked the subscriber it
duplicated for itself.
Also fixes the WebSocket engine: per RFC 6455 5.5.1, the side answering
a peer's Close now hangs up immediately instead of arming the same
closeTimeout as the initiator, dropping every graceful disconnect from
~1s to a few ms.
730 tests (up from 573), coverage 98.13/92.87/95.44/98.13, lint/format/
tsd/docs build clean.
Wire protocol v2, per the F5 implementation plan.
Subscriptions: a procedure that answers with a stream of values instead
of one. An async generator handler IS the declaration (procedure.subscription
is only needed for a plain function returning an async iterable);
introspection carries `kind` so the client scaffolds subscribe()/iterate()
instead of a callable. The pump (src/rpc/subscriptions.js) respects
transport backpressure and always answers exactly one `end` — completion,
a thrown error, an unsubscribe, or a disconnect — then closes the
generator so its `finally` runs.
Resume: tracked(eventId, data) labels a value; the client remembers the
last one and sends it back as lastEventId after a reconnect.
createEventLog({size}) is the ring buffer behind that — since(lastEventId)
returns what was missed, or null when the id has fallen out of the
buffer, so a caller can tell a real gap from nothing missed.
Cancellation: client.api.unit.method(args, {signal}) sends {type:'cancel'},
the caller is rejected with 499, and ctx.signal is aborted. Best-effort by
nature — a handler that ignores its signal keeps running — but its late
result is dropped rather than delivered to a caller that already gave up.
Batching: batch:{flush,maxSize,maxBytes} coalesces calls issued in one
tick into a single frame. Only `call` packets batch; a ping, cancel or
unsubscribe is a control packet whose whole point is to arrive now. On
HTTP the answers come back as one array in request order.
@alexify/wrpc/sse: Server-Sent Events as a full transport, not just a
one-way feed. A channel is two halves joined by an id — GET
{basePath}/events for server->client, POST with x-wrpc-channel for the
other direction — both bound to ONE server-side client, which is what
lets a subscription opened by a POST deliver its values down the stream.
A dropped stream doesn't destroy the channel: it's held for `retention`,
so a reconnect with Last-Event-ID re-attaches and replays what was
missed, subscriptions included. The client half is browser-safe (fetch +
a hand-written incremental parser, deliberately not EventSource, which
can't set headers or be aborted cleanly).
Verified with a 47-agent adversarial review after implementation; 13 of
17 confirmed defects fixed in this change (SSE reconnect killing live
subscriptions instead of resuming them, the pump parking forever after a
stream re-attach, a stale writer's close tearing down the live one,
fastify never routing the events endpoint, cancel/unsubscribe registered
after the first await, one bad batch answer stranding the rest, and
more). Four lower-priority findings (CORS on SSE channel POSTs, an
aborted HTTP batch not force-answering, a malformed batch element losing
its id, SSE channels not restoring the session cookie) are tracked
separately, not yet fixed.
827 tests (up from 730), coverage 97.22/91.10/95.02/97.22, lint/format/
tsd/docs build clean.
…, session restore) An adversarial multi-agent review of F5 (subscriptions, batching, cancellation, SSE) found 17 defects; 13 were already fixed. This closes the remaining four: - Cross-origin SSE could not work at all: the channel POST answered its 202/404 without CORS headers, and the default Access-Control-Allow-Headers omitted x-wrpc-channel/last-event-id, failing the preflight before the request was ever sent. - An aborted HTTP batch never answered: close() in batch mode only collected one more answer instead of responding, hanging the request and leaking the client. - A malformed batch element lost its id on a structure error, breaking the response array's documented positional ordering. - An SSE channel's Client never restored its session from the opening GET's cookie, so access:'session' procedures stayed 403 on that channel. Each fix has a regression test (verified to fail against the pre-fix source) in tests/rpc/batching.test.js and tests/sse/sse.test.js. index.d.ts and sse.d.ts updated to match; lint/format/types clean; coverage held at 97.25/91.07/95.02/97.25.
Contract-first typed client, `wrpc types` codegen, and the @alexify/wrpc/query
subpath — no TypeScript at runtime, no new runtime dependencies.
- Typed client: `connect<Api>(url)` (a one-line alias of WrpcClient.connect)
threads a hand-written contract interface through `client.api`. A zero-arg
procedure keeps its wire args slot instead of collapsing into the options
position — the naive tuple-spread version silently shipped `{signal}` as
the call's arguments and dropped cancellation, confirmed against a live
server before the fix. A contract key named `on` is excluded from mapping
so it stays the unit's Emitter listener method rather than colliding with
it. New utilities: TypedApi/TypedUnit/TypedMethod/TypedParams,
InferArgs/InferResult/FirstArg, SubscriptionContract, UntypedApi, IsAny,
InvalidContractMember.
- `wrpc types <url> --out api.d.ts`: generates the same contract shape from
a running server's `system/introspect`. Specifies the `signature`
descriptor format (docs/reference/protocol.md) as a closed, allowlisted
shape language — nothing from the wire is interpolated into the output
unescaped, nesting is depth-capped, and an e2e test compiles the generated
file against the real typed client with tsc.
- `@alexify/wrpc/query`: framework-agnostic TanStack Query option factories
(queryOptions/mutationOptions/subscriptionHandler), zero `require()`,
~1 KB min+gzip. Paths resolve lazily via own-property lookups so a unit's
inherited Emitter methods (on/emit/etc.) can't be mistaken for procedures,
and a cache-write failure inside a subscription's onData is now routed to
onError instead of escaping into the client's packet dispatch.
- scripts/size.js: replaced the substring-based "no node builtins" check
with an esbuild resolve plugin that fails a browser bundle on ANY non-
relative import (catches accidental devDependency inlining, e.g.
@tanstack/query-core), and added per-entry min+gzip budgets that fail the
build when exceeded.
891 tests (was 837); src/cli and src/query at 100% coverage on all four c8
metrics. lint/format/tsd/size/docs all green.
client.close() ended stream-based (iterate()) subscriptions via record.stream?.end() but never told callback-based (subscribe()) consumers anything — onEnd never fired, so a listener had no signal its feed was dead. This mattered most for the @alexify/wrpc/query cache bridge, whose subscriptionHandler is callback-based. close() now runs the same terminal sequence an `end` packet does (onRelease -> onEnd -> stream.end()) for every subscription it carried, factored into a shared #endSubscription() used by both the close() path and the existing end-packet path. unsubscribe() stays silent by design — the caller named that one feed and already knows — but close() is usually invoked by unrelated code (a page teardown, a shutdown hook), so the code that owns the subscription never asked for it to stop. Two related defects surfaced from the same code path and are fixed alongside: - A throwing onEnd/onError listener used to skip stream.end() entirely (on the ordinary end-packet path too, not just close()), leaving an iterate() consumer parked in next() forever — confirmed by reverting the fix, which turns the regression test into a 2-second hang instead of a fast failure. Each listener is now contained and escalated through the client's 'error' channel, so one bad listener can't strand the next one's signal or abort the rest of close()'s teardown. - close() didn't call the internal onRelease hook, so an iterate() consumer holding a caller-supplied AbortSignal left its 'abort' listener registered after the client was gone. close() also snapshots and clears the subscription registry before notifying anyone, so a listener that reacts by unsubscribing or closing again sees nothing left to do instead of mutating the map mid-iteration. 4 new regression tests in tests/rpc/subscriptions.test.js, each verified to fail (or hang) without the fix. 895 tests passing; coverage/size/docs gates green.
Build out the F7 documentation phase: a complete VitePress guide/reference structure (16 guide pages, 3 reference pages), a frozen 1.0 wire protocol, a rewritten README with a real feature/positioning table and bundle-size report, and a CONTRIBUTING.md with the manual release checklist. - docs/reference/protocol.md is frozen as 1.0: replaces the "pre-1.0, anything may break" warning with an explicit stability contract (packet types and field meanings fixed within 1.x, additive optional fields only, unknown types still answered with a 500 callback). - New docs/reference/wire-format.md (binary chunk framing, the RFC 6455/7692 engine, backpressure accounting, payload ownership) and docs/reference/engine.md (the Engine/WrpcSocket port, hosted vs standalone, capabilities, contract-testing a new engine). - New guide pages: server, router, sessions, rooms, subscriptions, streams, scaling, client, typed-client, cli, query, sse, and one per adapter (uws, fastify, express). getting-started.md is rewritten — the old version still documented the application.getMethod() contract F2 removed, so its quick start didn't run. - docs/.vitepress/config.mts gets the full nav/sidebar grouping, keywords, JSON-LD, and per-page canonical URLs, mirroring the kerberos site. - README rewritten: badges, a tRPC/Socket.IO positioning table, an honest "When NOT to use wrpc", an exports table with one deep-linked row per subpath, and the bundle-size table from `pnpm size`. - CONTRIBUTING.md added with the dev workflow, the house rules not visible in code (zero deps including no peers/optionals, .d.ts + tsd land together, the uws teardown trap that wedges node --test), and a 10-step manual release checklist (D4: nothing here bumps a version, tags, or publishes). - CHANGELOG [Unreleased] gains a Documentation section; a note at the top separates JS-API semver from the wire protocol's own stability promise. - Also drops "low-overhead"/"low overhead" from the description wherever it appeared (README, package.json description + keywords, CLAUDE.md, VitePress config og description + keywords). Verified: docs:build (dead-link check), lint, format:check, size (all budgets met), test:types, and test — 895/895 passing.
… audit Audited the implementation plan against the actual codebase (all 8 phases, Ф0–Ф7) — everything checked out except three verification harnesses that existed in code but were never exercised anywhere: - tests/scaling/redis.integration.test.js: the scaling backplane against a real Redis, not just the in-repo ioredis-shaped fake in redis.test.js. Manual/local only (REDIS_URL=... node --test ...) — pnpm test already covers the adapter's contract via the fake, and this needs a live server. ioredis joins devDependencies for it and nothing else. - bench/rpc-comparison.js: socket.io and tRPC (over wsLink) join the compared stacks, alongside the existing raw transports. Each measurement now also runs pipelined at 64 calls in flight next to the sequential one, since a stack with fixed per-call latency (tRPC: 0.03x sequential vs 0.22x pipelined) reads very differently under the two. Autobahn (scripts/autobahn/run.js) and the 1 GiB stream memory guard (pnpm test:perf) stay manual/local by design — not wired into CI. No library code changed. CLAUDE.md/CONTRIBUTING.md/CHANGELOG.md updated to describe the new manual checks and the devDependency additions (ioredis, socket.io, socket.io-client, @trpc/server, @trpc/client) — all test/bench-only, zero-dependency guarantee unaffected.
Every diagnostic in wrpc went through a `console` option typed as `Console`
and threaded by hand into a dozen constructors. That shape could not carry a
callId, a peer or a duration; it could not be turned off (ten test files
worked around this with a five-noop object); and it forced the fastify
adapter to downgrade a real pino to a Console before handing it over.
`src/logging.js` is a zero-import module — the same shape kerberos uses —
exporting one `createLoggerWriter(logger)` factory. It normalizes three
inputs into one writer with a fixed five-level method set:
- a structured logger (pino, bunyan, winston), identified by `child` or
`level` and called as (entry, message)
- a Console, called as (message), entry dropped
- nothing, in which case every method is a frozen no-op singleton
Call sites never branch: the disabled writer has the identical shape, and
its `child()` returns itself, which is what makes the per-connection and
per-call bindings free when logging is off. A logger that throws is
contained inside the writer — once — rather than at each of the thirty call
sites.
Threading follows the option funnel: `logger` replaces `console` in
RPC_OPTION_KEYS, so none of the four RpcServer construction sites can drop
it. RpcServer builds `rooms`/`sse`/`sessions` children once, Client binds a
connection-scoped `{ peer }` child eagerly, and Context binds a call-scoped
`{ callId }` child lazily — Context is allocated per packet and most
handlers never log.
The fastify adapter's `toConsole` shim is gone: fastify.log is a pino, and
the core now detects and calls it natively.
The package is unpublished, so `console` is removed outright rather than
deprecated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The client had no logger option at all. Its whole error path was `#escalate`,
which printed to the global console only when nobody was listening for
'error' — so an application that DID listen got no diagnostics beyond
whatever its own handler chose to do.
`WrpcClient` now takes a `logger`, off by default: unlike the server, a
client that printed on every reconnect would be noise in a browser console
nobody asked for. `#escalate` logs *and* emits — a logger observes, a
listener handles — with a distinct `event` at each of its nine call sites, so
a transport failure is distinguishable from a batch dispatch failure. Open,
close, reconnect scheduling and heartbeat timeouts get lines of their own.
`WrpcClientProxy` rebuilds its own options bag, so `logger` is forwarded
explicitly; without that a Service Worker proxy would silently lose it.
Three server-side paths that reached nobody are now reported:
- a subscription that dies server-side answered `end` and logged nothing
- `runSubscription` dropped the generator's error entirely when aborted
- `jsonParse(data) || {}` conflated "malformed" with "empty", hiding every
unparseable packet in the system behind one `||`
`handleStream`/`handleBinary` needed nothing: they answer through
`client.error`, which already logs.
`ServerSseTransport.writeFrame`'s silent catch is left alone — the transport
classes are deliberately option-free, and its close listener already handles
the dead response.
Browser bundle 7.1 -> 7.7 KB min+gzip of a 10 KB budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wrpc had no instrumentation of any kind: no durations, no counters, no hooks.
`src/telemetry.js` adds them the same way `src/logging.js` added logging — a
zero-import module whose `createServerTelemetry(telemetry)` returns a writer
of one fixed shape, disabled or not, so no call site branches.
Two injection modes, matching kerberos: `{ api }` (the @opentelemetry/api
module, from which wrpc derives a tracer and meter carrying the
`@alexify/wrpc` scope) or `{ tracer, meter }` directly. Tracer-only and
meter-only both work. @opentelemetry/api is never imported: the two spec-
frozen constants it would be needed for — SpanStatusCode.ERROR = 2 and the
SpanKind values — are hardcoded, and the scope version is omitted so this
file never has to require package.json.
Spans follow the OTel `rpc.*` semantic convention ($service/$method) rather
than a wrpc-specific naming scheme, because that is what APM tools group by.
The call span brackets the whole invocation — session wait, access check,
validation, timeout race — so an argument error gets an error span and a
duration sample exactly as a slow handler does. `startActiveSpan` is
preferred so a handler's own spans parent correctly, with the `invoked` flag
that lets an error from the callback propagate untouched while a tracer that
broke before running it is swallowed. Spans are ended from the caller's
`finally`, never inside the wrapper.
Twelve instruments cover calls, durations, connections, subscriptions and
values yielded, broadcasts and fan-out size, stream bytes, backpressure,
sessions and SSE channels. `createUpDownCounter` is feature-detected
separately from the counters — checking them together would let a meter
missing one silently disable all twelve.
`wrpc.server.dropped` from the plan is NOT here: `EventStream.dropped` has no
owner on the server side to read it, and a counter nobody feeds is worse than
an absent one.
Every recording method contains its own failures. A broken exporter, a
throwing meter, a span missing half its methods — none may turn a working
call into a failed one, and the suite asserts each of those.
Server transports gained a `kind` field ('ws' | 'http' | 'sse' | 'event') —
the metric attribute and log field that identifies which wire a client is on.
Browser bundle unchanged at 7.7 KB: telemetry.js is reached only from the
server core, so it stays out of the browser graph.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kerberos runs in one process, so delegating context propagation to the OTel
context manager is enough there. wrpc's caller is in another process — often
another machine, often a browser — so without something on the wire every
server span is a root and end-to-end tracing, the whole point, does not
exist.
`call`, `subscribe` and `event` packets may now carry two optional fields:
`tp` (W3C traceparent) and `ts` (tracestate). Short names because they ride
on every call packet — "traceparent" would cost eleven more bytes per call
for nothing. The context is per PACKET, not per frame, so each call in a
batch keeps its own parent; there is a test for exactly that.
wrpc does not parse or serialize W3C trace context — that is spec surface
that drifts. It calls `propagation.inject`/`extract` with a setter and getter
that only rename the header keys to the packet's field names, so whatever
propagator the application configured (W3C, B3, Jaeger) is what runs.
Consequently a bare `{ tracer, meter }` gets local spans only: propagation
needs `{ api }` or an explicit `propagation`, and that is documented rather
than papered over.
The parent-context overload of `startActiveSpan` is feature-detected on
arity. Handing four arguments to a three-argument implementation means the
callback is never invoked at all — the call would answer nothing — so the
guard is a correctness fix, not a nicety. A test drives a 3-arg tracer.
`trustRemoteContext` defaults to true, as in gRPC and every HTTP
instrumentation: a hostile peer can forge trace ids, the mitigation belongs
at ingress, and defaulting to false would mean the marquee feature did
nothing out of the box. Both settings are tested.
Adding the client half to src/telemetry.js pushed the browser bundle to
10.1 KB, over its 10 KB budget. Per the pre-agreed escalation the module was
SPLIT rather than the budget raised: src/telemetry/{shared,server,client}.js,
with src/client.js requiring the client half directly so the server's twelve
instruments never enter a browser graph. Browser is back to 9.2 KB.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…(F12)
Two new guide pages under a new "Operations" sidebar group:
- Logging — what `logger` accepts, how the structured and Console shapes are
told apart (`child`/`level`, and why guessing "Console" is the safer
default), the bindings wrpc builds for you, `context.log` in a handler,
and `logger: false`.
- OpenTelemetry — both injection modes, the span and attribute tables, all
fourteen metrics, the privacy rules, trace context on the wire, and the
trustRemoteContext trade-off stated with its risk rather than buried.
The fastify adapter page no longer describes the `toConsole` shim, which is
gone: `fastify.log` is a pino and goes in as one.
README gains an Observability section between Features and Exports, and the
Features table gains its own row. Keywords are synced in both package.json and
the docs config, which mirror each other by hand.
The CHANGELOG entry is written for someone deciding whether to adopt this:
what the option does, what it replaces, and why `console` was removed outright
rather than deprecated (nothing has been published, so there is nobody to
migrate).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deep review found 73 findings (3 critical, 27 high, 36 medium, 7 low) across maintainability, performance, security, scalability, resilience, governance, and observability. This closes all Part I findings plus two new features, in 6 phases. Security: server-minted SSE channel ids bound to session identity (closes the SSE takeover critical), compression-bomb ratio caps, finite backpressure defaults, HTTP-path CORS enforcement, 5xx message masking, __proto__-safe session state, RFC 6265 cookie validation, maxBodySize/maxCalls/introspection options. Features: fastify-style lifecycle hooks (onRequest..onTimeout, onConnect/onDisconnect, onSubscribe/onUnsubscribe) at router/unit/ procedure scope, flattened to frozen arrays at router build time — deliberately not middleware/next(). Pluggable generateId on client and server (kerberos pattern). wrpc.v1 subprotocol negotiation. Resilience: reconnect no longer kills subscriptions when load() fails, in-flight calls reject on disconnect instead of hanging out the full timeout, graceful drain/close with 1001, client streams and Redis subscriber handle disconnects/errors properly. Scale-out: broadcast fan-out now serializes once instead of once per recipient (8k -> 30k emits/sec at 200 clients), honest SSE gap signaling, epoch-stamped event log ids for cross-node resume, session touch(), room re-join on reconnect. Observability: unresolved method/event names no longer mint metric series or spans, span/metric attributes aligned to RPC semconv, disabled-telemetry fast path skips allocation, not just export. Hot path: native buffer.isUtf8 for frame validation (closes most of the perf gap vs ws — wrpc now beats ws on 10KB payloads), removed per-frame allocations, single cork per fragmented send. Packaging: browser.d.ts/sse.browser.d.ts split so browser consumers don't need @types/node, package consistency test, SHA-pinned CI actions, SECURITY.md, stability/deprecation policy.
`pnpm bench` wedged forever with no output after "HTTP call round-trip — small payload". The cause was a stale call site, not a leaked handle: the `notify` handler in bench.js still called `context.client.emit(...)`, which the v1 rename turned into the local Emitter emit. The server was emitting to itself, the client never saw 'bench/ping', and the next bench awaited a promise that could not settle. Switched to `sendEvent`, the wire send. This was the only stale wire-send left in the tree — the `client.emit` uses in tests/server.internals.test.js and tests/index.test-d.ts deliberately cover the local pair. Also made this class of failure loud. The first warmup call in the harness now races a 10s timer, so a bench that cannot settle throws with its own name and run-all.js propagates a non-zero exit. Only the first call is guarded: a per-iteration timer would allocate inside the measured loop and skew the numbers the harness exists to report. Lastly, protocol.md still documented server→client events as "emitted by client.emit(name, data)", which the same rename invalidated. Now it names sendEvent and room broadcast only.
Closes the two remaining competitive gaps vs socket.io — cluster-wide
introspection and acknowledged events — and goes past parity on both.
Cluster (server.cluster, new src/rpc/cluster.js): built ON TOP of the
unchanged publish/subscribe/close backplane contract, riding two new
channels ('cluster' + per-node 'inst:<id>'), so every adapter gets
cluster operations for free. Always present: without a backplane every
operation degrades to its local half.
- Replicated presence: join/leave deltas + corrective snapshots +
newcomer hello answered with addressed state + liveness on all
traffic + graceful bye + epoch (restart replaces, never doubles).
count()/presence() are local sums (~65M reads/sec) — socket.io's
cluster count is always a fetchSockets round-trip.
- fetchClients({room}?) with early completion by presence (node death
mid-request completes via eviction; timeout resolves partial with
incomplete:true, never silently).
- Commands: join/leave/disconnect by client id — ids are now
instance-prefixed, so an addressed command is ONE message to ONE
node — or by {room} selector applied everywhere.
- Node-to-node messaging: cluster.sendEvent/on and cluster.ask/respond
(the serverSideEmit pair). client.data + rpc.getClient(id) added.
Acks: a server→client event may carry an id, answered by the ordinary
callback packet — no new wire type. client.ask() on the server (408
timeout / 503 disconnect / 501 no responder), client.respond() on the
browser (client-level, so server-named unit methods cannot collide),
to(room).ask() aggregating {answers, errors, expected, incomplete}
cluster-wide with two-phase count+ack accounting. Encode-once survives:
one JSON.stringify per fan-out (per-recipient id is a suffix concat),
guarded by a serialization-counting regression test.
A 20-agent adversarial review of the new code confirmed and fixed 11
defects before commit, each now regression-tested: empty-target ask
leaking to every remote client (the WHERE-id-IN-() mistake returning
through the wire), HTTP callback packets hanging the request and its
whole batch, stale bye evicting a reborn node, epoch replacement
leaving requests waiting forever, answers processed after the epoch
sweep that discards them, unserializable questions waiting out the
full timeout, dishonest incomplete on mid-bask eviction, missing
Cluster export, missing Server shell delegation, unbounded
peer-controlled ids in logs, and VitePress anchors that never resolved.
… rule Fast-path Emitter.emit for the single-sync-listener case (3.7x awaited, 13.8x fire-and-forget — the shape every per-message/per-chunk emit() call actually is), condition ServerHttpTransport#ordered's batch reordering on a measured size threshold instead of an unconditional O(n) rewrite that loses below ~16 answers, and trim allocations in telemetry, the memory/ redis backplanes, adapter header iteration, rpc/core selectors and subscription replay, cluster's presence snapshot, and the client batch flush. Replace spread-into-push in cluster.js with indexed loops (a RangeError risk on large fan-in, not just an allocation), and unify three cluster op-dispatch chains that disagreed on their unknown-op default. Deliberately does NOT convert if/else chains to switch or to lookup tables: bench/dispatch.js shows a real handler call dominates branch cost (switch and the chain come out level), and a table loses ~25% in every shape measured. Adds a "Performance conventions" section to CLAUDE.md codifying hot-vs-cold path judgment, the switch-not-table finding, and the existing defensive-copy/security-allocation exceptions. New bench/emitter.js, bench/dispatch.js, bench/batch-ordering.js justify the above with before/after numbers. New batching tests exercise the indexed #ordered path (duplicate ids, large batches), which no existing test reached.
…pages Brand the docs site (Node.js green on ink, logo, favicon, OG card) and add 12 mermaid diagrams at the key architectural and lifecycle points that were previously prose-only. Fill the documentation gaps around the newest features: cluster/acks, the f0031d1 security hardening, graceful shutdown, reproducible benchmarks, testing patterns, and browser bundling — none of which had a dedicated page. Refresh stale bundle-size claims and the CLAUDE.md docs-site description, which still described it as a skeleton.
… error.details, wire codec
Closes the gap between wrpc's internal RPC surface and a REST-shaped world:
a procedure can now declare `{ http, schema }` and become a real endpoint —
under fastify a native route (hooks/validation/serialization/swagger all
delegated to fastify, one pipeline, no double validation), everywhere else
served by the core's own per-verb route table. The reverse direction ships
too: `mirror` turns an app's EXISTING fastify routes into callable wrpc
procedures via fastify.inject(), reverse-REST named, with no route rewrites.
Schema-driven validation and serialization are injectable (ajv/fjs-shaped,
zero-dependency), compiled once per router; the same introspected schemas
let a browser client pre-validate locally before it ever sends a doomed
call. Errors carry structured `details` on the wire under the existing
4xx/expose redaction rule. The client can fall back across an ordered
transport list (ws -> sse -> http) with per-candidate retries and loud
capability loss instead of silent breakage. A pluggable text codec
replaces the JSON framing on both ends when an app wants one.
Breaking: `error.details` and REST-route responses widen the wire error
shape (additive per the 1.0 freeze policy, but WrpcError callers reading
raw shapes should note the new optional field); `isValidator`/schema paths
gain a new declarative branch alongside input/output.
…with /vN REST paths, codec.rest Second batch of gaps surfaced by the alioth mapping, on top of the REST bridge (a177d73). - Context call identity: context.method (the wire target — 'unit/name', 'unit.vN/name', or the event name verbatim for an inbound event) and context.procedure (the resolved Procedure) are set at every context creation site — calls, subscriptions, inbound events, and the fastify adapter's delegated REST routes — so cross-cutting hooks read the identity instead of re-deriving it from the packet. - onDisconnect receives { rooms }: the router-level disconnect hook's payload carries a snapshot of the client's rooms taken before destroy() emptied the registry (its first act is rooms.leaveAll(), so client.rooms is already empty by hook time). The teardown order is untouched — hooks are fire-and-forget, so reordering would not guarantee visibility. - Static introspection: client.use(introspection) scaffolds units from a raw system/introspect artifact with zero wire traffic — synchronous, works before open(). Dynamic wins: a load()ed unit skips use() and reloads on reconnect; static units never re-introspect. The CLI's new --schema <path> (with --format cjs|esm) emits that artifact from the same fetch as the types. Size budgets raised 12 -> 13 (browser) and 13 -> 14 (sse.browser) with ratchet comments; the browser raise also absorbs the codec.rest client bytes below. - REST version strategy: defineRouter(units, { rest: { version: 'path' } }) maps a versioned unit's declared routes under a /vN prefix (auth.v1 + /auth/signIn -> /v1/auth/signIn; the default version stays unprefixed, so the same declared path across versions no longer conflicts). One effectiveHttp seam feeds the dispatch trie, restRoutes() and introspection, which keeps the shell, the fastify registration and the client's REST leg version-consistent with no changes of their own; a function form (version, path) => path takes full control; proc.http (the declaration) never mutates; the strategy survives merge() — which #withIntrospection performs on every boot. - codec.rest: an optional rest section on the wire codec — { encode(value), decode(body), contentType? } — frames REST BODIES (values, not packets; binary allowed, msgpack-friendly) on both REST modes, requests, results and errors, server and client. Rest-only codecs are valid (packets stay JSON). New public RpcServer.codec getter. The fastify adapter refuses codec.rest next to delegated REST routes: a codec-framed body would silently bypass fastify's serialization and swagger; the core hosts (node shell, express, uws) serve it natively. Fixes two mode-blind Content-Type bugs under a packet codec: the server applied codec.contentType before the packet/REST mode branch (REST responses advertised the packet framing while writing JSON), and the client's REST leg mirrored it on requests. Both now scope the packet codec's type to packet mode; REST bodies advertise codec.rest.contentType (or JSON).
…, pluggable token transport and stores
Re-authentication. A client-side `authenticate` hook is 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, since #restore sends its subscribe packets before its first
await. A failing hook terminates the transport and walks the normal backoff
('authenticate-failed' fires); `client.close()` inside the hook stops the
cycle. `refresh` handles a credential expiring mid-session: single-flight (ten
concurrent 401s produce one refresh), retried exactly once with a fresh packet
id, on both the packet and the REST leg; on failure the original error
surfaces. `client.call(target, args, options)` is now public — the escape
hatch a first-connect hook needs, since `api` is built by load().
Server ordering. `client.sessionReady` is assigned before the onConnect hooks
run (the documented `await client.sessionReady` recipe used to await the
constructor's resolved default), and the dispatcher now gates on a new
`client.ready` — session restore plus settled onConnect hooks — so a subscribe
racing the hooks can no longer miss a room broadcast. Two promises on purpose:
hooks await sessionReady, dispatch awaits ready — folding them would deadlock.
A stalled hook logs 'onConnect.stalled' after 5s (unref'd, hook-path only).
Metadata, two channels with different contracts. `headers` (connection-phase)
ride real request headers on http/sse/worker and one percent-encoded query
param on browser ws — the WHATWG WebSocket constructor takes no headers, and
the server reads observed headers first, query as fallback, so a transport
that CAN send real upgrade headers needs no query at all. Validated when a
procedure declares `schema.headers` (the part was already accepted and
silently ignored; delegated fastify routes still validate in fastify, not
twice). `meta` (connection AND every call/subscribe/event packet, additive
protocol field like tp/ts) is deliberately outside the `validation` option and
reaches handlers as `context.callMeta`; connection-phase data lands on
`client.meta.data`. Both channels share one sanitizer: encoded-length cap
(`metaMaxBytes`, 2048), plain-object check, `__proto__` drop, freeze, and a
reserved-name denylist on the ws-query path so a peer cannot spoof cookie or
x-wrpc-* headers through the URL. `client.meta` / `context.meta` also expose
the upgrade url, remoteAddress and negotiated protocol — every attach site
used to drop req.url.
Pluggable auth strategies, injected like codec/logger. `TokenTransport`
(read/write/clear, duck-typed) decides where the session token lives on the
wire — cookie stays the byte-identical default; bearer and payload strategies
ship in the new @alexify/wrpc/auth subpath, and a non-ambient transport drops
the safe-method CSRF rule it never needed. `TokenStore` (get/set/delete, sync
or async — a Map already qualifies) decides where the client keeps tokens;
webStorage/cookieStorage wrappers and a ready-made bearerAuth() pairing of
authenticate + refresh + headers live in the subpath, outside the base
browser bundle on purpose.
Fixes on the way: a post-open restore failure now restores the attempt count
(it used to retry at minDelay forever, never exhausting `retries`), a rejected
connect() no longer leaks the client into WrpcClient.connections, and
WrpcClient.online() no longer aborts on the first unlistened 'error'.
…meta Plain HTTP callers may now spell per-request metadata as one header per key (`x-wrpc-meta-idem: 9f3c`) instead of percent-encoding JSON into the single `x-wrpc-meta` header — the x-amz-meta-* idiom: hand-typeable in curl, and a gateway can inject or strip individual keys without JSON surgery. The prefixed form is the convenience spelling, not a replacement — the wrpc client keeps emitting the canonical JSON header, for three reasons the prefix form structurally cannot satisfy: - type fidelity: header values are strings only, so `retries: 3` would arrive as a string over REST and a number over ws — a per-transport shape divergence in context.callMeta; - key case: HTTP lowercases header names (`x-wrpc-meta-userId` -> `userid`), JSON keys keep their case; - CORS: with credentials there are no header wildcards, so every `x-wrpc-meta-<key>` name would need its own Access-Control-Allow-Headers entry, while the single `x-wrpc-meta` is one stable allowlist name. Semantics: prefixed values arrive as STRINGS (the same by-design rule as REST query args), keys lowercased by HTTP; on a key collision the JSON header wins. Both forms merge BEFORE sanitizeMeta, so one size cap (metaMaxBytes) covers the combined bag; a `__proto__` key via prefix is dropped at collection, arrays (duplicated headers) are skipped, and a malformed canonical header refuses only its own channel — the prefixed keys survive. Server-side only: zero browser bytes. Docs: guide/metadata.md gains both curl spellings with the trade-off rationale; protocol.md's Connection-metadata section documents the equivalent per-key form. Tests: tests/rpc/http.test.js — happy path, JSON-wins collision, the refusal matrix (nameless prefix, __proto__, oversize) and the malformed-canonical-plus-prefixed mix. 1303 tests pass; lint, format, docs build and size budgets clean.
….metaHeaders
Closes the client-side half of x-wrpc-meta-<key> support (the server
already accepted it): a new `metaFormat: 'json' | 'prefixed'` client option
picks the wire spelling for connection-phase meta — the default single
`x-wrpc-meta` JSON header (type-faithful, one CORS entry), or the S3
`x-amz-meta-*` idiom, one real header per key (string values, one CORS
entry per key). ws/worker need no code: normalization happens in open()
before transport.open(), so both carriers serialize an already-normalized,
already-stringified bag.
Keys of both declared bags (`headers` and `meta`) now normalize to
kebab-case on every transport and on both ends (`userId` -> `user-id`,
`xAppVersion` -> `x-app-version`), via a shared `toKebab` in src/utils.js.
One casing convention means `schema.headers` has exactly one spelling to
validate, and the two meta spellings collide on the same key instead of
sitting side by side as lookalikes. Underscores are left alone; acronym-only
casing differences merge (last write wins, documented); an external caller
must write kebab itself, since HTTP lowercases header names before the
server ever sees them.
Per-call meta now reaches two paths that used to drop it silently:
- the client's mapped REST leg (#restCall never read options.meta) — with
no packet to carry it, it merges over the connection bag and rides as
request headers, per-call winning a collision;
- a batched POST — the request headers carry the batch's AGGREGATE
(last write wins across the pending calls), as a lossy summary for
infrastructure between the two ends (gateway routing, WAF, access logs).
Nothing is actually lost: each call's exact meta still rides its own
packet's `meta` field untouched, which is what context.callMeta reports.
An aggregate that would exceed metaMaxBytes is refused client-side with a
warning rather than silently dropped whole by the server's cap.
cors.metaHeaders: string[] declares which per-key x-wrpc-meta-<key> names a
cross-origin client may send — CORS has no wildcard for header names, so the
prefixed carrier needs each key named explicitly. Entries run through the
same toKebab as the client, so a camelCase config grants the header the
client actually sends. Appended to cors.headers, which now also accepts an
array form.
Breaking (nothing released yet, 1.0.0 on feature/v1):
- ClientTransport#request(method, url, body, signal, rest) is now
request(method, url, body, signal, { rest, meta }).
- ClientTransport#write(data) is now write(data, meta).
- options.headers values are stringified on every transport instead of
being dropped silently on ws (core.js:98 previously skipped non-strings).
Two pre-existing doc inaccuracies fixed in passing: the stated default CORS
header list omitted x-wrpc-meta in three places (index.d.ts, server.md,
sse.md's own example), and server.md claimed a disallowed origin "does not
fail the call" when handleHttpCall actually refuses it 403.
1324 tests pass (up from 1250), coverage 97.24/90.57/96.46 against
95/90/95 thresholds, tsd clean, lint/format clean, docs build clean.
Size: browser.js 13.6/14.0 KB, sse.browser.js 14.6/15.0 KB — no ratchet
raise needed.
…nce, typed events, REST/observability closeup
Re-review batch (phases 1-8 of the 2026-08-22 plan): fixes the two classes of
bug the newest code had accumulated (client reconnect/refresh edge cases,
auth/meta transport drift), then closes the resulting observability and
scale gaps before 1.0.
Client resilience:
- fix single-flight refresh deadlock when the refresh handler's own call is
refused with a listed code
- subscriptions refresh-and-reopen instead of dying silently after a stale
token, once, non-looping
- backoff resets on connection stability (stableAfter), not on raw open, so
an accept-then-drop server no longer pins retries at minDelay forever
- connectTimeout races the transport open; coded 408/503 replace bare
Error() on request timeout and dead-transport batch/SSE flush; both now
settle their pending calls instead of hanging to callTimeout
Auth/meta:
- TokenTransport port widened to receive parsed {headers, url, declared,
meta} bags instead of re-parsing the wire itself, closing the drift where
payloadTransport didn't read x-wrpc-meta-<key> on http/sse
- bearer token now rides a wrpc.bearer.<token> ws subprotocol instead of the
connect URL; cookieStorage defaults to Secure
- new src/wire.js centralizes the wire-name constants shared by 5 modules
- per-call prefixed meta values flattened correctly (previously NaN'd out
non-string values); client-side cap added on ws query metadata
Performance (each with a bench/*.js before/after):
- memoized CORS allow-headers string, sync fast path for the compiled
validator, ring buffers replacing Array#shift in the two replay buffers,
bound-estimate before falling back to JSON.stringify in sanitizeMeta
Observability:
- REST leg and delegated fastify routes traced/metriced on both ends;
cluster gets message/request/instance metrics and trace-context
propagation over the backplane hop; early refusals (CORS/404/SSE) and
refresh failures are now logged and counted
Scale & cluster:
- presence replication moved from full snapshots to periodic digest+sync
(O(nodes) instead of O(nodes^2) on the steady state), with an honest
cluster:false, a cluster.rooms replication filter, a loud maxFetch cap on
fetchClients, opt-in HMAC envelope auth (cluster.secret), backplane
subscribe retry with a healthy/degraded signal, and room-channel linger
to avoid resubscribe churn
- SSE per-address capacity now takes an injectable clientAddress instead of
trusting the raw socket peer behind a proxy; standalone engines get
stopListening() for drain-before-close shutdown ordering
Typed contract & CLI:
- declaration-only events/sends contract keys with typed on/off/emit,
introspected alongside methods and picked up by wrpc types codegen
- CallOptions.timeout (per-call deadline) and opt-in retry; TanStack Query
gets infiniteQueryOptions; wrpc types --openapi projects introspection to
OpenAPI 3; server responses carry a wrpc-version header
Maintainability refactors:
- src/rpc/core.js split into client.js (Context/Client) and meta.js (the
connection-metadata parser); REST trie extracted to src/rpc/rest.js
- isClientTransport structural port + shared client transport contract
test (mirrors the engine contract); new guard tests keep the hand-synced
keywords/exports/size-budget pairs from drifting again
- CLAUDE.md module map extended to match
Docs: REST bridge and auth finally appear on the homepage feature grid and
in why.md's competitive table; new auth.md and stability.md pages (with
sidebar entries); README/browser.md size numbers regenerated from a live
pnpm size run; the ~40x tRPC claim replaced with an honest comparison after
tracing the old number to a client flush-timer artifact (measured: 1.53ms
fixed delay, not throughput).
BREAKING (internal, pre-1.0): Procedure#invoke gains a 5th budget param;
introspection adds reserved `on`/`emits` keys; fetchClients returns
{list, truncated} instead of a bare array; presence sync switches from full
broadcast to digest+addressed-sync; bearerTransport no longer parses the
raw wrpc_h query directly.
Governance/release (CI branch trigger, main merge, npm publish, examples/)
intentionally out of scope — reserved for a separate pass.
…example Prepares the release checklist and CHANGELOG for the first publish (Phase 9, items 3-5 of the 2026-08-22 plan) — governance/CI/publish itself stays a manual, separate step. CHANGELOG.md: moved the "not published to npm yet" note from the bottom of the file (orphaned after 600 lines of entries) to directly under the [Unreleased] heading where it's actually seen; added a compare link at the bottom pointing at the commit history since there are no tags yet. CONTRIBUTING.md release checklist: - step 3 (bump version) now says to verify it differs from what's on npm, with an honest note that this half is a no-op before the first publish - dropped the separate "sync docs/.vitepress version label" step — it's been enforced by tests/package/consistency.test.js since the last batch, so step 1 (green everything) already catches drift - tarball verification gained an ESM smoke test (dynamic import + a bundler-resolution tsc check) alongside the existing CommonJS require - new steps: confirm the deployed docs site answers 200 on / and /reference/protocol before announcing, and refresh the size numbers hand-copied into README/browser.md/index.md prose (pnpm size enforces the budget, nothing enforces the prose staying in sync) - explicit line that this stays manual on purpose: no GitHub Actions release workflow, no publish provenance step examples/chat/: a ~50-line server + browser client (wrpc's answer to socket.io's chat demo, using exactly the join/shout snippets already in getting-started.md) plus a tiny build.js that bundles browser.js with the esbuild devDependency already in the repo, since a browser can't load the package's CommonJS output without one. Verified live end to end (build, serve, send a message, see it echo back through the room). Not part of the npm tarball (package.json#files is an explicit allowlist that omits examples/); the generated public/wrpc.browser.js is gitignored as a build artifact.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This pull request updates project documentation and CI configuration to clarify architecture, testing, and CI practices for the
@alexify/wrpcproject. The most significant changes include a comprehensive rewrite and expansion of theCLAUDE.mdarchitecture section, improvements to the test and linting command documentation, and a CI job safeguard to prevent test hangs.Documentation and Architecture Updates:
CLAUDE.mdarchitecture section: clarifies module structure, wire protocol (including newping/pongpacket types), and explains new subpaths (src/query/,src/auth/), as well as the rationale for zero runtime dependencies and injection-based adapter design. Adds details on new and existing files, architectural seams, and strict dependency boundaries.test:perf), clarifies which directories are linted/formatted, and explains CI job structure and manual test exclusions (e.g., Autobahn, Redis integration).Continuous Integration Improvements:
timeout-minutes: 20safeguard to thetestjob in.github/workflows/ci.ymlto prevent CI hangs due to leaked sockets during testing.These changes improve onboarding, clarify project structure and expectations, and help ensure CI reliability.