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 middleware handlers through
middlewarePlugin()when exposing an API - 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 { expose } from "kkrpc"import { middlewarePlugin, type MiddlewareHandler } from "kkrpc/middleware"
// Logging interceptorconst logger: MiddlewareHandler = async (ctx, next) => { console.log(`→ ${ctx.method}`, ctx.args) const result = await next() console.log(`← ${ctx.method}`, result) return result}
// Timing interceptorconst timer: MiddlewareHandler = 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: MiddlewareHandler = async (ctx, next) => { if (ctx.method.startsWith("admin.") && !isAuthorized()) { throw new Error("Unauthorized") } return next()}
expose(api, transport, { plugins: [middlewarePlugin([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 default callback restoration or remote-ref decoding 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: MiddlewareHandler = async (ctx, next) => { ctx.state.userId = await authenticate(ctx) return next()}
const audit: MiddlewareHandler = async (ctx, next) => { const result = await next() await logAudit(ctx.state.userId, ctx.method, ctx.args) return result}
expose(api, transport, { plugins: [middlewarePlugin([setUser, audit])]})Transforming return values
Section titled “Transforming return values”Interceptors can modify the handler’s return value:
const doubler: MiddlewareHandler = 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. Decode callback/value envelopes and opt-in remote-reference envelopes 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 middleware
Section titled “No middleware”// No middleware plugin means no middleware overhead.expose(api, transport)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 } from "kkrpc"
const rpc = new RPCChannel(transport, { expose: api, timeout: 5000 // 5 second timeout})
const api = rpc.getAPI()
try { await api.slowOperation()} catch (error) { if (error instanceof Error && error.name === "RPCTimeoutError") { console.log(error.message) }}Timeout Error Shape
Section titled “Timeout Error Shape”The stable timeout behavior rejects the pending client call with an Error whose name is "RPCTimeoutError". There is no stable timeout-specific exported class or type guard.
Error serialization
Section titled “Error serialization”Timeouts are produced locally by the caller while waiting for a response. Remote errors still use kkrpc’s normal error serialization.
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.
Default and disabled timeout
Section titled “Default and disabled timeout”// Default: 30 secondsnew RPCChannel(transport, { expose: api })
// Disable request timeout for this channel.new RPCChannel(transport, { expose: api, timeout: -1 })Combining Features
Section titled “Combining Features”Middleware, validation, and timeout work together:
import { expose } from "kkrpc"import { middlewarePlugin, type MiddlewareHandler } from "kkrpc/middleware"import { validationPlugin } from "kkrpc/validation"
const logger: MiddlewareHandler = async (ctx, next) => { console.log(`→ ${ctx.method}`) const result = await next() console.log(`← ${ctx.method}`) return result}
expose(api, transport, { plugins: [ validationPlugin(validators), // Validate inputs/outputs middlewarePlugin([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> }MiddlewareHandler—(ctx: RPCCallContext, next: () => Promise<unknown>) => Promise<unknown>
Functions
Section titled “Functions”runInterceptors(interceptors, ctx, handler)— runs the interceptor chain (used internally, exported for testing)