Skip to content

Connections

Everything in osra happens over connections.
A connection is what a pair of expose() calls establish over a transport: each side sends its value across, each side gets the other’s back, and every call, stream and promise between them rides on that connection afterwards.

expose() gives you back its connections in two ways.
Awaiting the result gives you the first one, and iterating it gives you every one as it arrives:

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
})
for await (const
const remote: {
ping: () => Promise<string>;
}
remote
of
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
})) {
const remote: {
ping: () => Promise<string>;
}
remote
.
ping: () => Promise<string>
ping
()
}

By default, what you get is the peer’s exposed value, which is what expose() has always resolved to.
The connection option lets you change that.

A connection starts with a handshake, and the handshake needs traffic in both directions.
This means that the transport must be able to both emit and receive: if you pass half a transport, only the emit side of a custom pair for example, expose() rejects immediately with an error telling you so.

Every expose() call generates a random uuid for itself.
Until it has its first connection, it announces that uuid on the transport, retrying with a backoff that starts at 50ms and doubles up to once per second.
This is what lets you expose() toward an iframe that hasn’t finished loading, or a worker that starts late: whoever comes up first just keeps knocking until the other side answers.

A side that hears an announce answers with an announce of its own, addressed to that uuid.
Once the two sides know each other’s uuid, each one sends a single init message carrying its exposed value.
Your expose() promise resolves when the peer’s init arrives and its value has been revived, so what you get is ready to call.

If nobody ever answers, the promise stays pending.
Errors and lifecycle covers the cases where it rejects instead, and how to put a deadline on it.

One thing to note is that your own value is built and serialized as part of that init message.
This is why a context() factory runs before anything reaches the peer.

Osra never reconnects on its own.
Once your side has a connection it stops announcing, but it keeps listening, so a new peer that announces later on the same channel still connects to you.
And if a connection goes away, calling expose() on the same transport again starts a fresh handshake, a teardown does not poison the transport, see errors and lifecycle.
This matters mostly for WebExtension MV3 service workers, which the platform unloads on its own schedule, the transports page covers reconnecting there.

Keep in mind that a transport is a channel, not a connection.
Several independent expose() calls can share one channel under different keys, and a single expose() can hold several peers at once, see multiple peers.

The connection option receives { value, context } and returns whatever a connection should mean in your code:

const {
const value: unknown
value
,
const context: Context
context
} = await
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 ..., {
...;
}], Worker, {}, {
...;
}>(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 & Worker
transport
:
const worker: Worker
worker
,
connection?: ((connected: Connected<unknown>) => {
value: unknown;
context: Context;
}) | undefined
connection
: ({
value: unknown
value
,
context: Context
context
}) => ({
value: unknown
value
,
context: Context
context
})
})

Since it is a plain function, it can return anything you like, for example just the one field you care about:

for await (const
const origin: string | undefined
origin
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, {}, string | undefined>(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>) => string | undefined) | undefined
connection
: ({
context: Context
context
}) =>
context: Context
context
.
origin?: string | undefined
origin
})) {
const origin: string | undefined
origin
// string | undefined
}

It runs on your side, once per connection, after the handshake finishes.
It cannot change what you send, and nothing it returns ever crosses the wire.

The context only contains what the transport actually observed about the peer, plus an abort() for that one connection.
Each field is only there when the browser actually set it:

Transport Context
Window, iframe abort, origin, source
WebExtension abort, port, sender
WebSocket abort, origin
MessagePort, Worker, SharedWorker abort

One thing to remember is that a port or a worker observes nothing about its peer: their messages arrive with an empty origin and a null source, so neither field survives.
This is fine in practice, a server handing out ports created each port in response to something that did know who was asking, so the identity is already in scope where you call expose().

Also keep in mind that a WebSocket’s origin is the origin of the socket’s URL, not the peer’s.
Every peer on the same relay reports the same one, so identify relay peers with key, name, or something in your own payload instead.

Nothing in the context is ever sent anywhere, and nothing the peer sends can reach it.
It is built purely from what the browser told your side about the delivery.

Wrap your value in context() and it is built once per connection, from that connection’s context, instead of being one object shared by everyone:

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, Contextual<...>, unknown>(value: Contextual<...>, 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
(
context<{
read: (path: string) => string;
}>(make: (ctx: Context) => {
read: (path: string) => string;
}): Contextual<{
read: (path: string) => string;
}>

Build the exposed value once per connection, from that connection's context, rather than sharing one value across every realm that connects. It runs BEFORE the value is boxed and sent, which is what lets one server answer each realm differently:

expose(context(({ origin }) => resolvers(idFor(origin))), { transport })

A wrapper rather than "pass a function", because osra exposes functions as endpoints, so a bare typeof value === 'function' cannot tell a per-peer factory from a plain function value.

What the read side needs is not declared here: connection: sees the same context and derives its own.

context
(({
origin: string | undefined
origin
}) => ({
read: (path: string) => string
read
:
const readFor: (origin: string | undefined) => (path: string) => string
readFor
(
origin: string | undefined
origin
) })), {
transport: Transport & Window
transport
:
const child: Window
child
})

The factory runs before your value is boxed and sent, which is what makes it useful: a page embedded by several iframes can answer each one differently, rather than exposing one object to all of them.

One thing to note is that this is a wrapper rather than “just pass a function”, because osra exposes functions as endpoints.
A bare function is a value you are exposing, not a factory:

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 ..., {
...;
}], Worker, (n: number) => Promise<...>, unknown>(value: (n: number) => Promise<...>, 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
(async (
n: number
n
: number) =>
n: number
n
* 2, {
transport: Transport & Worker
transport
:
const worker: Worker
worker
}) // an endpoint, called by the peer

The factory and connection receive the same context object, so anything your connection function needs, it can derive by itself.

Calling context.abort() closes that one connection and leaves every other peer alone.
unregisterSignal is still the way to tear down your whole side, see errors and lifecycle.

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
?.()
}

If you call it from inside a context() factory instead, the peer is refused before your value is ever sent, and the peer’s own expose() rejects:

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, Contextual<...>, unknown>(value: Contextual<...>, 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
(
context<{
read: (path: string) => string;
}>(make: (ctx: Context) => {
read: (path: string) => string;
}): Contextual<{
read: (path: string) => string;
}>

Build the exposed value once per connection, from that connection's context, rather than sharing one value across every realm that connects. It runs BEFORE the value is boxed and sent, which is what lets one server answer each realm differently:

expose(context(({ origin }) => resolvers(idFor(origin))), { transport })

A wrapper rather than "pass a function", because osra exposes functions as endpoints, so a bare typeof value === 'function' cannot tell a per-peer factory from a plain function value.

What the read side needs is not declared here: connection: sees the same context and derives its own.

context
(
ctx: Context
ctx
=> {
if (!
const allowed: (origin: string | undefined) => boolean
allowed
(
ctx: Context
ctx
.
origin?: string | undefined
origin
))
ctx: Context
ctx
.
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
?.()
return
const resolvers: {
read: (path: string) => string;
}
resolvers
}),
{
transport: Transport & Window
transport
:
const child: Window
child
}
)

Several for await loops over one expose() result each get every connection.
They are independent readers rather than a shared queue, so one loop cannot consume a peer another loop was waiting for.

Peers that connect before anything iterates are buffered, up to the 32 most recent, and that buffer is replayed to every loop that starts afterwards.
This also bounds what a side that only ever awaits holds on to: at most those 32, no matter how many peers show up.

One thing to note is that the replay only covers peers that arrived while nothing was iterating.
A loop that starts while another one is already running gets that buffer plus every peer from the moment it starts, not what the first loop already received.

With no connection option, expose<Api>() types the result as Remote<Api>.
With one, the result type is inferred from whatever your function returns.

One thing to remember is that those two cannot be combined, because TypeScript has no partial type argument inference: passing <Api> explicitly makes every later type parameter fall back to its default, so the inferred result type is lost.
If you want both, type the peer on the function’s parameter instead, using the Connected and Remote types osra exports:

const {
const value: {
ping: () => Promise<string>;
}
value
,
const context: Context
context
} = await
expose<{
ping: () => Promise<string>;
}, 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 ..., {
...;
}], Worker, {}, {
...;
}>(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 & Worker
transport
:
const worker: Worker
worker
,
connection?: ((connected: Connected<{
ping: () => Promise<string>;
}>) => {
value: {
ping: () => Promise<string>;
};
context: Context;
}) | undefined
connection
: ({
value: {
ping: () => Promise<string>;
}
value
,
context: Context
context
}:
type Connected<TValue> = {
value: TValue;
context: Context;
}

An established connection: the value that realm exposed, and what this side knows about the realm it came from. context is whatever the transport observed, plus an abort that drops this one peer. Anything derived from it is the caller's to compute, in the value factory or in connection:, rather than something to declare up front.

Connected
<
type Remote<T> = T extends (...args: infer P) => infer R ? (...args: P) => Promise<Remote<Awaited<R>>> : T extends Promise<infer U> ? Promise<Remote<U>> : T extends Map<infer K, infer V> ? Map<Remote<K>, Remote<V>> : T extends Set<infer V> ? Set<Remote<V>> : T extends ReadableStream<infer C> ? ReadableStream<Remote<C>> : T extends RegExp | MessagePort | ... 11 more ... | WritableStream<...> ? T : T extends EventTarget ? EventTarget : T extends AsyncIterable<...> ? AsyncIterableIterator<...> : T extends readonly unknown[] ? { [K in keyof T]: Remote<...>; } : T extends object ? { [K in keyof T]: Remote<...>; } : T

What a value looks like from the far side of the connection: functions become async (calls cross the wire), containers map recursively, everything else revives as itself.

Remote
<
type Api = {
ping: () => string;
}
Api
>>) => ({
value: {
ping: () => Promise<string>;
}
value
,
context: Context
context
})
})
await
const value: {
ping: () => Promise<string>;
}
value
.
ping: () => Promise<string>
ping
()