@midwess/unb
v2.0.3
Published
Target-routed duplex messaging for TypeScript on Node.js and browsers
Maintainers
Readme
unb
Target-routed duplex messaging for TypeScript. Calls use canonical /node/subject
paths. Node.js uses the native Rust node and server binding. Browsers use the
same full Rust Node and NodeBuilder surface inside a dedicated WebAssembly worker. Rust owns framing, sessions, routing, endpoint
fallback, cancellation, discovery, and AI projection; TypeScript adapts
application values and platform transports.
Install
npm install @midwess/unbThe package is ESM-only and requires Node.js 20 or newer. Version 2.0.3 supports native Node.js execution on Apple silicon macOS (darwin-arm64) and worker-backed execution in browsers with WebAssembly and module-worker support. Browser execution does not require cross-origin isolation. CommonJS, Bun, Deno, Linux native addons, Intel macOS native addons, and Windows native addons are unsupported.
Use explicit entry points in application code:
import { Node } from '@midwess/unb/node'
import { BrowserClient, Node as BrowserNode } from '@midwess/unb/browser'The package root uses conditional exports for bundlers that select node or browser conditions.
Node.js Quick Start
import {
Endpoint,
EndpointSet,
Node,
type StandardSchemaV1,
} from '@midwess/unb/node'
type WeatherInput = { city: string }
type WeatherOutput = { city: string; tempC: number }
const weatherInput: StandardSchemaV1.Schema<WeatherInput> = {
'~standard': {
version: 1,
vendor: 'weather-example',
validate(value) {
if (typeof value === 'object' && value !== null && typeof (value as WeatherInput).city === 'string') return { value: value as WeatherInput }
return { issues: [{ message: 'city must be a string' }] }
},
},
}
const weatherOutput: StandardSchemaV1.Schema<WeatherOutput> = {
'~standard': {
version: 1,
vendor: 'weather-example',
validate(value) { return { value: value as WeatherOutput } },
},
}
const service = Node.builder('weather-service')
.insecureAcceptDeclaredPeerIdentities()
.unary('weather.now', {
input: weatherInput,
output: weatherOutput,
discovery: {
input: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
output: { type: 'object', properties: { city: { type: 'string' }, tempC: { type: 'number' } } },
},
}, request => ({ city: request.payload.city, tempC: 21 }))
.build()
const caller = Node.builder('caller').insecureAcceptDeclaredPeerIdentities().build()
const hosting = await service.host({ address: '127.0.0.1:0', websocket: {} }).start()
try {
if (!hosting.tcpAddress) throw new Error('WebSocket hosting did not start')
const connection = await caller.connect(new EndpointSet([Endpoint.websocket(`ws://${hosting.tcpAddress}`)]))
console.log(connection.peer, connection.status())
const reply = await caller.fetchJson<WeatherOutput>('/weather-service/weather.now', { body: { city: 'Hanoi' } })
console.log(reply.body)
} finally {
hosting.close()
caller.close()
service.close()
}For in-process nodes, use await caller.link(service) instead of creating a network endpoint. See the repository examples/ directory for request/response, linking, catalogs, errors, multi-hop routing, runtime mutation, AI tools, WebTransport fallback, node-to-node relay, and raw byte payload examples.
Peer connection lifecycle
Node-to-node connect() remains eager: it verifies the peer and completes the
initial route synchronization before returning a shared PeerConnection.
After unintentional transport loss, the shared handle publishes connecting
and owns one automatic maintenance supervisor. It immediately performs an
ordered endpoint sweep, then retries indefinitely with capped jittered backoff.
const connection = await caller.connect(endpoints)
console.log(connection.status())
// Optional observation only; callers never poll or trigger recovery.
await connection.changed()
// Terminal for this logical connection and all of its clones.
connection.disconnect()New operations that need a recovering route may wait only within their original
deadline and resolve the authoritative route again when woken. Operations
already admitted to the failed session fail with PEER_UNREACHABLE and are not
replayed. A later deliberate connect() creates a new logical handle; it never
revives the disconnected clones. Unix parent links use this same maintenance
path.
Peer admission
Every node must select an explicit peer-admission policy before build(). Node.js and browser
builders expose the same asynchronous peerLayer(fn) contract. The callback receives the local and
remote declared identities, including their opaque proof, and a consume-once downstream
continuation:
import { Node, type PeerLayerFn } from '@midwess/unb/browser'
const verifyPeer: PeerLayerFn = async (_local, remote, next) => {
if (remote.proof !== 'expected-application-proof') throw new Error('peer proof rejected')
await next()
}
const node = Node.builder('browser-shell')
.identityProof('expected-shell-proof')
.peerLayer(verifyPeer)
.build()await next() marks the declared identity verified and runs the remaining layers in registration
order. A throw, rejected promise, return without next(), repeated next(), or downstream rejection
fails closed. Node, connection, or session retirement drains UNB's pending admission wait; a user
JavaScript promise may continue running, but its late completion cannot revive the retired session.
UNB's existing five-second establishment timeout remains authoritative and no browser-only timeout
is added.
insecureAcceptDeclaredPeerIdentities() is an explicit development escape hatch that trusts a
self-declared identity without validating its proof. It is not a replacement for a production peer
layer.
Hosting And TLS
node.host(options) takes a declarative options object. Transports are opt-in: a transport you do
not configure is disabled, and a host with no transport fails at start. There is no boolean named
tls — WebSocket TLS and WebTransport TLS are configured separately.
Reverse-proxy termination (recommended production default for HTTP/WebSocket — unb loads no certificates):
const hosting = await node.host({
address: '127.0.0.1:8080',
websocket: {},
}).start()Direct HTTPS/WSS termination with file-based PEM credentials — the same TLS listener serves HTTPS POST ingress and WSS upgrades over HTTP/1.1:
const hosting = await node.host({
address: '0.0.0.0:443',
websocket: {
tls: { certFile: '/run/secrets/fullchain.pem', keyFile: '/run/secrets/privkey.pem' },
},
webtransport: {
identity: { certFile: '/run/secrets/fullchain.pem', keyFile: '/run/secrets/privkey.pem' },
},
}).start()Development WebTransport requires explicit intent; the certificate hash is exposed only for this
path and can be handed to Endpoint.webtransport(url, hosting.developmentCertHash):
const hosting = await node.host({
address: '127.0.0.1:0',
websocket: {},
webtransport: { developmentSelfSigned: ['localhost'] },
}).start()Credentials are loaded and validated when host() is called, before any socket binds. Certificate
reload is deployment-owned: for TCP TLS supply a Rust-side rustls config with a dynamic certificate
resolver (Rust API escape hatch); for WebTransport, replace the hosting (restart the QUIC endpoint)
when the certificate rotates. Production WebTransport always requires a real identity — there is no
implicit self-signed certificate.
Handlers And Middleware
NodeBuilder.unary() infers the validated input and checked output types. NodeBuilder.streaming() accepts an async iterable and validates each event.
const node = Node.builder('clock')
.insecureAcceptDeclaredPeerIdentities()
.layer(async (request, next) => next.run({
context: { ...request.context, traced: true },
}))
.streaming('clock.ticks', {
input: weatherInput,
event: weatherOutput,
}, async function* (request) {
yield { city: request.payload.city, tempC: 21 }
yield { city: request.payload.city, tempC: 22 }
})
.build()Middleware runs in registration order. It may continue with next.run(), replace the local payload or context passed to later middleware and handlers, or reject by throwing a structured error.
Handlers can also be added at runtime with node.unary() and node.streaming(), then removed with removeSubject() or removeOperation().
Requests And Subscriptions
fetch and subscribe are the raw primitives: request bodies, response bodies, and stream events are Uint8Array, with status and headers passed through untouched. fetchJson<T> and subscribeJson<T> are the explicit JSON convenience APIs — they encode the request body, decode the reply, and optionally validate it with a Standard Schema passed as schema.
const stream = await node.subscribeJson<WeatherOutput>('/clock/clock/ticks', { body: { city: 'Hanoi' } })
try {
for await (const event of stream) {
console.log(event)
break
}
} finally {
await stream.return()
}EventStream is an async iterator. Normal producer completion ends iteration. return(), close(), and async disposal propagate cancellation to Rust. Always close a stream when leaving before normal completion.
Worker-backed browser Nodes also accept timeoutMs on fetch, fetchHead, and fetchJson. The
value must be a positive 32-bit integer and replaces the default 30-second call deadline for that
request; it does not change connection-establishment or subscription deadlines.
Raw Bytes
Raw handlers opt in with raw: true and exchange Uint8Array payloads without JSON or schema involvement:
const files = Node.builder('files')
.insecureAcceptDeclaredPeerIdentities()
.unary('files.blob', { raw: true }, request => request.payload)
.streaming('files.chunks', { raw: true }, async function* (request) { yield request.payload })
.build()
const response = await files.fetch('/files/files/blob', { body: new Uint8Array([0, 255, 128]) })
console.log(response.status, response.body instanceof Uint8Array)An empty body decodes to null under fetchJson/subscribeJson; a non-empty body that is not valid JSON throws JsonError, which carries the raw bytes and the parse cause so callers can fall back to byte handling.
Discovery And Schemas
Standard Schema validators and discovery JSON Schema have separate purposes:
input,output, andeventStandard Schema values validate runtime application data.discovery.input,discovery.output,discovery.event, anddiscovery.errordescribe contracts to remote tools and catalogs.- Runtime validators are never converted into JSON Schema automatically.
Node applications can inspect localCatalog(false) for the local index, localCatalog(true) for complete local contracts, catalog() for reachable catalog data, and discoverTree() for the reachable node graph. Browser clients use discover() and discoverTree().
Errors
All package errors extend UnbError:
ProtocolErrorexposes a stable SCREAMING_SNAKE_CASEcode, such asUNKNOWN_SUBJECT,INVALID_INPUT,BUSY, orCANCELLED.ValidationErrorexposes Standard Schema issues.JsonErrorreports a non-JSON body underfetchJson/subscribeJsonand carries the rawbytes.DialErrorreports endpoint selection and connection failures.BindingErrorreports native or WASI binding failures.AiErrorexposes AI operation codes such asunknown_toolorinvalid_input.UnsupportedPlatformErroridentifies a capability unavailable on the current runtime.
Raw fetch resolves protocol error frames as responses: the mapped status, the exact code in the unb-code header, and the error body as bytes. fetchJson throws ProtocolError instead.
import { decodeJsonBody, ProtocolError } from '@midwess/unb/node'
const response = await node.fetch('/weather/weather.missing')
if (response.status >= 400) console.error(response.headers['unb-code'], decodeJsonBody(response.body))
try {
await node.fetchJson('/weather/weather.missing')
} catch (error) {
if (error instanceof ProtocolError) console.error(error.code, error.message)
else throw error
}Use error classes and codes as machine-readable behavior. Human-oriented message suggestions are not a stable data format.
Browser Client
import { BrowserClient, Endpoint, EndpointSet } from '@midwess/unb/browser'
const endpoints = new EndpointSet([
Endpoint.webtransport('https://api.example.com/wire', 'CERT_HASH_HEX'),
Endpoint.websocket('wss://api.example.com/wire'),
])
const client = await BrowserClient.connect(endpoints, { peer: 'api' })
try {
const reply = await client.fetchJson('/api/weather.now', {
body: { city: 'Hanoi' },
headers: { trace: 'browser-example' },
})
console.log(reply.body)
const stream = await client.subscribeJson('/api/clock.ticks')
try {
console.log(await stream.next())
} finally {
await stream.return()
}
await client.discover('/api', { detail: 'index', scope: 'reachable' })
await client.discoverTree({ root: 'api', maxDepth: 4 })
} finally {
await client.close()
}The endpoint list expresses preference and fallback. Rust prefers WebTransport when supported, then tries WebSocket after connection failure or timeout. The public API does not expose the selected transport, so application code should not infer it from successful connection alone.
Browsers support Node construction, linking, target-routed requests, subscriptions, discovery, dynamic unary and streaming handlers, and peer-admission layers. Listener hosting, request-continuation layer() middleware, state registration, and native/provider capabilities remain unsupported and throw UnsupportedPlatformError where exposed.
Browser Deployment
The browser runtime requires workers only. It does not require SharedArrayBuffer, atomics, WebAssembly threads, or crossOriginIsolated, so no Cross-Origin-Opener-Policy or Cross-Origin-Embedder-Policy header is needed on the host document. The runtime therefore loads in third-party iframes, alongside scripts served without Cross-Origin-Resource-Policy, and in embeds.
Cross-origin WASM, worker, and transport resources must still permit loading through suitable CORS headers. The package emits its WASM, loader, and worker under dist/browser-runtime/ and resolves them through package-relative URLs. Bundlers and static servers must preserve and serve those relationships.
The worker executes trusted package-native WebAssembly. Treat the package, worker, WASM, loader, and their hosting origin as trusted executable dependencies, and apply dependency-integrity and Content Security Policy controls.
AI Projection
discoverTree(), await listTools(), and callTool() expose Rust-backed discovery and tool projection. listTools reads the subject's full discovery contract: a described unary subject yields a schema-bearing tool (complete: true), and callTool validates input against that schema before anything is sent — a validation failure returns an invalid_input error carrying the schema so the caller can correct itself. A subject without a usable contract keeps the generic request fallback with complete: false; inspect ToolList.warnings rather than assuming every subject is schema-described.
Cleanup
Close resources deterministically:
await stream.return()orawait stream.close()for subscriptions.await client.close()forBrowserClient.hosting.close()for listeners.node.close()for native nodes.
Finalizers are best-effort safeguards, not the primary lifecycle strategy. Use try/finally around owned resources.
Runtime Matrix
| Capability | Node.js 20+ on macOS arm64 | Qualified browser |
|---|---:|---:|
| Conditional package root | Native binding | Browser binding through bundler |
| Requests, subscriptions, discovery | Yes | Yes |
| WebSocket and WebTransport dialing | Yes | Yes, with capability detection and fallback |
| Build and link a node | Yes | Yes |
| Listener hosting | Yes | No |
| JavaScript unary and streaming handlers | Yes | Yes |
| Peer-admission peerLayer() | Yes | Yes |
| Request-continuation layer() | Yes | No |
| AI tree and tool projection | Limited facade | Client discovery only |
There is no JavaScript protocol fallback and no public raw native-binding export.
