Middleware & Timeout
kkrpc supports two features for production reliability: an interceptor chain for cross-cutting concerns and request timeouts to prevent hung calls.
Middleware / Interceptors
Section titled “Middleware / Interceptors”How It Works
Section titled “How It Works”- You provide an
interceptorsarray when creating an RPCChannel - When a call is received, kkrpc runs each interceptor in order (onion model)
- Each interceptor calls
next()to proceed to the next interceptor (or the handler) - Interceptors can inspect/modify args, transform return values, measure timing, or throw to abort
- Interceptors run after input validation and before output validation
Since kkrpc is bidirectional, both sides can independently have interceptors for their own exposed API.
Basic Usage
Section titled “Basic Usage”import { RPCChannel, type RPCInterceptor } from "kkrpc"
// Logging interceptorconst logger: RPCInterceptor = async (ctx, next) => { console.log(`→ ${ctx.method}`, ctx.args) const result = await next() console.log(`← ${ctx.method}`, result) return result}
// Timing interceptorconst timer: RPCInterceptor = async (ctx, next) => { const start = performance.now() const result = await next() console.log(`${ctx.method} took ${(performance.now() - start).toFixed(1)}ms`) return result}
// Auth interceptor (throw to reject)const auth: RPCInterceptor = async (ctx, next) => { if (ctx.method.startsWith("admin.") && !isAuthorized()) { throw new Error("Unauthorized") } return next()}
new RPCChannel(io, { expose: api, interceptors: [logger, timer, auth]})Onion Model
Section titled “Onion Model”Interceptors execute in the standard onion order — the first interceptor wraps all others:
interceptor[0] "before" → interceptor[1] "before" → handler() interceptor[1] "after" ←interceptor[0] "after" ←This means an outer interceptor (like a timer) can measure the total time including inner interceptors.
RPCCallContext
Section titled “RPCCallContext”Each interceptor receives a ctx object:
| Property | Type | Description |
|---|---|---|
method | string | Dotted method path (e.g. "math.divide") |
args | unknown[] | Arguments after callback restoration and input validation |
state | Record<string, unknown> | Shared state bag — interceptors can attach data for downstream interceptors |
Sharing state between interceptors
Section titled “Sharing state between interceptors”Use ctx.state to pass data between interceptors:
const setUser: RPCInterceptor = async (ctx, next) => { ctx.state.userId = await authenticate(ctx) return next()}
const audit: RPCInterceptor = async (ctx, next) => { const result = await next() await logAudit(ctx.state.userId, ctx.method, ctx.args) return result}
new RPCChannel(io, { expose: api, interceptors: [setUser, audit]})Transforming return values
Section titled “Transforming return values”Interceptors can modify the handler’s return value:
const doubler: RPCInterceptor = async (_ctx, next) => { const result = (await next()) as number return result * 2}Position relative to validation
Section titled “Position relative to validation”handleRequest flow: 1. Resolve method path 2. Restore callback arguments 3. Input validation (if configured) — rejects early on bad input 4. ▶ Interceptor chain wrapping handler invocation ◀ 5. Output validation (if configured) — rejects on bad return 6. Send responseInterceptors see validated, clean args. They don’t need to worry about malformed input. Output validation catches bad handler returns (including interceptor-modified returns).
No interceptors (backward compatible)
Section titled “No interceptors (backward compatible)”// Existing code works exactly as before — no interceptors, no overheadnew RPCChannel(io, { expose: api })Request Timeout
Section titled “Request Timeout”How It Works
Section titled “How It Works”- You provide a
timeoutoption (in milliseconds) when creating an RPCChannel - Each outgoing call (method call, property get/set, constructor) starts a timer
- If the remote side doesn’t respond before the deadline, the call rejects with
RPCTimeoutError - When a response arrives, the timer is cleared
- When
destroy()is called, all pending requests are immediately rejected
Basic Usage
Section titled “Basic Usage”import { RPCChannel, isRPCTimeoutError } from "kkrpc"
const rpc = new RPCChannel(io, { expose: api, timeout: 5000 // 5 second timeout})
const api = rpc.getAPI()
try { await api.slowOperation()} catch (error) { if (isRPCTimeoutError(error)) { console.log(error.method) // "slowOperation" console.log(error.timeoutMs) // 5000 }}RPCTimeoutError properties
Section titled “RPCTimeoutError properties”| Property | Type | Description |
|---|---|---|
method | string | Method path or operation that timed out |
timeoutMs | number | The configured timeout in milliseconds |
name | string | Always "RPCTimeoutError" |
message | string | Human-readable summary |
Error serialization
Section titled “Error serialization”RPCTimeoutError survives kkrpc’s error serialization automatically — all custom properties (method, timeoutMs) are preserved across the wire. The isRPCTimeoutError() type guard works on both the original error and the deserialized version.
Cleanup on destroy
Section titled “Cleanup on destroy”When destroy() is called, kkrpc rejects all pending requests with "RPC channel destroyed" and clears all timers. This prevents memory leaks from abandoned pending promises.
No timeout (default)
Section titled “No timeout (default)”// Default: timeout is 0 (no timeout)// Calls will wait indefinitely for a responsenew RPCChannel(io, { expose: api })Combining Features
Section titled “Combining Features”Middleware, validation, and timeout work together:
import { RPCChannel, type RPCInterceptor, type RPCValidators } from "kkrpc"
const logger: RPCInterceptor = async (ctx, next) => { console.log(`→ ${ctx.method}`) const result = await next() console.log(`← ${ctx.method}`) return result}
new RPCChannel(io, { expose: api, validators, // Validate inputs/outputs interceptors: [logger], // Log all calls timeout: 10_000 // 10 second timeout})API Reference
Section titled “API Reference”RPCCallContext—{ method: string, args: unknown[], state: Record<string, unknown> }RPCInterceptor—(ctx: RPCCallContext, next: () => Promise<unknown>) => Promise<unknown>RPCTimeoutError— error class withmethod,timeoutMs
Functions
Section titled “Functions”runInterceptors(interceptors, ctx, handler)— runs the interceptor chain (used internally, exported for testing)isRPCTimeoutError(error)— type guard that works across serialization boundaries