Skip to content

Errors and lifecycle

Osra connections are long lived, and so are the calls, streams and promises riding on them.
This page covers what happens around the happy path: how a throw on one side reaches the other, what expose() does while it’s still looking for a peer, and what becomes of everything in flight when a connection goes away.

If the other side throws while handling one of your calls, your pending promise rejects with that error.
The thrown value goes through the same treatment as any other value, so you catch a real Error carrying the original message, the other side’s stack, and its cause if it had one.

worker.ts
const
const payload: {
parse: (input: string) => {
ok: boolean;
};
}
payload
= {
parse: (input: string) => {
ok: boolean;
}
parse
: (
input: string
input
: string) => {
if (
input: string
input
!== 'valid') throw new
var TypeError: TypeErrorConstructor
new (message?: string, options?: ErrorOptions) => TypeError (+3 overloads)
TypeError
(`could not parse "${
input: string
input
}"`)
return {
ok: boolean
ok
: true }
}
}
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 ..., {
...;
}], typeof globalThis, {
...;
}, 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
(
const payload: {
parse: (input: string) => {
ok: boolean;
};
}
payload
, {
transport: Transport & typeof globalThis
transport
:
module globalThis
globalThis
})
main.ts
const {
const parse: (input: string) => Promise<{
ok: boolean;
}>
parse
} = await
expose<{
parse: (input: string) => {
ok: boolean;
};
}, 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 ..., {
...;
}], Transport, Capable<...>, {
...;
}>(value: Capable<...>, 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
<
type Payload = {
parse: (input: string) => {
ok: boolean;
};
}
Payload
>({}, {
transport: Transport
transport
:
const worker: Worker
worker
})
try {
await
const parse: (input: string) => Promise<{
ok: boolean;
}>
parse
('nope')
} catch (
var error: unknown
error
) {
if (
var error: unknown
error
instanceof
var TypeError: TypeErrorConstructor
TypeError
) {
var error: TypeError
error
.
Error.message: string
message
// could not parse "nope"
var error: TypeError
error
.
Error.stack?: string | undefined
stack
// the worker's stack, not yours
}
}

Built-in error classes arrive as an instance of the same class, which is why instanceof TypeError works above.
A custom Error subclass arrives as a plain Error that keeps its name, message, stack and cause, so compare on error.name instead of instanceof.
The full list is in supported types.

Throwing something that isn’t an Error works too, the caller simply catches whatever value was thrown.

expose() resolves once the two sides have found each other.
Until then, it keeps announcing itself on the transport, backing off from 50ms up to once a second, and stops as soon as a peer connects.

This is what lets you expose() toward an iframe that hasn’t finished loading, or a worker that starts late: whenever the other side shows up, the next announce completes the handshake.

If there is genuinely nobody on the other end, expose() stays pending forever.
This is deliberate, a peer that shows up ten seconds later still connects.
If you need a deadline, abort your unregisterSignal once it passes:

const
const controller: AbortController
controller
= new
var AbortController: new () => AbortController

The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.

MDN Reference

AbortController
()
const
const timer: number
timer
=
function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number
setTimeout
(() =>
const controller: AbortController
controller
.
AbortController.abort(reason?: any): void

The abort() method of the AbortController interface aborts an asynchronous operation before it has completed. This is able to abort fetch requests, the consumption of any response bodies, or streams.

MDN Reference

abort
(new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
('no peer after 5s')), 5000)
const
const remote: {
ping: () => Promise<string>;
}
remote
= await
expose<Api, 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 ..., {
...;
}], Transport, Capable<...>, {
...;
}>(value: Capable<...>, 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
<
type Api = {
ping: () => string;
}
Api
>({}, {
transport: Transport
transport
:
const worker: Worker
worker
,
unregisterSignal?: AbortSignal | undefined
unregisterSignal
:
const controller: AbortController
controller
.
AbortController.signal: AbortSignal

The signal read-only property of the AbortController interface returns an AbortSignal object instance, which can be used to communicate with/abort an asynchronous operation as desired.

MDN Reference

signal
})
function clearTimeout(id: number | undefined): void
clearTimeout
(
const timer: number
timer
)

If the peer connects in time, clearing the timer keeps the connection alive.
Otherwise the abort rejects expose() with the error you aborted with.

expose() also rejects right away when a connection can never happen:

  • the transport cannot both emit and receive
  • your own value cannot be sent, a circular structure for example
  • the peer’s value cannot be revived on your side
  • the peer closes before the handshake finishes, or refuses you
  • your unregisterSignal aborts, in which case it rejects with your abort reason

To tear your side down, pass an AbortSignal as the unregisterSignal option and abort it whenever you are done:

const
const controller: AbortController
controller
= new
var AbortController: new () => AbortController

The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.

MDN Reference

AbortController
()
const
const remote: {
slowCall: () => Promise<string>;
}
remote
= await
expose<Api, 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 ..., {
...;
}], Transport, Capable<...>, {
...;
}>(value: Capable<...>, 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
<
type Api = {
slowCall: () => Promise<string>;
}
Api
>({}, {
transport: Transport
transport
:
const worker: Worker
worker
,
unregisterSignal?: AbortSignal | undefined
unregisterSignal
:
const controller: AbortController
controller
.
AbortController.signal: AbortSignal

The signal read-only property of the AbortController interface returns an AbortSignal object instance, which can be used to communicate with/abort an asynchronous operation as desired.

MDN Reference

signal
})
const
const pending: Promise<string>
pending
=
const remote: {
slowCall: () => Promise<string>;
}
remote
.
slowCall: () => Promise<string>
slowCall
()
const controller: AbortController
controller
.
AbortController.abort(reason?: any): void

The abort() method of the AbortController interface aborts an asynchronous operation before it has completed. This is able to abort fetch requests, the consumption of any response bodies, or streams.

MDN Reference

abort
(new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
('shutting down'))
// pending rejects with Error('osra: connection closed')

Aborting does everything at once:

  • it stops listening on the transport
  • it tells every connected peer that the connection is over
  • it rejects all of your pending calls with Error('osra: connection closed')
  • it rejects the expose() promise with your abort reason, if it hadn’t resolved yet
  • it ends any for await loop you had running over the connections

The peer that receives the close runs the same teardown on its side, so pending calls reject on both ends instead of hanging.
Calling a revived function after the connection closed rejects immediately with that same error, without ever touching the transport.

A signal that is already aborted when you call expose() short-circuits: nothing gets registered on the transport and the promise rejects immediately with the abort reason.

Note: aborting does not poison the transport.
Calling expose() on it again starts a completely fresh handshake.

unregisterSignal ends your whole side at once.
If you are serving multiple peers over one transport and only want to drop one of them, call abort() on that connection’s context instead:

for await (const
const peer: {
value: unknown;
context: Context;
}
peer
of
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 ..., {
...;
}], Window, {}, {
...;
}>(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
({}, {
transport: Transport & Window
transport
:
const child: Window
child
,
connection?: ((connected: Connected<unknown>) => {
value: unknown;
context: Context;
}) | undefined
connection
: ({
value: unknown
value
,
context: Context
context
}) => ({
value: unknown
value
,
context: Context
context
})
})) {
if (!
const allowed: (origin: string | undefined) => boolean
allowed
(
const peer: {
value: unknown;
context: Context;
}
peer
.
context: Context
context
.
origin?: string | undefined
origin
))
const peer: {
value: unknown;
context: Context;
}
peer
.
context: Context
context
.
abort?: (() => void) | undefined

Tears down THIS connection and nothing else: the peer is sent a close, its revivables are torn down, and it stops being tracked. unregisterSignal is the whole-expose equivalent; this is the one a server reaches for when a single realm misbehaves or is finished with.

abort
?.()
}

The dropped peer sees exactly the same close it would see from a full teardown, so its pending calls reject rather than hang, while every other peer stays connected.
More in connections.

On a structured transport, revivables like promises and streams ride real MessagePorts that are transferred through the transport.
Once such a port has crossed, it is independent of the osra connection that carried it, so a promise or a stream that was already on its way keeps working after the connection closes.

Function calls are the exception: their channel always routes through the connection itself, on every transport, so they always reject on teardown.

On a JSON transport there are no real ports to transfer, everything is routed through the connection, so everything dies with it.
Streams get cancelled or errored with that same connection closed error, and their pending writes reject.

One thing to remember is that a revived AbortSignal does not abort when the connection dies, so a remote signal cannot serve as a liveness check.
If you need to observe the death of a connection, use your own unregisterSignal, or the rejection of a pending call.

Message What happened
osra: connection closed Your side aborted, or the peer did. Pending calls, streams and writers all reject with this.
osra: peer closed the connection The peer went away before the handshake finished, or refused you outright.
osra: connection aborted You dropped a peer with context.abort() while your expose() was still waiting on it.
osra: transport must be able to both emit and receive… You passed half a transport. Pair it with a custom { emit, receive }.
osra: cannot serialize a circular structure… Break the cycle, or send the shared part by reference with identity().
osra: stream exceeded its credit window A peer pushed more chunks than it was granted. Usually a hand-rolled implementation of the protocol.
osra: a chunk failed to deserialize on this platform The receiving platform dropped a moved chunk, so the stream errors instead of silently missing data.
osra: Blob is only supported on structured-clone transports… Send an ArrayBuffer or Uint8Array instead, see supported types.