Osra comes with two small helpers that change how a value crosses a connection:
identity(value) keeps the value’s reference stable, so the other side sees one object for it no matter how many times you send it.
transfer(value) moves the value instead of copying it, which is a lot cheaper for large buffers.
Both of them give you back the exact value you passed in, with the same type, so you can drop them into an existing call without changing any signature.
Both are also no-ops on values they don’t apply to, wrapping a string in either of them does nothing.
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,
constidentity: <T>(value:T) =>T
Mark a value so osra preserves its reference identity across the boundary. The peer's revived
value stands for this one, and handing it back - to you, or onward to a further context and back
again - resolves to this very reference. The mark sticks to the value, so only the side that
owns it has to opt in. Idempotent, and primitives pass through unchanged.
identity } from'osra'
const
constplain: {
foo:string;
}
plain= {
foo: string
foo: 'bar' }
const
constshared: {
foo:string;
}
shared= {
foo: string
foo: 'bar' }
const
constpayload: {
plain1: {
foo:string;
};
plain2: {
foo:string;
};
ref1: {
foo:string;
};
ref2: {
foo:string;
};
}
payload= {
plain1: {
foo: string;
}
plain1:
constplain: {
foo:string;
}
plain,
plain2: {
foo: string;
}
plain2:
constplain: {
foo:string;
}
plain,
ref1: {
foo: string;
}
ref1:
identity<{
foo:string;
}>(value: {
foo:string;
}): {
foo:string;
}
Mark a value so osra preserves its reference identity across the boundary. The peer's revived
value stands for this one, and handing it back - to you, or onward to a further context and back
again - resolves to this very reference. The mark sticks to the value, so only the side that
owns it has to opt in. Idempotent, and primitives pass through unchanged.
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 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= {
plain1: {
foo:string;
};
plain2: {
foo:string;
};
ref1: {
foo:string;
};
ref2: {
foo:string;
};
}
Payload>({}, {
transport: Transport
transport:
constworker:Worker
worker })
constplain1: {
foo:string;
}
plain1===
constplain2: {
foo:string;
}
plain2// false, the same object in two places arrives as two copies
constref1: {
foo:string;
}
ref1===
constref2: {
foo:string;
}
ref2// true, one object, and marking it once was enough
One thing to note is that ref2 was sent without any wrapper.
The mark lives on the value, not on that one send, so every later send of it resolves to the same object on the other side.
Since the mark travels with the value, the peer doesn’t have to do anything to hand it back.
Whatever you gave out, you get back as your actual original object:
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,
constidentity: <T>(value:T) =>T
Mark a value so osra preserves its reference identity across the boundary. The peer's revived
value stands for this one, and handing it back - to you, or onward to a further context and back
again - resolves to this very reference. The mark sticks to the value, so only the side that
owns it has to opt in. Idempotent, and primitives pass through unchanged.
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({
getSettings: () => {
theme: string;
}
getSettings: () =>
identity<{
theme:string;
}>(value: {
theme:string;
}): {
theme:string;
}
Mark a value so osra preserves its reference identity across the boundary. The peer's revived
value stands for this one, and handing it back - to you, or onward to a further context and back
again - resolves to this very reference. The mark sticks to the value, so only the side that
owns it has to opt in. Idempotent, and primitives pass through unchanged.
identity(
constsettings: {
theme:string;
}
settings),
saveSettings: (saved:typeof settings) =>void
saveSettings: (
saved: {
theme: string;
}
saved:typeof
constsettings: {
theme:string;
}
settings) => {
saved: {
theme: string;
}
saved===
constsettings: {
theme:string;
}
settings// true, the peer just sent back what it was given
}
}, {
transport: Transport &typeof globalThis
transport:
moduleglobalThis
globalThis })
This is what makes remote callbacks removable: removeEventListener needs the exact function that was registered, and osra’s own EventTarget proxy uses identity() for exactly that.
Keep in mind that what travels is the reference, not the contents.
The peer’s copy is still its own object, so a change made on one side is not synced to the other.
An identity keeps working however far the value travels.
A context that received one can pass it on to the next, and whatever comes back resolves at each hop to exactly the value that hop handed out, all the way to the 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({
getSession: () =>Promise<{
user:string;
}>
getSession: async () =>
identity<{
user:string;
}>(value: {
user:string;
}): {
user:string;
}
Mark a value so osra preserves its reference identity across the boundary. The peer's revived
value stands for this one, and handing it back - to you, or onward to a further context and back
again - resolves to this very reference. The mark sticks to the value, so only the side that
owns it has to opt in. Idempotent, and primitives pass through unchanged.
identity(
constsession: {
user:string;
}
session),
close: (returned:typeof session) =>Promise<void>
close: async (
returned: {
user: string;
}
returned:typeof
constsession: {
user:string;
}
session) => {
returned: {
user: string;
}
returned===
constsession: {
user:string;
}
session// true, however many contexts it went through
}
}, {
transport: Transport & Worker
transport:
constworker:Worker
worker })
worker.ts
// the middle context forwards both ways and marks nothing itself
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 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<
typeMiddle= {
getSession: () =>Promise<{
user:string;
}>;
close: (session: {
user:string;
}) =>Promise<void>;
}
Middle>({}, {
transport: Transport
transport:
moduleglobalThis
globalThis })
const
constsession: {
user:string;
}
session=await
constmiddle: {
getSession: () =>Promise<{
user:string;
}>;
close: (session: {
user:string;
}) =>Promise<void>;
}
middle.
getSession: () =>Promise<{
user:string;
}>
getSession()
await
constmiddle: {
getSession: () =>Promise<{
user:string;
}>;
close: (session: {
user:string;
}) =>Promise<void>;
}
middle.
close: (session: {
user:string;
}) =>Promise<void>
close(
constsession: {
user:string;
}
session)
Sending it costs the full payload the first time a given peer sees it, and only a small reference on every send after that.
One thing to note is that the value comes home the way it went out.
Each context resolves identities per peer, so if the same value reaches a context through two different routes, that context ends up with two different references, each tied to whoever sent it.
Each side only holds on to the peer’s identities for as long as the original value is alive.
When your value gets garbage collected, osra tells the peer to drop its copy, and a peer that had passed it further along tells its own peer in turn, so a chain unwinds from the origin outward.
A few small things worth knowing:
Primitives pass through identity() untouched, there is no reference to keep.
Marking the same value twice does nothing extra.
Unique symbols (Symbol()) ride this machinery automatically, which is why they keep their identity across a connection without you wrapping anything, see supported types.
Osra copies transferable values by default, just like postMessage() does when you don’t give it a transfer list.
Wrapping a value in transfer() moves it instead, which is the difference between duplicating 16 MB of pixels and handing over a pointer:
Opt into transfer (move) semantics for a transferable value. Idempotent;
non-transferable inputs pass through unchanged. Silently degrades to a
copy when the platform/transport can't transfer the given type. Lies at
the type level - runtime value is a TransferWrapper typed as T.
transfer(
constpixels:ArrayBuffer
pixels))
constpixels:ArrayBuffer
pixels.
ArrayBuffer.byteLength: number
Read-only. The length of the ArrayBuffer (in bytes).
byteLength// 0, it now belongs to the peer
Transfer semantics are the platform’s: the moment the value ships, it is detached on your side, and reading it afterwards is an error.
There is only ever one owner.
Here is what wrapping each kind of value does on a structured transport:
Value
Default
Wrapped in transfer()
ArrayBuffer
copied
moved
Typed array spanning its whole buffer
copied
backing buffer moved
Typed array over part of its buffer
its window copied
its window sliced out and moved, buffer intact
DataView
copied
whole backing buffer moved
ImageBitmap, VideoFrame, AudioData
copied
moved
ReadableStream, WritableStream
proxied, chunk contents copied
proxied, chunk contents moved
Request, Response
body proxied, chunk contents copied
body proxied, chunk contents moved
MessagePort, TransformStream, OffscreenCanvas
moved anyway
moved anyway
SharedArrayBuffer
shared
shared, see below
Anything else passes through transfer() unchanged, so wrapping a plain object is harmless, and wrapping the same value twice does nothing extra.
Keep in mind that a custom transport only moves values when its emit forwards the transferables list.
Otherwise everything falls back to a copy.
A typed array that spans its whole buffer moves that buffer, detaching it on your side.
A view over part of a buffer only ever ships the bytes it can see, so that window is sliced out first and the slice is what moves, leaving your buffer intact:
transfer(newUint8Array(buffer)) // moves, buffer is detached
transfer(newUint8Array(buffer, 8, 4)) // ships those 4 bytes, buffer is fine
DataView is the exception: wrapping one always moves its entire backing buffer, even when the view only covers part of it.
The view arrives with its window intact over the moved buffer, and the whole buffer is detached on your side.
A ReadableStream or a WritableStream is always proxied chunk by chunk, the stream itself never moves.
Wrapping one in transfer() keeps that proxying exactly as it is, and moves every transferable found inside each chunk instead of copying it.
The same goes for the body of a wrapped Request or Response.
The details, including per-chunk wrapping and the detach caveats, are in revivables.
Some host objects cannot be copied by structured clone at all, so on a structured transport they move whether you wrapped them or not: MessagePort, TransformStream, OffscreenCanvas, MediaStreamTrack, MediaSourceHandle, MIDIAccess, RTCDataChannel, WebTransportSendStream, WebTransportReceiveStream.
This means that sending one of these detaches it on your side, every time.
SharedArrayBuffer is the opposite case: it is neither copied nor moved, both contexts simply look at the same memory.
A JSON transport cannot move anything, there is no memory to hand over in a text protocol.
On those, transfer() quietly degrades to a copy: same code, no error.
Most of the values in the table above are not available on JSON transports at all, see the supported types table. MessagePort still works there because osra proxies it instead of moving it.