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:
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<
typeApi= {
ping: () =>string;
}
Api>({}, {
transport: Transport
transport:
constworker:Worker
worker })) {
constremote: {
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.
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 & Worker
transport:
constworker: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:
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.
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.
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(
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.
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 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(async (
n: number
n:number) =>
n: number
n*2, {
transport: Transport & Worker
transport:
constworker: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.
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?.()
}
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 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(
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 (!
constallowed: (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.
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:
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 & Worker
transport:
constworker:Worker
worker,
connection?: ((connected:Connected<{
ping: () =>Promise<string>;
}>) => {
value: {
ping: () =>Promise<string>;
};
context: Context;
}) |undefined
connection: ({
value: {
ping: () =>Promise<string>;
}
value,
context: Context
context }:
typeConnected<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.
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.