Skip to content

Low-level API

Everything on this page is exported from osra directly, but none of it is needed for everyday use, that is what expose() is for.
These are the primitives expose() itself is built on, and you reach for them when building infrastructure around osra: relays, custom transports, tooling that inspects osra traffic, or your own typed wrapper.

startConnections(value, options)

The engine underneath expose().
At runtime the two are the same thing: expose() only adds the compile time layer, the Capable check that rejects unsendable values at the call site and the Remote<T> mapping of the peer’s value, then delegates straight to startConnections().

const remote = await expose<Api>(value, { transport })
// behaves at runtime exactly like
const remote = await startConnections<Remote<Api>>(value, { transport })

It takes the same options as expose() and returns the same Exposed result: awaiting it gives the first peer, iterating it gives every peer as it connects.
The handshake, the connection tracking and the teardown described in errors and lifecycle all live here.

One thing to remember is that skipping expose() also skips its transport-aware type checking.
The value is still typed as Capable, so a WeakMap still fails to compile, just with a plain assignability error instead of the branded report, and the transport narrowing is gone entirely: a File over a WebSocket compiles here and only fails at runtime, where expose() would have rejected it at the call site.
This means that startConnections() is only worth calling from plumbing where the typed layer gets in the way, for example when the value or the transport are only known at runtime.

If you try to pass a transport that cannot both emit and receive, the returned promise rejects immediately, since no connection can be established over half a channel.

relay(transportA, transportB, options?)

Forwards osra traffic between two channels.
Use it when two contexts cannot see each other, but both can see you.
A worker and an iframe are the typical pair: neither holds a reference to the other, so the page in the middle relays between them.

function relay(transportA: Transport, transportB: Transport, { key, origin, originA, originB, nameA, nameB, unregisterSignal, }?: RelayOptions): void
relay
(
const worker: Worker
worker
, {
emit: Window
emit
:
const iframe: HTMLIFrameElement
iframe
.
HTMLIFrameElement.contentWindow: Window | null

The contentWindow property returns the Window object of an HTMLIFrameElement.

MDN Reference

contentWindow
!,
receive: Window & typeof globalThis
receive
:
var window: Window & typeof globalThis

The window property of a Window object points to the window object itself.

MDN Reference

window
}, {
key?: string | undefined
key
: 'app' })

Only envelopes matching key are forwarded, and nothing is ever revived in the middle, so no value ever exists in the relay context.
The two real ends still handshake directly with each other, the relay is invisible to them.
Every forwarded message goes through getTransferableObjects() again, so buffers that were moved into the relay context are moved out of it too.

Option
key Which channel to forward. Defaults to osra’s default key.
origin Applies to both sides.
originA / originB Per side, overriding origin.
nameA / nameB Per side, only forward envelopes from a peer with this name.
unregisterSignal Abort to unhook both directions.

One thing to note is that each direction is hooked up independently.
If one transport cannot receive or the other cannot emit, that direction is simply skipped, so a mismatched pair degrades to one way forwarding.

More context in custom transports & relays.

registerOsraMessageListener({ listener, transport, key?, remoteName?, origin?, unregisterSignal? })

Subscribes to osra messages on a transport and hands them to you raw, still boxed, nothing revived.
It knows how to listen on every transport kind: it parses JSON strings for you, listens on a SharedWorker’s .port, calls .start() on a MessagePort, and handles the whole web extension family.
It also filters by key, remoteName and origin exactly the same way expose() does, because expose() uses it internally.

The reason to reach for it is the second argument your listener gets, which expose() does not surface:

MessageContext
sender The web extension sender, when there is one.
port The extension Port it arrived on.
source The MessageEventSource, for window and worker messages.
origin The event.origin, for window messages.
receiveTransport The transport it came from.

That makes it the way to filter extension messages by sender before osra ever processes them.
expose() does surface the sender too, but only per connection through the context, after the handshake has already run, where the remedy is context.abort().
Here you can drop the message outright:

background.ts
import {
const expose: <T = unknown, const TModules extends readonly RevivableModule[] = readonly [typeof import("osra/build/revivables/transfer"), typeof import("osra/build/revivables/identity"), typeof import("osra/build/revivables/array-buffer"), typeof import("osra/build/revivables/date"), typeof import("osra/build/revivables/headers"), typeof import("osra/build/revivables/error"), typeof import("osra/build/revivables/typed-array"), typeof import("osra/build/revivables/promise"), typeof import("osra/build/revivables/function"), typeof import("osra/build/revivables/message-port"), typeof import("osra/build/revivables/readable-stream"), ... 16 more ..., {
...;
}], const TTransport extends Transport = Transport, const TValue = Capable<...>, TResult = Remote<...>>(value: TValue extends Contextual<infer U> ? Contextual<CapableCheck<...>> : CapableCheck<...>, options: Omit<StartConnectionsOptions<TModules>, "connection"> & {
transport: TTransport;
connection?: (connected: Connected<Remote<T>>) => TResult;
}) => Exposed<TResult>

Expose a value to whoever connects, and get back what they exposed.

Wrap value in context to build it once per connection, which is what lets one server answer each realm differently (scoped resolvers per app) instead of sharing one object across all of them. A bare function stays a plain exposed endpoint, so the wrapper is what disambiguates the two.

The result is both awaitable and async-iterable: awaiting gives the first peer, iterating gives every peer as it connects. Both hand back the same shape.

const remote = await expose(resolvers, { transport }) // the first peer's value
for await (const remote of expose(resolvers, { transport })) { } // every peer's value

connection decides what that shape is. Omit it and it is the peer's value, which is what expose has always resolved to. Return whatever a connection should mean instead:

const { value, context } = await expose(resolvers, {
transport,
connection: ({ value, context }) => ({ value, context }),
})
for await (const peer of expose(resolvers, {
transport,
connection: ({ value, context }) => ({ value, context }),
})) {
if (!allowed(peer.context.origin)) peer.context.abort?.()
}

A peer's identity is whatever the transport can observe merged over whatever the caller declared in context. Only a window message carries a browser-set origin and source; a MessagePort message carries neither, so a port-based server declares what it learned when it received the port. Observed fields win over declared ones, so a declaration can never spoof a real origin.

expose
,
const registerOsraMessageListener: ({ listener, transport, remoteName, key, origin, unregisterSignal }: {
listener: (message: Message, messageContext: MessageContext) => void;
transport: ReceiveTransport;
remoteName?: string;
key?: string;
origin?: string;
unregisterSignal?: AbortSignal;
}) => void
registerOsraMessageListener
} from 'osra'
expose<unknown, readonly [typeof import("osra/build/revivables/transfer"), typeof import("osra/build/revivables/identity"), typeof import("osra/build/revivables/array-buffer"), typeof import("osra/build/revivables/date"), typeof import("osra/build/revivables/headers"), typeof import("osra/build/revivables/error"), typeof import("osra/build/revivables/typed-array"), typeof import("osra/build/revivables/promise"), typeof import("osra/build/revivables/function"), typeof import("osra/build/revivables/message-port"), typeof import("osra/build/revivables/readable-stream"), typeof import("osra/build/revivables/writable-stream"), ... 15 more ..., {
...;
}], {
...;
}, {
...;
}, unknown>(value: {
...;
}, options: Omit<...> & {
...;
}): Exposed<...>

Expose a value to whoever connects, and get back what they exposed.

Wrap value in context to build it once per connection, which is what lets one server answer each realm differently (scoped resolvers per app) instead of sharing one object across all of them. A bare function stays a plain exposed endpoint, so the wrapper is what disambiguates the two.

The result is both awaitable and async-iterable: awaiting gives the first peer, iterating gives every peer as it connects. Both hand back the same shape.

const remote = await expose(resolvers, { transport }) // the first peer's value
for await (const remote of expose(resolvers, { transport })) { } // every peer's value

connection decides what that shape is. Omit it and it is the peer's value, which is what expose has always resolved to. Return whatever a connection should mean instead:

const { value, context } = await expose(resolvers, {
transport,
connection: ({ value, context }) => ({ value, context }),
})
for await (const peer of expose(resolvers, {
transport,
connection: ({ value, context }) => ({ value, context }),
})) {
if (!allowed(peer.context.origin)) peer.context.abort?.()
}

A peer's identity is whatever the transport can observe merged over whatever the caller declared in context. Only a window message carries a browser-set origin and source; a MessagePort message carries neither, so a port-based server declares what it learned when it received the port. Observed fields win over declared ones, so a declaration can never spoof a real origin.

expose
(
{
add: (a: number, b: number) => number
add
: (
a: number
a
: number,
b: number
b
: number) =>
a: number
a
+
b: number
b
},
{
transport: Transport & {
readonly isJson: true;
readonly emit: (message: Message) => Promise<unknown>;
readonly receive: (listener: (event: Message, messageContext: MessageContext) => void) => void;
}
transport
: {
isJson: true
isJson
: true,
emit: (message: Message) => Promise<unknown>
emit
:
message: Message
message
=>
const runtime: Runtime.Static

Use the browser.runtime API to retrieve the background page, return details about the manifest, and listen for and respond to events in the app or extension lifecycle. You can also use this API to convert the relative path of URLs to fully-qualified URLs.

runtime
.
Runtime.Static.sendMessage<Message, unknown>(message: Message, options?: Runtime.SendMessageOptionsType): Promise<unknown> (+1 overload)

Sends a single message to event listeners within your extension/app or a different extension/app. Similar to $(ref:runtime.connect) but only sends a single message, with an optional response. If sending to your extension, the $(ref:runtime.onMessage) event will be fired in each page, or $(ref:runtime. onMessageExternal), if a different extension. Note that extensions cannot send messages to content scripts using this method. To send messages to content scripts, use $(ref:tabs.sendMessage).

@paramoptions Optional.

sendMessage
(
message: Message
message
),
receive: (listener: (event: Message, messageContext: MessageContext) => void) => void
receive
:
listener: (event: Message, messageContext: MessageContext) => void
listener
=>
function registerOsraMessageListener({ listener, transport, remoteName, key, origin, unregisterSignal }: {
listener: (message: Message, messageContext: MessageContext) => void;
transport: ReceiveTransport;
remoteName?: string;
key?: string;
origin?: string;
unregisterSignal?: AbortSignal;
}): void
registerOsraMessageListener
({
transport: ReceiveTransport
transport
:
const runtime: Runtime.Static

Use the browser.runtime API to retrieve the background page, return details about the manifest, and listen for and respond to events in the app or extension lifecycle. You can also use this API to convert the relative path of URLs to fully-qualified URLs.

runtime
,
listener: (message: Message, messageContext: MessageContext) => void
listener
: (
message: Message
message
,
context: MessageContext
context
) => {
if (
context: MessageContext
context
.
sender?: WebExtSender | undefined
sender
?.
id?: string | undefined
id
!==
const runtime: Runtime.Static

Use the browser.runtime API to retrieve the background page, return details about the manifest, and listen for and respond to events in the app or extension lifecycle. You can also use this API to convert the relative path of URLs to fully-qualified URLs.

runtime
.
Runtime.Static.id: string

The ID of the extension/app.

id
) return
listener: (event: Message, messageContext: MessageContext) => void
listener
(
message: Message
message
,
context: MessageContext
context
)
}
})
}
}
)

One thing to remember is that registerOsraMessageListener filters with osra’s default key when you do not pass one.
If your expose() uses a custom key, pass the same one here, or every envelope is silently dropped.

Also keep in mind that a custom receive handler can return a cleanup function, and registerOsraMessageListener calls it when unregisterSignal aborts.

If you only need the filter itself, checkOsraMessageKey(message, key) is the guard it uses internally: it checks that a value is an osra envelope carrying that key.
You can see it multiplexing peers by hand in the connectionless extension example.

sendOsraMessage(transport, message, origin?, transferables?)

The other half.
It picks the right send call for whatever transport you hand it:

Transport How it sends
Window postMessage(message, origin, transferables)
SharedWorker Posts on its .port
WebSocket JSON.stringify, queued until open while the socket is still connecting
WebExtension runtime runtime.sendMessage()
WebExtension Port port.postMessage()
Custom { emit } Your emit(message, transferables)
Everything else postMessage(message, transferables)

Every message osra sends funnels through here, so two teardown behaviors are built in:

  • A web extension Port throws on postMessage once disconnected. After the first such throw the port is remembered and further sends become no-ops, instead of one throw per message while a stream is still winding down.
  • runtime.sendMessage rejects with “Receiving end does not exist” while nobody is listening yet, which is normal while osra announces itself, so exactly that rejection is swallowed and any other error stays visible.
getTransferableObjects(message): Transferable[]

Walks a boxed message and collects the values that should be moved rather than copied on postMessage.
Osra’s connection layer runs it right before every send and passes the result to sendOsraMessage() as its transferables argument, and relay() does the same for every message it forwards.
It is exported for infrastructure that sends envelopes itself and needs to build the same transfer list.

Three rules decide what ends up on the list:

  • Values that structured clone refuses to copy (MessagePort, ReadableStream, WritableStream, TransformStream, OffscreenCanvas, …) are always included, opted in or not.
  • SharedArrayBuffer is never included, shared memory is meant to be shared, not moved.
  • Every other transferable (ArrayBuffer, ImageBitmap, VideoFrame, …) is only included inside a transfer() box, and only when the wrapper did not degrade to a copy.

The guards osra uses to recognise transports and values, exported so custom transports and revivables can make the same decisions.

Transports: isTransport, isEmitTransport, isReceiveTransport, isCustomTransport, isCustomEmitTransport, isCustomReceiveTransport, isJsonOnlyTransport, isEmitJsonOnlyTransport, isReceiveJsonOnlyTransport, plus assertEmitTransport and assertReceiveTransport which throw instead of returning false.

Platform objects: isWindow, isWorker, isDedicatedWorker, isSharedWorker, isServiceWorker, isServiceWorkerContainer, isWebSocket.

Web extension: isWebExtensionRuntime, isWebExtensionPort, isWebExtensionOnConnect, isWebExtensionOnMessage.

Values: isTypedArray, isTransferable, isSharedArrayBuffer, isOsraMessage, isRevivableBox, instanceOfAny.

Most of them probe globalThis before touching a constructor, and instanceOfAny skips constructors that do not exist, so a platform that never heard of SharedWorker or Float16Array does not crash them.
isWindow is worth knowing about: a cross-origin window throws a SecurityError on most property access, so it probes only the few properties that never do.

recursiveBox(value, context) and recursiveRevive(boxed, context) are the two halves of osra’s value walker, exported for custom revivables that box their own fields.
BoxBase is the marker every box spreads, and it is what isRevivableBox looks for.

A few behaviors worth knowing about:

  • An already boxed value passes through recursiveBox untouched, so boxing a field twice is safe.
  • Both walkers throw a TypeError on circular structures, which is why expose()’s promise can reject with one.
  • For binary fields there is boxBuffer(buffer, context) and reviveBuffer(boxed), the same pair osra’s own modules use: a raw ArrayBuffer on structured transports, base64 on JSON ones.
  • boxClaimedValue(value, context, yourType) is for the rarer module that claims a bare value rather than a wrapper around one, the way identity() marks a reference in place. It boxes the value through every other module and walks its children as usual, skipping your own module and the cycle guard the walker already holds for that value, both of which recursiveBox on the same value would trip.

The default module list is also exported as defaultRevivableModules, though the revivableModules option already hands it to you, so you rarely need the export itself.

Three small helpers let a live value clean up after its connection:

  • onTeardown(context, fn) runs fn when the connection is torn down, and returns a function that unregisters it. Registering against a context that is already torn down runs fn immediately.
  • isTornDown(context) tells you whether that already happened, so a module can refuse new work instead of starting something no teardown will ever visit.
  • onBoxWalkSettled(commit, rollback) defers a side effect until the box walk it belongs to has finished, and runs rollback instead if that walk throws. A module that records “the peer knows this now” wants this, since a later sibling failing to box means nothing shipped.
Value
OSRA_KEY '__OSRA_KEY__' The envelope field holding the channel key.
OSRA_DEFAULT_KEY '__OSRA_DEFAULT_KEY__' The key used when you do not pass one.
OSRA_BOX '__OSRA_BOX__' The field marking an object as a revivable box.

The public types, all importable from osra directly:

Remote, Capable, Exposed and Connected have their own page: TypeScript.
The generated API reference lists every export with its exact signature.