@sideband/runtime
v0.5.0
Published
Transport-agnostic Sideband runtime: manage peers, attach transports, route frames, correlate RPC calls, and expose middleware hooks without concrete I/O.
Downloads
525
Maintainers
Readme
@sideband/runtime
Transport-agnostic Sideband runtime pieces: session lifecycle, message routing, and RPC correlation. Most apps should use @sideband/peer; reach for @sideband/runtime when you need custom transports or want direct control over session and routing behavior.
Features
- SessionManager: Connect -> negotiate -> active with retry/backoff and lifecycle events
- Router: Subject validation, handler registry, RPC dispatch, and error mapping
- RpcCorrelationManager: Pending RPC tracking with timeouts and explicit
cidcorrelation - SbpNegotiator: Built-in SBP handshake negotiator for direct connections
- NegotiatorConnectionParams: Dynamic endpoint/header resolution per connect attempt (rotate time-limited tokens on every reconnect)
- SessionSignal: Type for out-of-band session protocol signals (e.g., SBRP pause/resume/end/pending)
Install
bun add @sideband/runtimeQuick start: session + router
import {
createRouter,
createSessionManager,
SbpNegotiator,
type Session,
} from "@sideband/runtime";
import { asPeerId, isMessageFrame } from "@sideband/protocol";
import {
LoopbackTransport,
unsafeAsTransportEndpoint,
} from "@sideband/transport";
const router = createRouter();
router.route("rpc", async (msg) => {
// Dispatch by method name from envelope (msg.rpc.method)
if (msg.rpc?.method === "user.get") {
const userId = (msg.rpc.params as { id: number }).id;
await msg.rpc.reply({ id: userId, name: "Ada" });
}
});
let activeSession: Session | undefined;
// LoopbackTransport is for tests; use wsTransport() + wsEndpoint() in production.
// unsafeAsTransportEndpoint brands a raw string without URL validation.
const transport = new LoopbackTransport();
const endpoint = unsafeAsTransportEndpoint("loopback://test");
const manager = createSessionManager({
endpoint,
transportFactory: (endpoint) => transport.connect(endpoint),
negotiator: new SbpNegotiator({ peerId: asPeerId("browser-ui") }),
onFrame: async (frame) => {
if (!activeSession || !isMessageFrame(frame)) return;
const sessionLike = {
peerId: activeSession.peerId,
send: (data: Uint8Array) => activeSession.sendRaw(data),
};
const errorBytes = await router.dispatch(frame, sessionLike);
if (errorBytes) {
await activeSession.sendRaw(errorBytes);
}
},
});
activeSession = await manager.connect();Notes:
Router.dispatch()expectsMessageFrameand returns encoded ErrorFrame bytes when subject validation or RPC envelope decoding fails. Send those bytes back on the session channel.SessionManageronly decodes frames; higher layers own validation and routing.
Quick start: RPC correlation
import { RpcCorrelationManager } from "@sideband/runtime";
import { createRpcRequest, decodeRpcEnvelope, encodeRpcEnvelope } from "@sideband/rpc";
import { generateFrameId } from "@sideband/protocol";
const correlator = new RpcCorrelationManager(10_000);
const cid = generateFrameId();
const request = createRpcRequest("user.get", cid, { id: 42 });
const requestPayload = encodeRpcEnvelope(request);
const pending = correlator.registerRequest(cid);
// send requestPayload inside a MessageFrame over your transport...
const responsePayload = /* MessageFrame.data from remote peer */;
const envelope = decodeRpcEnvelope(responsePayload);
correlator.matchResponse(envelope.cid, envelope);
const response = await pending;Core concepts
SessionManager
connect()establishes a session with a negotiator and emits lifecycle events.terminate(options?)closes the session and cancels any pending retry.on(event, handler)subscribes toconnecting,negotiating,active,retrying,closed, and identity events.- Retry is opt-in via
retryPolicyand uses exponential backoff with jitter. onDecodeErrorhook controls whether malformed frames are ignored (default) or treated as fatal.
Session
sendFrame(frame)— preferred API; type-safe, enforces SBP frame structure.sendRaw(data)— expert escape hatch; caller must supply a complete, valid SBP frame.channelmay be a session wrapper (not always raw transport). Closing the session channel may not close the underlying transport.
Router
- Validates subjects against a policy and dispatches by deterministic ordering.
- RPC subjects use exclusive dispatch with timeout handling and error mapping.
- Event and custom subjects broadcast to all matching handlers.
Subject policy defaults
- Allowed channels:
rpc,event(exact-match) - Reserved channels:
stream(rejected withErrorCode.UnsupportedFeature) - Allowed prefixes:
app/(for custom sub-paths) - Use
createRouter(config, subjectPolicy)to override.
Negotiators
SbpNegotiatorimplements the SBP handshake (direct peer-to-peer).getConnectionParams?()is called before each connect attempt (initial and every reconnect) to resolve a fresh endpoint URL and optional upgrade headers. Implement this to rotate time-limited tokens (e.g., embed a new JWT in the URL on each attempt). If neither this norSessionConfig.endpointprovides a non-empty endpoint, the runtime throws.- Custom negotiators MAY return
subscribeSignalsinNegotiationResult(e.g., SBRPsession_paused,session_resumed,session_ended,session_pending) for higher layers that use it. SessionManagerdoes not emitSessionSignaldirectly; runtime exposes the type/contract so wrappers can forward these signals.- SBRP is not built in. Integrate
@sideband/secure-relayby implementing a customNegotiatorthat returns a wrappedSessionChanneland, optionally, asubscribeSignalsfunction.
References
- Session lifecycle (ADR-009)
- RPC correlation (ADR-010)
- Routing semantics (ADR-011)
- Subject validation (ADR-008)
License
Apache-2.0
