ADR-014: RPC Developer Experience and Safety Improvements
Status: Implemented Date: 2025-10-30 References: ADR-012, ADR-013
Problem
While core RPC reliability features are production-ready (abort, deadlines, one-shot, backpressure), several developer experience issues remain:
- Implicit correlation policy: Clients must manually generate
correlationId; missing IDs cause silent match failures - Unclear intent: No explicit method to signal "send to this client" vs. other send methods
- Idempotency key format left to apps; no helper for consistent canonicalization
- Reserved prefix enforcement: Runtime filtering works, but schema creation doesn't fail fast
- Typed error codes: No client-side type narrowing for error handling
- Backpressure tuning: Configuration hidden; no platform-specific guidance
Solution
Six surgical DX improvements keeping the API surface minimal:
1. Auto-Correlation (Client + Server)
Problem: Manual correlation IDs are error-prone.
Solution:
- Client: Auto-generate
correlationIdusingcrypto.randomUUID()if not provided - Server: Synthesize missing
correlationIdfor RPC messages; tag withmeta.syntheticCorrelation = truefor debugging
Impact: Zero-cost invariant (every RPC always has a correlationId)
// Client side (automatic)
const correlationId = opts?.correlationId ?? crypto.randomUUID();
// Server side (fallback)
if (isRpc && !correlationId) {
correlationId = crypto.randomUUID();
meta.syntheticCorrelation = true; // For debugging
}2. Primary Method: ctx.send() for Semantic Clarity
Problem: Single send method doesn't clearly signal intent (unicast to sender vs. broadcast).
Solution:
- Introduce
ctx.send()as the primary method for sending to client (always available) - Clear semantics: "send to this client only"
- Functionally identical to
ctx.send()
router.on(QueryMessage, (ctx) => {
const result = await queryDatabase(ctx.payload);
ctx.send(QueryResponse, result); // Clear: send to this client only
});3. Typed RpcErrorCode for Client Error Narrowing
Problem: No way to type-narrow error codes in catch blocks.
Solution:
- Export
RpcErrorCodeunion type from client - Make
RpcErrorgeneric:RpcError<TCode extends RpcErrorCode>
export type RpcErrorCode =
| "INVALID_ARGUMENT"
| "UNAUTHENTICATED"
| "PERMISSION_DENIED"
| "NOT_FOUND"
| "RESOURCE_EXHAUSTED"
| "DEADLINE_EXCEEDED"
| "CANCELLED"
| "INTERNAL"
| "ONE_SHOT"
| string; // Allow custom codesUsage:
try {
await client.request(Query, payload);
} catch (e) {
if (e instanceof RpcError && e.code === "RESOURCE_EXHAUSTED") {
// Type-narrowed: retryAfterMs guaranteed present
await sleep(e.retryAfterMs ?? 100);
}
}4. Idempotency Helper: stableStringify() + idempotencyKey()
Problem: Apps roll their own payload hashing; leads to inconsistent formats and DoS risks (expensive hashes).
Solution:
- Export
stableStringify(data)for canonical JSON (sorted keys, consistent output) - Export
idempotencyKey(opts)helper for standard key generation
import { stableStringify, idempotencyKey } from "@ws-kit/core";
import crypto from "node:crypto";
const payload = { user: "alice", action: "purchase" };
const hash = crypto
.createHash("sha256")
.update(stableStringify(payload))
.digest("hex");
const key = idempotencyKey({
tenant: ctx.data?.tenantId,
user: ctx.data?.userId,
type: ctx.type,
hash,
});
// Result: "tenant:alice:purchase:abc123def..."Recommendation (documented in ADR-013):
- Domain-key first:
tenant:user:type:hash - Cap key length: 256 bytes
- Hash payload with SHA256 (fast, secure)
5. Reserved Prefix Enforcement at Design-Time
Problem: Runtime filtering works, but schema creation doesn't fail fast.
Solution:
- Add validation in
rpc()andmessage()helpers - Throw immediately if type starts with
$ws:
// In packages/zod/src/schema.ts, packages/valibot/src/schema.ts
const RESERVED_PREFIX = "$ws:";
if (requestType.startsWith(RESERVED_PREFIX)) {
throw new Error(`Reserved prefix "${RESERVED_PREFIX}" not allowed...`);
}Impact: Developers catch mistakes at definition time, not runtime
6. Backpressure Configuration Visibility
Problem: maxQueuedBytesPerSocket is a router option, but not visible in adapter docs.
Solution:
- Surface in adapter
serve()/handler()options with JSDoc guidance - Document platform-specific recommendations
Adapter Guidance:
// @ws-kit/bun
serve(router, {
maxQueuedBytesPerSocket: 1_000_000, // 1MB, advisory per platform
// Bun: 1-4MB typical, varies by system memory
});
// @ws-kit/cloudflare
handler = createCloudflareHandler(router, {
maxQueuedBytesPerSocket: 512_000, // 512KB, conservative for DO limits
// DO: message cap ~125KB, request cap ~30MB
});Testing
New tests ensure invariants hold:
- Property tests: One-shot, deadline, correlation invariants
- Reconnect fuzz: Disconnect/reconnect with different resend policies
- Backpressure: Buffer exceeded →
RESOURCE_EXHAUSTEDerror, never partial replies - Error code coverage: All
RpcErrorCodetypes tested - Reserved prefix:
rpc("$ws:BAD", ...)throws at definition time
Implementation Status
✅ All changes implemented and tested:
- Auto-correlation on client + server synthesis
ctx.send()as primary send methodRpcErrorgeneric withRpcErrorCodeunionstableStringify()andidempotencyKey()utilities- Design-time reserved prefix validation
- All 953 tests passing
Library Status: This library has not been published yet, so all API decisions are final with no backward compatibility constraints.
7. Incomplete RPC Handler Detection
Problem: RPC handlers that complete without calling ctx.reply() or ctx.error() cause clients to hang with timeouts. This is a common developer mistake but only caught at runtime via client timeout, with no server-side warning.
Solution:
Add automatic warning in development mode when RPC handlers complete without sending a terminal response:
// Enable by default (router option)
const router = createRouter({
warnIncompleteRpc: true, // Default: enabled
});
// Disable for legitimate async patterns
const router = createRouter({
warnIncompleteRpc: false, // For spawned async work
});Behavior:
- When enabled (default): After RPC handler execution completes, check if terminal response was sent
- If not terminal: Emit warning with message type, correlation ID, and actionable guidance
- Dev-mode only: Warnings only in
NODE_ENV !== "production" - Zero cost in production: No checks or logging when disabled or in production
Warning message example:
[ws] RPC handler for GET_USER (req-abc123) completed without calling ctx.reply() or ctx.error().
Client may timeout. Consider using ctx.reply() to send a response, or disable this warning
with warnIncompleteRpc: false if spawning async work.Use cases:
✅ Caught immediately:
- Sync handlers that forget reply
- Async handlers that return early without error
- Handlers with conditional returns missing error cases
✅ Known false positive (legitimate async):
setTimeout(() => ctx.reply(...), delay)— warns because reply happens after handler completessetImmediate(() => ctx.reply(...))— same pattern
Mitigation for async patterns:
Either disable the warning or use a pattern that marks async work:
// Option 1: Disable warning (for known async patterns)
const router = createRouter({ warnIncompleteRpc: false });
// Option 2: Use explicit deferral (future enhancement)
// ctx.defer(() => reply()): // Explicitly mark async workTesting:
Tests verify:
- Warning fires for sync/async handlers without reply
- No warning when reply or error is sent
- No warning for non-RPC messages
- Respects
warnIncompleteRpc: falseconfig - Warning only in dev mode
- Warning includes message type and correlation ID
See packages/core/test/features/rpc-incomplete-warning.test.ts for full test coverage.
Future Work
- Streaming RPC with enhanced AsyncIterable client API
- Client-side AbortSignal sending
$ws:abort - Reconnect policy options (explicit
resendOnReconnectknob)
References
- ADR-012: Minimal Reliable RPC — Core lifecycle features
- ADR-013: Reconnect & Idempotency Policy — Client resend logic
- RPC Troubleshooting — Common issues and solutions