Skip to content

osra

Strictly typed, ergonomic and lightweight (13 kB gzipped) RPC library in TypeScript.

Send complex types and call functions across contexts with inferred typing and pluggable transports.

Osra makes your multi-context code look like normal code.
Zero boilerplate, and the best error messages you’ve ever seen.

Each side calls expose() once with the value it wants to share, and gets the other side’s value back, ready to use.
Functions stay callable, generators stream, errors and abort signals cross, and what you get back is typed from what the peer exposed.

It runs over workers, shared and service workers, windows and iframes, MessagePort, WebSockets, web extensions and Node.js worker threads, or anything you can wrap in an { emit, receive } pair.

worker.ts
import {
const expose: <T = unknown, const TModules extends readonly RevivableModule[] = 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 ..., {
...;
}], const TTransport extends Transport = Transport, const TValue = Capable<...>, TResult = Remote<...>>(value: TValue extends Contextual<infer U> ? Contextual<CapableCheck<...>> : CapableCheck<...>, options: Omit<StartConnectionsOptions<TModules>, "connection"> & {
transport: TTransport;
connection?: (connected: Connected<Remote<T>>) => TResult;
}) => Exposed<TResult>

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
} from 'osra'
const
const payload: {
hash: Uint8Array<ArrayBuffer>;
add: (a: number, b: number) => number;
makeCounter: () => () => number;
streamData: () => AsyncGenerator<number, void, unknown>;
}
payload
= {
hash: Uint8Array<ArrayBuffer>
hash
:
var crypto: Crypto
crypto
.
Crypto.getRandomValues<Uint8Array<ArrayBuffer>>(array: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer>

The Crypto.getRandomValues() method lets you get cryptographically strong random values. The array given as the parameter is filled with random numbers (random in its cryptographic meaning).

MDN Reference

getRandomValues
(new
var Uint8Array: Uint8ArrayConstructor
new (length: number) => Uint8Array<ArrayBuffer> (+6 overloads)
Uint8Array
(10)),
add: (a: number, b: number) => number
add
: (
a: number
a
: number,
b: number
b
: number) =>
a: number
a
+
b: number
b
,
makeCounter: () => () => number
makeCounter
: () => {
let
let count: number
count
= 0
return () => ++
let count: number
count
},
streamData: () => AsyncGenerator<number, void, unknown>
streamData
: async function* () { yield* [0, 1, 2] }
}
export type
type Payload = {
hash: Uint8Array<ArrayBuffer>;
add: (a: number, b: number) => number;
makeCounter: () => () => number;
streamData: () => AsyncGenerator<number, void, unknown>;
}
Payload
= typeof
const payload: {
hash: Uint8Array<ArrayBuffer>;
add: (a: number, b: number) => number;
makeCounter: () => () => number;
streamData: () => AsyncGenerator<number, void, unknown>;
}
payload
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 ..., {
...;
}], typeof globalThis, {
...;
}, unknown>(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
(
const payload: {
hash: Uint8Array<ArrayBuffer>;
add: (a: number, b: number) => number;
makeCounter: () => () => number;
streamData: () => AsyncGenerator<number, void, unknown>;
}
payload
, {
transport: Transport & typeof globalThis
transport
:
module globalThis
globalThis
})
main.ts
import type {
type Payload = {
hash: Uint8Array<ArrayBuffer>;
add: (a: number, b: number) => number;
makeCounter: () => () => number;
streamData: () => AsyncGenerator<number, void, unknown>;
}
Payload
} from './worker'
import {
const expose: <T = unknown, const TModules extends readonly RevivableModule[] = 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 ..., {
...;
}], const TTransport extends Transport = Transport, const TValue = Capable<...>, TResult = Remote<...>>(value: TValue extends Contextual<infer U> ? Contextual<CapableCheck<...>> : CapableCheck<...>, options: Omit<StartConnectionsOptions<TModules>, "connection"> & {
transport: TTransport;
connection?: (connected: Connected<Remote<T>>) => TResult;
}) => Exposed<TResult>

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
} from 'osra'
const
const worker: Worker
worker
= new
var Worker: new (scriptURL: string | URL, options?: WorkerOptions) => Worker

The Worker interface of the Web Workers API represents a background task that can be created via script, which can send messages back to its creator.

MDN Reference

Worker
(new
var URL: new (url: string | URL, base?: string | URL) => URL

The URL interface is used to parse, construct, normalize, and encode URLs. It works by providing properties which allow you to easily read and modify the components of a URL.

MDN Reference

URL
('./worker.ts', import.

The type of import.meta.

If you need to declare that a given property exists on import.meta, this type may be augmented via interface merging.

meta
.
ImportMeta.url: string
url
), {
WorkerOptions.type?: WorkerType | undefined
type
: 'module' })
export const {
const hash: Uint8Array<ArrayBuffer>
hash
, // Uint8Array
const add: (a: number, b: number) => Promise<number>
add
, // (a: number, b: number) => Promise<number>
const makeCounter: () => Promise<() => Promise<number>>
makeCounter
, // () => Promise<() => Promise<number>>
const streamData: () => Promise<AsyncIterableIterator<number>>
streamData
, // () => Promise<AsyncIterableIterator<number>>
} = await
expose<{
hash: Uint8Array<ArrayBuffer>;
add: (a: number, b: number) => number;
makeCounter: () => () => number;
streamData: () => AsyncGenerator<number, void, 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"), ... 19 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 Payload = {
hash: Uint8Array<ArrayBuffer>;
add: (a: number, b: number) => number;
makeCounter: () => () => number;
streamData: () => AsyncGenerator<number, void, unknown>;
}
Payload
>({}, {
transport: Transport
transport
:
const worker: Worker
worker
})
const hash: Uint8Array<ArrayBuffer>
hash
.
Uint8Array<ArrayBuffer>.byteLength: number

The length in bytes of the array.

byteLength
// 10
await
const add: (a: number, b: number) => Promise<number>
add
(40, 2) // 42
const
const counter: () => Promise<number>
counter
= await
const makeCounter: () => Promise<() => Promise<number>>
makeCounter
()
await
const counter: () => Promise<number>
counter
() // 1
await
const counter: () => Promise<number>
counter
() // 2
for await (const
const n: number
n
of await
const streamData: () => Promise<AsyncIterableIterator<number>>
streamData
()) {
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(
const n: number
n
) // 0, 1, 2
}

Those are the real types, compiled against the published package. Hover any symbol to see them.

Anything your transport cannot carry is rejected where you wrote it, with the path to the offending value.
A File can cross to a worker, but not over a WebSocket, so this fails at compile time instead of arriving mangled:

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 ..., {
...;
}], WebSocket, {
...;
}, unknown>(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
({
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.

MDN Reference

File
([], '') }, {
transport: Transport & WebSocket
transport
: new
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.

MDN Reference

WebSocket
('') })
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]
  • Efficient transport modes: structured clone by default (workers, windows, MessagePort), which can move values instead of copying them, and JSON where the channel carries text (WebSockets, web extensions). See transport modes.
  • Wide type support: functions, promises, async generators, ReadableStream, Response, Map, Uint8Array, AbortSignal, errors and many more.
  • Explicit TypeScript errors: the whole codebase is strictly typed, and anything your transport cannot carry fails at compile time, pointing at the exact field. See TypeScript.
  • Small and dependency free: 13 kB gzipped, zero runtime dependencies, tested on Chromium, Firefox and WebKit through Playwright, and on Node.js 22 and 24.

Head to getting started to see it running, or to the overview for a map of these docs.