An Undici compose interceptor that routes HTTP requests to servers registered from worker threads or TCP addresses.
npm install undici-thread-interceptor- Node.js with worker thread messaging support.
- Undici 8 style dispatcher handlers.
- ESM applications. The package is distributed from TypeScript source built to
dist/**.
The v2 API has three explicit roles:
createCoordinator()creates a mesh coordinator for onemeshId.createServer()registers one server target for one domain.createInterceptor()creates an Undici compose interceptor that routes matching requests through the mesh.
A request is intercepted only when its hostname matches the configured domain suffix and the requested domain exists in the mesh. If no mesh entry exists, the request is delegated to the next Undici dispatcher. If the mesh entry exists but no target is available, the request fails with NoAvailableTargetError.
Main thread:
import { Worker } from 'node:worker_threads'
import { Agent, request } from 'undici'
import { createCoordinator, createInterceptor } from 'undici-thread-interceptor'
const meshId = 'app'
const coordinator = createCoordinator({ meshId })
async function main () {
const worker = new Worker(new URL('./worker.js', import.meta.url), {
workerData: {
meshId,
coordinatorThreadId: 0
}
})
const interceptor = createInterceptor({
meshId,
domain: '.local'
})
await interceptor.ready
const agent = new Agent().compose(interceptor)
const { body } = await request('http://api.local', { dispatcher: agent })
console.log(await body.json())
await worker.terminate()
interceptor.close()
coordinator.destroy()
}
main().catch(error => {
console.error(error)
process.exitCode = 1
})Worker thread:
import { parentPort, workerData } from 'node:worker_threads'
import Fastify from 'fastify'
import { createServer } from 'undici-thread-interceptor'
const app = Fastify()
app.get('/', async () => ({ hello: 'world' }))
const server = createServer({
meshId: workerData.meshId,
coordinatorThreadId: workerData.coordinatorThreadId,
serverId: 'api-1',
domain: 'api.local',
server: app
})
server.ready
.then(() => parentPort?.postMessage({ ready: true }))
.catch(error => {
throw error
})The interceptor is a normal Undici compose interceptor, so it can be installed on a global dispatcher:
import { Agent, setGlobalDispatcher } from 'undici'
import { createInterceptor } from 'undici-thread-interceptor'
async function main () {
const interceptor = createInterceptor({ meshId: 'app', domain: '.local' })
await interceptor.ready
setGlobalDispatcher(new Agent().compose(interceptor))
const response = await fetch('http://api.local')
console.log(await response.json())
}
main().catch(error => {
console.error(error)
process.exitCode = 1
})createServer() can register an HTTP address instead of an in-process server. The interceptor dispatches directly to the target address:
import { createServer } from 'undici-thread-interceptor'
const server = createServer({
meshId: 'app',
serverId: 'api-tcp',
domain: 'api.local',
server: 'http://127.0.0.1:3000'
})
server.ready.catch(error => {
throw error
})Mesh targets can serve WebSocket connections. Client code is unmodified: use undici's WebSocket with the composed dispatcher and the mesh domain, and register a Node http.Server (or a Fastify instance using @fastify/websocket) so the mesh can emit its 'upgrade' event:
// Worker thread
import { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
import { createServer as createMeshServer } from 'undici-thread-interceptor'
const httpServer = createServer(app)
const wss = new WebSocketServer({ server: httpServer })
wss.on('connection', socket => socket.on('message', data => socket.send(data)))
createMeshServer({ meshId: 'app', domain: 'api.local', server: httpServer })
// Client thread
import { WebSocket } from 'undici'
const ws = new WebSocket('ws://api.local/updates', { dispatcher: agent })Use ws:// URLs for mesh domains. Mesh origins are http:-based, so a wss:// URL never matches the mesh — it is delegated to regular Undici dispatch, which will attempt a real TLS connection. There is no TLS inside the mesh; the tunnel runs over MessagePorts.
The connection is tunneled between threads over a dedicated MessagePort as raw bytes: the server performs its real handshake, so subprotocols, ping/pong, close codes, and permessage-deflate behave exactly as over TCP. TCP targets upgrade over their real address. Routing, allowTarget hooks, and connectTimeout apply to upgrades the same way they apply to requests — connectTimeout covers the window from dispatch until the handshake response arrives.
Handshake rejections are transparent: when the server answers an upgrade with a non-101 response (for example ws rejecting with a 400), the response is replayed to the client as a regular HTTP response, exactly as it would arrive over TCP. A target that advertises the upgrade capability but has no 'upgrade' listener attached at request time answers 501; a server that is unavailable when the upgrade arrives (a selection race with pause/close) answers 503.
Supported clients — anything that dispatches through undici:
- undici's
WebSocketwith thedispatcheroption, as above. - Node's global
WebSocketviasetGlobalDispatcher(agent)from undici — no per-connection options needed. (Passing{ dispatcher }to the globalWebSocketalso works on Node ≥ 24; on Node 22 the bundled undici predates the current handler API, so prefersetGlobalDispatcheror the undici import there.) dispatcher.upgrade()for manual HTTP upgrades.
Clients that speak node:http directly — most notably the ws package's client — do not consult undici dispatchers. For those, interceptor.createUpgradeAgent() returns a node:http Agent that routes upgrade requests for mesh domains through the mesh and falls back to real TCP for every other host:
import { WebSocket } from 'ws'
const ws = new WebSocket('ws://api.local/updates', {
agent: interceptor.createUpgradeAgent()
})This is how @fastify/http-proxy WebSocket proxying reaches mesh targets — pass the agent through wsClientOptions and the undici dispatcher for the HTTP path:
import proxy from '@fastify/http-proxy'
await app.register(proxy, {
upstream: 'http://api.local',
websocket: true,
wsClientOptions: { agent: interceptor.createUpgradeAgent() },
undici: agent
})The agent supports upgrade requests only: regular HTTP requests to mesh domains through it receive a 501 (use the undici interceptor for those). Mesh upstreams must use ws:///http://; interceptor onRequest/allowTarget hooks and connectTimeout apply, while response hooks and the upgrade:established/upgrade:rejected diagnostics do not fire on this path — after routing, the connection is a direct byte pipe and the handshake response is parsed by the client itself.
Targets advertise an upgrade capability in the mesh (capabilities.upgrade). Bare request handlers cannot accept upgrades and are skipped by upgrade selection; if no target can upgrade, dispatch fails with NoAvailableTargetError. To serve upgrades without an http.Server, pass an explicit handler:
createMeshServer({
meshId: 'app',
domain: 'api.local',
server: handler,
upgrade (req, socket, head) {
wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req))
}
})server.close() drains established connections: it waits up to upgradeDrainTimeout milliseconds (default 30000) for them to close on their own, then destroys the remainder. Use 0 to destroy them immediately. Paused servers keep established connections but reject new upgrades. Established connections are never migrated: mesh updates only affect new connections, and interceptor.close() or the loss of either thread destroys the tunnel — the peer sees an abnormal closure (WebSocket close code 1006). CONNECT requests to mesh targets are rejected.
Server domains must not include a protocol. Use api.local, not http:api.local or http://api.local.
createServer({
meshId: 'app',
domain: 'api.local',
server: app
})
createInterceptor({
meshId: 'app',
domain: '.local'
})The interceptor checks the configured domain suffix case-insensitively. Requests outside the configured domain are delegated to Undici.
Hooks can be a function or an array of functions. Hooks must be synchronous. Async hooks are rejected.
const interceptor = createInterceptor({
meshId: 'app',
domain: '.local',
onRequest (req, ctx) {
ctx.started = Date.now()
},
allowTarget (req, target, ctx) {
return target.metadata?.disabled !== true
},
onResponse (req, res, ctx) {
console.log(req.path, res.statusCode, Date.now() - ctx.started)
},
onResponseEnd (req, res, ctx) {
console.log('completed', req.path)
},
onError (req, res, ctx, error) {
console.error(error)
}
})allowTarget is an access-control hook. Returning false denies that target and selection continues with the next available target. In hook arrays, evaluation stops on the first false.
For WebSocket upgrades, onRequest and allowTarget run as usual and onResponse fires with the handshake response (statusCode: 101 on establishment, or the rejection status). onResponseEnd fires for rejected handshakes but never for established connections — they are long-lived and have no response end. Neither response hook fires on the createUpgradeAgent() path, where the handshake response goes straight to the client.
const server = createServer({
meshId: 'app',
serverId: 'api-1',
domain: 'api.local',
server: app,
onRequest (req) {
console.log(req.method, req.url)
},
onResponse (req, res) {
console.log(res.statusCode)
},
onError (req, res, error) {
console.error(error)
}
})Server hooks are notification hooks. onRequest does not receive a next callback and cannot replace the application handler.
For WebSocket upgrades, server-side onRequest fires with the synthetic upgrade request before it is emitted to the upgrade target; onResponse does not fire for upgrades because the handshake response is written directly to the socket by the upgrade handler.
Servers and interceptors can publish arbitrary metadata into the mesh:
const server = createServer({
meshId: 'app',
domain: 'api.local',
server: app,
metadata: { region: 'eu-west-1' }
})
server.updateMetadata({ region: 'eu-west-1', disabled: true })Interceptor target hooks can use server metadata for routing decisions.
Servers can be paused, resumed, replaced, and closed:
server.pause()
server.resume()
server.replaceServer(nextApp)
server.updateMetadata(nextMetadata)
server.close().catch(error => {
throw error
})Paused servers remain visible in mesh snapshots but are skipped by selection. Closing a server removes it from the mesh immediately, then drains queued and in-flight thread-mode requests.
Interceptors expose lifecycle and mesh inspection helpers:
interceptor.ready
.then(() => {
console.log(interceptor.interceptorId)
console.log(interceptor.getMesh())
interceptor.updateMetadata({ role: 'client' })
interceptor.close()
})
.catch(error => {
throw error
})Coordinators can manage server state and their own lifecycle:
coordinator.pause('api-1')
coordinator.resume('api-1')
coordinator.close('api-1')
coordinator.close()
coordinator.restart()
coordinator.destroy()close(serverId) asks one registered server to close. close() without a server id closes current members and keeps the coordinator reusable after restart(). destroy() permanently removes the coordinator from the process registry.
interface CoordinatorOptions {
meshId: string
onMesh?: (mesh: Mesh) => void
onInterceptorAvailable?: (interceptor: MeshInterceptor) => void
onInterceptorClosed?: (interceptor: MeshInterceptor) => void
onServerAvailable?: (server: MeshServer) => void
onServerUnavailable?: (server: MeshServer) => void
onServerPaused?: (server: MeshServer) => void
onServerResumed?: (server: MeshServer) => void
onServerClosed?: (server: MeshServer) => void
onServerUpdate?: (server: MeshServer) => void
onError?: (error: Error) => void
}interface ServerOptions {
meshId: string
serverId?: string
domain: string
server: any
paused?: boolean
metadata?: unknown
coordinatorThreadId?: number
bootstrapTimeout?: number
upgrade?: (req, socket, head) => void
upgradeDrainTimeout?: number
onRequest?: Hook | Hook[]
onResponse?: Hook | Hook[]
onError?: Hook | Hook[]
}serverId defaults to a crypto.randomUUID() value. coordinatorThreadId defaults to 0. server can be a Fastify instance, a Node http.Server, an Express/Koa-style handler accepted by light-my-request, or a TCP target address string. upgrade overrides upgrade delivery; otherwise upgrades are emitted on the registered server's 'upgrade' event (or its .server property for Fastify). upgradeDrainTimeout defaults to 30000.
interface InterceptorOptions {
meshId: string
interceptorId?: string
domain?: string
connectTimeout?: number
coordinatorThreadId?: number
bootstrapTimeout?: number
metadata?: unknown
onRequest?: Hook | Hook[]
allowTarget?: Hook | Hook[]
onResponse?: Hook | Hook[]
onResponseEnd?: Hook | Hook[]
onError?: Hook | Hook[]
}The returned value is both an Undici compose interceptor and an object with:
interceptorIdreadyclose()updateMetadata(metadata)getMesh()createUpgradeAgent()— anode:httpAgentfor routing upgrade requests from node:http clients (e.g.ws) through the mesh
interceptorId defaults to a crypto.randomUUID() value. coordinatorThreadId defaults to 0.
Thread-mode requests publish Undici-compatible diagnostics channels:
undici:request:createundici:request:headersundici:request:trailersundici:request:error
Server-side diagnostics:
http.server.request.starthttp.server.response.finish
Mesh diagnostics:
undici-thread-interceptor:mesh:updateundici-thread-interceptor:peer:connectundici-thread-interceptor:peer:disconnect
WebSocket upgrade diagnostics (interceptor side):
undici-thread-interceptor:upgrade:startundici-thread-interceptor:upgrade:establishedundici-thread-interceptor:upgrade:rejectedundici-thread-interceptor:upgrade:closed
WebSocket upgrade diagnostics (server side):
undici-thread-interceptor:server:upgrade:startundici-thread-interceptor:server:upgrade:rejectundici-thread-interceptor:server:upgrade:closed
TCP targets are dispatched through Undici directly and do not emit synthetic thread-mode Undici request diagnostics.
NoAvailableTargetErroris thrown when a domain exists in the mesh but no available target can serve it — for upgrades, that includes meshes where no target advertises the upgrade capability.ConnectTimeoutErroris thrown when the interceptor times out waiting for a thread-mode response or a WebSocket handshake response, on both the dispatcher andcreateUpgradeAgent()paths.
See MIGRATION.md for v1-to-v2 changes.
MIT