Architecture
osra is three layers: transports normalize any channel into two messaging primitives, connections run the handshake and per-peer protocol state, and revivables translate values into wire-safe boxes and back. This page maps each layer to its place in src/ and explains the two mechanisms everything else is built on: the box → revive walk and portId routing.
The three layers
Section titled “The three layers”-
Transports (
src/utils/transport.ts,src/utils/type-guards.ts) normalize any supported channel — Window, Worker,DedicatedWorkerGlobalScope, SharedWorker, MessagePort, WebSocket, ServiceWorker/ServiceWorkerContainer, WebExtension runtime/Port/onConnect/onMessage, or a custom{ emit, receive }pair — into two primitives:sendOsraMessage(transport, envelope, origin, transferables)andregisterOsraMessageListener({ listener, transport, key, remoteName, origin, unregisterSignal }). See transports for the user-facing catalogue and low-level messaging for the primitives themselves. -
Connections (
src/connections/) own the handshake, per-peer connection state, and the envelope protocol. Oneexpose()call creates one protocol instance with its ownuuid; each discovered peer gets aConnectionContextkeyed by the peer’s uuid. Envelope shapes are documented in the wire protocol reference; how peers find each other is covered in the handshake. -
Revivables (
src/revivables/) are an ordered list of modules, each owning one value type withisType/box/revive(and optionallyinitfor per-connection state). Non-JSON values inside envelopedataare replaced by boxes: plain objects tagged with__OSRA_BOX__that name the module that owns them. You can add, drop, reorder, or override modules — see custom revivables.
The box → revive walk
Section titled “The box → revive walk”recursiveBox (src/revivables/index.ts) walks a value depth-first: the first module whose isType matches handles it; otherwise arrays and plain objects (prototype exactly Object.prototype) are descended into, and anything else passes through as-is. recursiveRevive mirrors it, dispatching each box to the module with the matching type string.
The walk’s rules have direct consequences:
- First match wins. Module order decides which module claims a value; this is why custom modules usually go ahead of the defaults, which end in catch-all fallbacks. See custom revivables.
- Null-prototype objects are not descended into. Live values nested inside one are never boxed: a function in a null-prototype object throws
DataCloneErroratpostMessageon clone transports, or is dropped on JSON. - Circular structures throw. Both walks track the current ancestor path and throw a
TypeErroron circular structures instead of recursing forever. - Pre-built boxes pass through. Already-boxed values pass through
recursiveBoxuntouched, so modules can embed pre-built boxes in their payloads.
portId routing: one logical channel
Section titled “portId routing: one logical channel”Live values (functions, promises, streams, ports, signals, …) all reduce to MessagePort semantics. The message-port module (src/revivables/message-port.ts) multiplexes every such port over the single underlying transport: each boxed port gets a random portId, its traffic rides { type: 'message', portId, data } envelopes, and a per-connection Map<portId, handler> gives O(1) dispatch. { type: 'message-port-close', portId } tears one port down without touching the rest.
On clone transports a real MessagePort is transferred directly when possible; everything else — and everything on JSON transports — routes via portId. The distinction matters at teardown: wire-routed traffic dies with the connection, while real transferred ports survive it. See lifecycle.
EventChannel: synthetic ports
Section titled “EventChannel: synthetic ports”EventChannel/EventPort (src/utils/event-channel.ts) is an in-memory MessageChannel look-alike — postMessage queues until start(), close() notifies the peer — that never touches structured clone. It is used wherever a real MessageChannel can’t be:
- on JSON transports, where ports can’t be transferred, and
- for every function call channel (
src/revivables/function.ts), even on clone transports, because revived live values appearing in call arguments aren’t structured-clonable.
Synthetic ports are always wire-routed via portId.