A transport is a channel, and a connection is what osra establishes over it.
The two are not one-to-one: several independent connections can share a single channel, and a single expose() can end up connected to several peers at once.
This page covers how the handshake behaves when a channel is shared, and the options that decide who your side talks to.
If two unrelated parts of your app want to share the same worker, give each pair of expose() calls its own key.
Inbound messages are filtered by key before anything else happens, so traffic on one key is completely invisible to the other:
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(
conststorage: {
get: (key:string) =>string;
}
storage, {
transport: Transport & Worker
transport:
constworker:Worker
worker,
key?: string |undefined
key: 'storage' })
Both sides need to use the same key.
If you leave it out, you get the default key '__OSRA_DEFAULT_KEY__', which is fine as long as the channel only carries one connection.
One thing to remember is that key is a label, not a credential: any peer on the channel that uses the same key is a valid peer.
When expose() starts, it announces itself on the channel and keeps doing so, backing off from 50ms up to once a second, until a peer answers.
Every instance listening on the same key answers the announces it sees, so when more than one peer is present, each answer becomes its own connection: every peer receives your exposed value and can call into it.
If you wrapped your value in context(), the factory runs once per connection, so each peer can get its own value, see connections.
Awaiting your expose() resolves with the first peer’s value and stays resolved on it.
Iterating gives you every peer instead, each 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<
typePeerApi= {
ping: () =>string;
}
PeerApi>({}, {
transport: Transport
transport:
constworker:Worker
worker })) {
constpeer: {
ping: () =>Promise<string>;
}
peer.
ping: () =>Promise<string>
ping()
}
One thing to note is that your side stops announcing after its first connection, but that does not close the door.
A peer that shows up later announces itself, your side answers, and it connects all the same.
To learn who each peer is, or to drop one, use the connection option.
It receives { value, context }, where value is that peer’s exposed value and context is what the transport observed about the peer, plus an abort() that drops that one peer:
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.
The ports read-only property of the MessageEvent interface is an array of MessagePort objects containing all MessagePort objects sent with the message, in order.
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(
constapi: {
add: (a:number, b:number) =>number;
}
api, {
transport: Transport & MessagePort
transport:
constport:MessagePort
port })
}
})
Every page gets its own port, its own connection and its own expose().
The same pattern applies to runtime.onConnect in a web extension.
It is worth knowing which of the two shapes you want.
A port observes nothing about its peer, so a per-port expose() learns who it’s talking to from the scope that created the port, while one expose() with several peers on a window transport observes each peer’s origin directly.
name labels your side, and rides along on every message you send. remoteName says which label you accept: any message carrying a different name is dropped before it reaches your connection.
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<
typeWorkerApi= {}
WorkerApi>(
constapi: {}
api, {
transport: Transport
transport:
constworker:Worker
worker,
name?: string |undefined
name: 'page',
remoteName?: string |undefined
remoteName: 'worker' })
This is useful when several peers share a key and you only want a specific one, and a name is generally clearer in logs than a random uuid.
Note: remoteName matches against the peer’s name, so a peer that did not set one is dropped too.
Every expose() gets a random uuid at startup and announces itself with it.
That uuid is how osra tells peers apart: everything after the announce is addressed, each message names the uuid it is for, and every instance drops messages that are not addressed to it.
It is also how osra ignores its own messages when a channel echoes them back.
This addressing is what makes broadcast-style channels work.
A WebSocket relay that forwards every message to every client still behaves like a set of private pairwise connections.
Keep in mind that on such a relay every peer reports the same origin, the origin of the socket’s URL, so telling relay peers apart is a job for key, name, or something in your own payload.
You can pin the uuids.
Set uuid to a fixed value and preset the peer’s as remoteUuid, and the handshake is skipped: your value goes out immediately, addressed to that uuid, exactly once.
// side A
expose(a, { transport, uuid: A, remoteUuid: B })
// side B
expose(b, { transport, uuid: B, remoteUuid: A })
Do this on both sides or not at all.
A side that preset remoteUuid never announces on its own, though it still answers announces from others, so a half-pinned pair falls back to the normal handshake anyway and you gain nothing from the pin.
Also keep in mind that pinning gives up the retry loop that makes the normal handshake tolerant of a slow start.
The init is sent once and never re-sent, so the peer has to be listening already: a peer that starts late misses the only init that will ever be sent and waits forever.
Unless you have a reason, let osra announce.
Four options shape who your side talks to, and they compose:
Option
Scope
key
Which logical channel you are on.
origin
Which origin may send and receive, on window transports. Applied in both directions.
remoteName
Which peer label you accept.
remoteUuid
Which instance your pinned handshake is addressed to. Not a filter: peers that announce still connect.
origin is the one that matters across documents, since it is enforced by the browser rather than by osra, so set it whenever the two sides live on different origins.
It is applied in both directions, as the targetOrigin of everything you send and as a filter on everything you receive.
The one exception is the unsolicited announce, which has to go out with '*': until a cross-origin iframe commits its document, its window still holds the initial about:blank page, and a strict targetOrigin fails the browser’s delivery check.
That announce carries nothing but the key, the name and the uuid.
Your value only travels in the addressed messages that follow, which do respect origin. See transports for a worked iframe setup.
The rest are routing labels.
They keep independent connections from colliding, and they keep the wrong peer’s traffic out of your handlers, but they are plain values on the wire that any peer on the channel can set.
If a channel is reachable by code you do not control, scope it with origin, or do not put it on a shared channel at all.