Skip to content

Middleware & Timeout

kkrpc supports two features for production reliability: an interceptor chain for cross-cutting concerns and request timeouts to prevent hung calls.

  1. You provide an interceptors array when creating an RPCChannel
  2. When a call is received, kkrpc runs each interceptor in order (onion model)
  3. Each interceptor calls next() to proceed to the next interceptor (or the handler)
  4. Interceptors can inspect/modify args, transform return values, measure timing, or throw to abort
  5. Interceptors run after input validation and before output validation

Since kkrpc is bidirectional, both sides can independently have interceptors for their own exposed API.

import { RPCChannel, type RPCInterceptor } from "kkrpc"
// Logging interceptor
const logger: RPCInterceptor = async (ctx, next) => {
console.log(`${ctx.method}`, ctx.args)
const result = await next()
console.log(`${ctx.method}`, result)
return result
}
// Timing interceptor
const 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]
})

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.

Each interceptor receives a ctx object:

PropertyTypeDescription
methodstringDotted method path (e.g. "math.divide")
argsunknown[]Arguments after callback restoration and input validation
stateRecord<string, unknown>Shared state bag — interceptors can attach data for downstream 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]
})

Interceptors can modify the handler’s return value:

const doubler: RPCInterceptor = async (_ctx, next) => {
const result = (await next()) as number
return result * 2
}
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 response

Interceptors see validated, clean args. They don’t need to worry about malformed input. Output validation catches bad handler returns (including interceptor-modified returns).

// Existing code works exactly as before — no interceptors, no overhead
new RPCChannel(io, { expose: api })
  1. You provide a timeout option (in milliseconds) when creating an RPCChannel
  2. Each outgoing call (method call, property get/set, constructor) starts a timer
  3. If the remote side doesn’t respond before the deadline, the call rejects with RPCTimeoutError
  4. When a response arrives, the timer is cleared
  5. When destroy() is called, all pending requests are immediately rejected
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
}
}
PropertyTypeDescription
methodstringMethod path or operation that timed out
timeoutMsnumberThe configured timeout in milliseconds
namestringAlways "RPCTimeoutError"
messagestringHuman-readable summary

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.

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: timeout is 0 (no timeout)
// Calls will wait indefinitely for a response
new RPCChannel(io, { expose: api })

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
})
  • RPCCallContext{ method: string, args: unknown[], state: Record<string, unknown> }
  • RPCInterceptor(ctx: RPCCallContext, next: () => Promise<unknown>) => Promise<unknown>
  • RPCTimeoutError — error class with method, timeoutMs
  • runInterceptors(interceptors, ctx, handler) — runs the interceptor chain (used internally, exported for testing)
  • isRPCTimeoutError(error) — type guard that works across serialization boundaries