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
constpayload: {
parse: (input:string) => {
ok:boolean;
};
}
payload= {
parse: (input:string) => {
ok: boolean;
}
parse: (
input: string
input:string) => {
if (
input: string
input!=='valid') thrownew
var TypeError:TypeErrorConstructor
new (message?:string, options?:ErrorOptions) =>TypeError (+3 overloads)
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.
constremote=awaitexpose(resolvers, { transport }) // the first peer's value
forawait (constremoteofexpose(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:
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 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.
constremote=awaitexpose(resolvers, { transport }) // the first peer's value
forawait (constremoteofexpose(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:
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<
typePayload= {
parse: (input:string) => {
ok:boolean;
};
}
Payload>({}, {
transport: Transport
transport:
constworker:Worker
worker })
try {
await
constparse: (input:string) =>Promise<{
ok:boolean;
}>
parse('nope')
} catch (
var error:unknown
error) {
if (
var error:unknown
errorinstanceof
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
constcontroller: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.
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.
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.
constremote=awaitexpose(resolvers, { transport }) // the first peer's value
forawait (constremoteofexpose(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:
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<
typeApi= {
ping: () =>string;
}
Api>({}, {
transport: Transport
transport:
constworker:Worker
worker,
unregisterSignal?: AbortSignal |undefined
unregisterSignal:
constcontroller: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.
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.
constremote=awaitexpose(resolvers, { transport }) // the first peer's value
forawait (constremoteofexpose(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:
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<
typeApi= {
slowCall: () =>Promise<string>;
}
Api>({}, {
transport: Transport
transport:
constworker:Worker
worker,
unregisterSignal?: AbortSignal |undefined
unregisterSignal:
constcontroller: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.
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.
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:
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.
constremote=awaitexpose(resolvers, { transport }) // the first peer's value
forawait (constremoteofexpose(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:
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:
constchild: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 (!
constallowed: (origin:string|undefined) =>boolean
allowed(
constpeer: {
value:unknown;
context:Context;
}
peer.
context: Context
context.
origin?: string |undefined
origin))
constpeer: {
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.