Osra’s type system does two jobs, and two types carry almost all of it. Remote<T> describes what a value looks like from the other side of a connection, so what you get back from expose() matches what actually arrives. Capable is the set of every type osra can send over the transport you passed, and anything outside of it is rejected at compile time.
expose() applies both of them for you, so most of the time you never write either one yourself.
When you write expose<Payload>(), the value you get back is typed Remote<Payload> rather than Payload itself.
The mapping is recursive, and it mostly does one thing: it makes every function asynchronous, because calling one now crosses the wire.
You have
The peer sees
(...args: P) => R
(...args: P) => Promise<Remote<Awaited<R>>>
Promise<U>
Promise<Remote<U>>
Map<K, V>, Set<V>, ReadableStream<C>
The same container, with Remote applied to what it holds
Date, Error, RegExp, ArrayBuffer and its views, Blob, File, FileList, WritableStream, MessagePort, AbortSignal, Request, Response, Headers
The EventTarget interface is implemented by objects that can receive events and may have listeners for them. In other words, any target of events implements the three methods associated with this interface.
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.
Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.
@returns ― Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.
get('click') // (() => Promise<void>) | undefined
constremote: {
add: (a:number, b:number) =>Promise<number>;
handlers:Map<string, () =>Promise<void>>;
events:EventTarget;
}
remote.
events: EventTarget
events// EventTarget, addEventListener and removeEventListener only
The rows are tried in the order of the table, which matters in two places.
A ReadableStream keeps being a ReadableStream even though the platform makes it async iterable, and a MessagePort or AbortSignal keeps its exact type even though both extend EventTarget, because osra revives those two faithfully.
If you try to send a generic function, its type parameters are lost, because a conditional type cannot carry them across the mapping.
This means that <T>(x: T) => T collapses to (x: unknown) => Promise<unknown> on the other side.
The function itself still works at runtime, only its typing is flattened.
Note: Remote<unknown> is just unknown.
So when you call expose() without a type argument, the peer’s value comes back as unknown and you have to narrow it yourself before calling anything on it.
TResult is Remote<Peer> by default.
If you pass the connection option, it becomes whatever that function returns instead, and the function receives a Connected:
Which fields of Context are populated depends on the transport, see connections.
One thing to note is that naming Peer and passing connection at the same time does not work.
TypeScript has no partial type argument inference, so writing expose<Api>() resets every later type parameter to its default and the inferred result type is lost.
If you need both, annotate the function’s parameter as Connected<Remote<Api>> instead:
Expose a value to whoever connects, and get back what they exposed.
Wrap value in context to build it once per connection, which is what lets one server answer
each realm differently (scoped resolvers per app) instead of sharing one object across all of them.
A bare function stays a plain exposed endpoint, so the wrapper is what disambiguates the two.
The result is both awaitable and async-iterable: awaiting gives the first peer, iterating gives
every peer as it connects. Both hand back the same shape.
constremote=awaitexpose(resolvers, { transport }) // the first peer's value
forawait (constremoteofexpose(resolvers, { transport })) { } // every peer's value
connection decides what that shape is. Omit it and it is the peer's value, which is what expose
has always resolved to. Return whatever a connection should mean instead:
if (!allowed(peer.context.origin)) peer.context.abort?.()
}
A peer's identity is whatever the transport can observe merged over whatever the caller declared
in context. Only a window message carries a browser-set origin and source; a MessagePort message
carries neither, so a port-based server declares what it learned when it received the port.
Observed fields win over declared ones, so a declaration can never spoof a real origin.
expose({}, {
transport: Transport & 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.
Capable is built in layers, which you will run into around the API reference:
Type
What it covers
Jsonable
Strings, numbers, booleans, null, and plain objects and arrays of the same.
Structurable
Jsonable plus what structured clone handles on its own: Date, RegExp, Blob, File, FileList, ArrayBuffer and its views, ImageBitmap, ImageData, Map, Set, bigint, undefined.
StructurableTransferable
Structurable plus the platform’s transferable types.
Capable
That base, plus every type the revivable modules contribute, nested in containers of any depth.
expose() checks the value you pass against Capable.
If you try to expose something your transport cannot carry, it fails at the call site, instead of quietly becoming {} at runtime:
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({
ok: () =>Promise<number>
ok: async () =>1,
cache: WeakMap<WeakKey, any>
cache: new
var WeakMap:WeakMapConstructor
new <WeakKey, any>(iterable?:Iterable<readonly [WeakKey, any]> |null|undefined) =>WeakMap<WeakKey, any> (+1 overload)
WeakMap() }, {
transport: Transport & Worker
transport:
constworker:Worker
worker })
Error ts(2345)Argument of type { ok: () => Promise<number>; cache: WeakMap<WeakKey, any>; } is not assignable to parameter of type {
readonly ok: () => Promise<number>;
readonly cache: WeakMap<WeakKey, any>;
} & {
[ErrorMessage]: "Value type must resolve to a Capable";
[BadValue]: WeakMap<...>;
[Path]: "cache";
[ParentObject]: { ...; };
}.Type { ok: () => Promise<number>; cache: WeakMap<WeakKey, any>; } is missing the following properties from type {
[ErrorMessage]: "Value type must resolve to a Capable";
[BadValue]: WeakMap<WeakKey, any>;
[Path]: "cache";
[ParentObject]: { ...; };
}: [ErrorMessage], [BadValue], [Path], [ParentObject]
The immediate container holding the bad value, or the whole value when the root itself failed.
So reading the error is mostly reading Path: it points at the exact field to fix, even when the WeakMap sits three objects deep.
One gap to be aware of is that inside a value typed as a plain array, T[], the traversal cannot descend, so BadValue becomes the array itself and Path stops at it.
Tuples are walked element by element and report the exact index.
Since expose() infers its value with a const type parameter, inline array literals arrive as tuples and get the precise report, so the coarse one only shows up for values typed as plain arrays elsewhere.
Capable resolves against the transport expose() inferred, so the same value can be legal on one channel and rejected on another.
On a JSON transport, the base of the union narrows down to Jsonable, and the modules that only work with structured clone (Blob, File, and the clonable and transferable host objects) stop contributing their types.
This means that a value JSON would silently mangle fails at compile time instead:
Expose a value to whoever connects, and get back what they exposed.
Wrap value in context to build it once per connection, which is what lets one server answer
each realm differently (scoped resolvers per app) instead of sharing one object across all of them.
A bare function stays a plain exposed endpoint, so the wrapper is what disambiguates the two.
The result is both awaitable and async-iterable: awaiting gives the first peer, iterating gives
every peer as it connects. Both hand back the same shape.
constremote=awaitexpose(resolvers, { transport }) // the first peer's value
forawait (constremoteofexpose(resolvers, { transport })) { } // every peer's value
connection decides what that shape is. Omit it and it is the peer's value, which is what expose
has always resolved to. Return whatever a connection should mean instead:
if (!allowed(peer.context.origin)) peer.context.abort?.()
}
A peer's identity is whatever the transport can observe merged over whatever the caller declared
in context. Only a window message carries a browser-set origin and source; a MessagePort message
carries neither, so a port-based server declares what it learned when it received the port.
Observed fields win over declared ones, so a declaration can never spoof a real origin.
expose({
foo: File
foo: new
var File:new (fileBits:BlobPart[], fileName:string, options?:FilePropertyBag) =>File
The File interface provides information about files and allows JavaScript in a web page to access their content.
var WebSocket:new (url:string|URL, protocols?:string|string[]) =>WebSocket
The WebSocket object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection.
Error ts(2345)Argument of type { foo: File; } is not assignable to parameter of type { readonly foo: File; } & {
[ErrorMessage]: "Value type is only supported on structured-clone transports, not on JSON transports";
[BadValue]: File;
[Path]: "foo";
[ParentObject]: { ...; };
}.Type { foo: File; } is missing the following properties from type {
[ErrorMessage]: "Value type is only supported on structured-clone transports, not on JSON transports";
[BadValue]: File;
[Path]: "foo";
[ParentObject]: { ...; };
}: [ErrorMessage], [BadValue], [Path], [ParentObject]
The same code with a Worker transport compiles.
When a value fails only because of the transport, meaning it would be fine on a structured one, the ErrorMessage says so: Value type is only supported on structured-clone transports, not on JSON transports, instead of the general Value type must resolve to a Capable.
Types with a dedicated module that supports both modes, like Date, Map, Set, bigint, ArrayBuffer, functions and streams, stay legal on JSON. See supported types for the full table.
Registering a custom revivable widens Capable.
On a side that passes no type argument, expose() infers the module list from the revivableModules option and your type is accepted right away.
On a side that names the peer’s type, you have to pass the module list’s type as the second type argument too:
This is the partial inference rule from above: naming PeerApi alone resets the module list parameter to its default, so pass both type arguments or neither.
Without it your modules still run at runtime, but Capable falls back to the defaults and rejects your type where you wrote it.
Osra does not pin a TypeScript version.
The library itself is type checked with TypeScript 7, and every example in these docs is checked with TypeScript 5.9, so those are the versions we exercise.
Compile with strict on, it is the only configuration we test.
The shipped declarations are verified as an npm consumer sees them, with skipLibCheck off and a lib as low as es2022 plus dom, so you don’t need skipLibCheck or an esnext lib to use them.