osra 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,84 +1,99 @@
1
- # osra
1
+ <p align="center">
2
+ <h2 align="center">Osra</h2>
3
+ </p>
2
4
 
3
- [![npm version](https://img.shields.io/npm/v/osra.svg)](https://www.npmjs.com/package/osra)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [Documentation](https://osra.banou.dev)
5
6
 
6
- osra is a zero-runtime-dependency TypeScript RPC library that connects two JavaScript contexts over any message channel. Both sides call `expose(value, { transport })` and each receives the other's value with live semantics: functions become callable async proxies, async generators stream with `for await`, streams keep backpressure, errors keep their built-in subclasses, `AbortSignal`s propagate aborts. It works across Workers, SharedWorkers, windows/iframes, MessagePorts, WebSockets, web extensions, and anything else you can wrap in a custom `{ emit, receive }` pair, degrading gracefully to a JSON-only mode on text channels.
7
+ Strictly typed, ergonomic, and lightweight (13kb gzipped) RPC library in Typescript. Send complex types and call functions across contexts with inferred typing, pluggable transports.
7
8
 
8
- ## Features
9
-
10
- - **Zero runtime dependencies**: one ESM module; the single declared dependency (`@types/webextension-polyfill`) is types-only, supporting the published declarations
11
- - **Symmetric API**: both sides call `expose()`; either side can pass functions, both can call
12
- - **Deep type support**: functions, promises, async generators, `ReadableStream`/`WritableStream`, `MessagePort`, `AbortSignal`, `Error` subclasses, `File`/`FileList`, `Request`/`Response`, `Map`/`Set`, typed arrays, `BigInt`, `Symbol`, …
13
- - **JSON-mode degradation**: most value types work over text-only transports (WebSocket, extension messaging); `Date`, `Map`, typed arrays, even `NaN`/`±Infinity` survive
14
- - **`identity()`** for reference-preserving sends, **`transfer()`** for zero-copy moves
15
- - **Strict TypeScript**: `Remote<T>` maps your API type across the wire; a compile-time `Capable` check rejects non-serializable values with the offending path pinpointed
16
- - **Tested** on Chromium, Firefox, and WebKit via Playwright
17
-
18
- ## Install
19
-
20
- ```sh
21
- npm install osra
22
- ```
9
+ TL;DR: Osra makes your multi-context code looks like normal code. Zero boilerplate and gives you the best error messages you've ever seen.
23
10
 
24
- ## Quick Start
25
-
26
- ```ts
27
- // worker.ts
11
+ `worker.ts`
12
+ ```typescript
28
13
  import { expose } from 'osra'
29
14
 
30
- const api = {
31
- add: async (a: number, b: number) => a + b,
32
- makeCounter: async () => {
15
+ const payload = {
16
+ hash: crypto.getRandomValues(new Uint8Array(10)),
17
+ add: (a: number, b: number) => a + b,
18
+ makeCounter: () => {
33
19
  let count = 0
34
- return async () => ++count
35
- },
36
- streamData: async function* () {
37
- for (let i = 0; i < 3; i++) yield i
20
+ return () => ++count
38
21
  },
22
+ streamData: async function* () { yield* [0, 1, 2] }
39
23
  }
24
+ export type Payload = typeof payload
40
25
 
41
- export type Api = typeof api
42
-
43
- expose(api, { transport: globalThis })
26
+ expose(payload, { transport: globalThis })
44
27
  ```
45
28
 
46
- ```ts
47
- // main.ts
48
- import type { Api } from './worker'
49
-
29
+ `main.ts`
30
+ ```typescript
31
+ import type { Payload } from './worker'
50
32
  import { expose } from 'osra'
51
33
 
52
34
  const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
53
35
 
54
- const remote = await expose<Api>({}, { transport: worker })
36
+ export const {
37
+ hash, // Uint8Array
38
+ add, // (a: number, b: number) => Promise<number>
39
+ makeCounter, // () => Promise<() => Promise<number>>,
40
+ streamData, // () => Promise<AsyncIterableIterator<number>>
41
+ } = await expose<Payload>({}, { transport: worker })
42
+
43
+ hash.byteLength // 10
55
44
 
56
- await remote.add(40, 2) // 42
45
+ await add(40, 2) // 42
57
46
 
58
- const counter = await remote.makeCounter()
47
+ const counter = await makeCounter()
59
48
  await counter() // 1
60
49
  await counter() // 2
61
50
 
62
- for await (const n of await remote.streamData()) {
51
+ for await (const n of await streamData()) {
63
52
  console.log(n) // 0, 1, 2
64
53
  }
65
54
  ```
66
55
 
67
- Both sides call `expose()`; the returned promise resolves with the remote side's value once the handshake completes. A side that only serves (like the worker above) can ignore the returned promise.
68
-
69
- ### Options
70
-
71
- | Option | Default | Description |
72
- |---|---|---|
73
- | `transport` | required | The channel to communicate over (see [Transports](#transports)) |
74
- | `key` | `'__OSRA_DEFAULT_KEY__'` | Namespacing tag that lets multiple independent osra connections share one channel; it does not identify peers |
75
- | `origin` | `'*'` | On window transports: sets the outbound `postMessage` target origin **and** filters inbound messages by `event.origin`; the initial announce beacon alone goes out with `'*'` (see Window ↔ iframe) |
76
- | `name` / `remoteName` | - | Label your endpoint / only accept envelopes from a matching peer name |
77
- | `unregisterSignal` | - | `AbortSignal` that tears the connection down (see [Lifecycle](#error-handling--lifecycle)) |
78
- | `uuid` / `remoteUuid` | random / - | Pin instance uuids (`remoteUuid` is otherwise learned from the peer's announce); when both sides preset each other's `remoteUuid`, the announce handshake is skipped |
79
- | `revivableModules` | - | `defaults => modules` function to add, drop, reorder, or override revivable modules |
56
+ ## Features
80
57
 
81
- If multiple peers connect over the same transport, the returned promise resolves with the **first** peer's value; later peers still connect and can call your exposed value.
58
+ - **Efficient transport modes**:
59
+ - Structured-clone (default for `Window`, `Worker`, [etc...](#transport-modes)) is the fastest transport mode, being able to clone and transfer values to other contexts efficiently.
60
+ - JSON (default for `WebSocket`, WebExtensions, [etc...](#transport-modes)) is slower but supports more transport targets (e.g WebSocket, WebExtensions, etc...).
61
+
62
+ - **Wide type support**: Support all of the native platform types like `Function`, `Promise`, `ReadableStream`, `Response`, `Map`, `Uint8Array`, and [many more](#supported-types)...
63
+
64
+ - **Explicit typescript errors**: The codebase is entirely and extensively strictly typed. Anything that CAN cause issues at runtime will throw compile time errors.
65
+
66
+ As an example, trying to transfer a `File` value over a JSON transport, like so, will throw a compile time error:
67
+ ```typescript
68
+ ┌─────────────────────────────────────────────────────────────────────────────────────────────────────────┐
69
+ │ ... { │
70
+ │ [ErrorMessage]: "Value type is only supported on structured-clone transports, not on JSON transports";│
71
+ │ [BadValue]: File; │
72
+ │ [Path]: "foo"; │
73
+ │ [ParentObject]: { ...; }; │
74
+ │ }'. │
75
+ │ Type '{ foo: File; }' ... │
76
+ └─────────────────────────────────────────────────────────────────────────────────────────────────────────┘
77
+ ^^^^^^^^^^^^^^^^^^^^^^^^^
78
+ expose({ foo: new File([], '') }, { transport: new WebSocket('') })
79
+ ```
80
+
81
+ - **Extensive automated test suite** on Chromium, Firefox, and WebKit via Playwright
82
+
83
+ ## Transport modes
84
+
85
+ - **Structured-clone** (
86
+ [Window](https://developer.mozilla.org/en-US/docs/Web/API/Window),
87
+ [Worker](https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker),
88
+ [SharedWorker](https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker),
89
+ [ServiceWorker](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker),
90
+ [MessagePort](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort),
91
+ custom transports)
92
+ - **JSON** (
93
+ [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket),
94
+ [WebExtension runtime](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions) `connect()` and `onMessage`,
95
+ [WebExtension port](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/Port),
96
+ custom transports with `isJson: true`)
82
97
 
83
98
  ## Supported types
84
99
 
@@ -87,226 +102,131 @@ Transports are either **structured-clone** (Worker, Window, MessagePort, SharedW
87
102
  | Type | Clone | JSON | Notes |
88
103
  |---|---|---|---|
89
104
  | JSON primitives, plain objects, arrays | ✅ | ✅ | |
90
- | `undefined`, `NaN`, `±Infinity` | ✅ | ✅ | preserved even over JSON |
105
+ | `undefined`, `NaN`, `±Infinity` | ✅ | ✅ | |
91
106
  | `Date`, `BigInt`, `Map`, `Set` | ✅ | ✅ | |
92
- | Typed arrays, `ArrayBuffer` | | | subarray views round-trip their visible bytes; the revived view is full-length over a fresh buffer (`byteOffset` 0, length preserved) |
93
- | `Error` + subclasses | ✅ | ✅ | built-ins (`TypeError`, `RangeError`, `AggregateError` with nested errors, `DOMException`, …) revive as their own class; custom subclasses revive as `Error` with `name`, `message`, `stack`, `cause` preserved (`DOMException` drops `cause`) |
94
- | `Symbol` | ✅ | ✅ | `Symbol.for` registry symbols round-trip by key; others keep per-connection identity |
107
+ | `ArrayBuffer`, `Int8Array`, `Uint8Array`, `Uint8ClampedArray`, `Int16Array`, `Uint16Array`, `Int32Array`, `Uint32Array`, `Float16Array`, `Float32Array`, `Float64Array`, `BigInt64Array`, `BigUint64Array` | | | |
108
+ | `Error` + subclasses | ✅ | ✅ | built-ins errors properly preserve their subclass; custom error classes becomes generic `Error` |
109
+ | `Symbol` | ✅ | ✅ | `Symbol.for` properly preserves the Symbol's key; `Symbol()` is automatically wrapped with [`identity()`](#identity) |
95
110
  | `RegExp` | ✅ | ❌ | |
96
- | `SharedArrayBuffer` | ✅ | ❌ | shared memory across the contexts |
97
- | Function | ✅ | ✅ | becomes `(...args) => Promise<result>`; arguments and results recurse through the same boxing |
111
+ | `SharedArrayBuffer` | ✅ | ❌ | |
112
+ | Function | ✅ | ✅ | becomes `(...args) => Promise<result>`; arguments and results are properly handled too |
98
113
  | `Promise` | ✅ | ✅ | |
99
- | Async generators / async iterables | ✅ | ✅ | `next`/`return`/`throw` proxied; `for await` works; early `break` runs the source's `finally` |
100
- | `ReadableStream` | ✅ | ✅ | credit-window backpressure; cancel reason crosses |
101
- | `WritableStream` | ✅ | ✅ | write/close/abort with acks; sink errors reject the writer with an `Error` carrying the message string only |
102
- | `MessagePort` | ✅ | ✅ | revives as a real `MessagePort` on both transport kinds; on clone transports the sent port is moved (no longer usable on the sender), over JSON it is bridged by port-id messages |
103
- | `AbortSignal` | ✅ | ✅ | abort and reason propagate |
104
- | `File` / `FileList` | ✅ | ❌ | revive as themselves via structured clone (clone transports only); `Blob` is **not** supported |
105
- | `Request` / `Response` / `Headers` | ✅ | ✅ | streamed bodies; `Request.signal` propagates; `Response.url`/`redirected` restored; opaque status-0 revives as `Response.error()` |
106
- | `Event` / `CustomEvent` | ✅ | ✅ | subclass fields beyond `detail` are dropped |
114
+ | Async generators / async iterables | ✅ | ✅ | |
115
+ | `ReadableStream` | ✅ | ✅ | |
116
+ | `WritableStream` | ✅ | ✅ | |
117
+ | `MessagePort` | ✅ | ✅ | |
118
+ | `AbortSignal` | ✅ | ✅ | |
119
+ | `File` / `FileList` / `Blob` | ✅ | ❌ | |
120
+ | `Request` / `Response` / `Headers` | ✅ | ✅ | |
121
+ | `Event` / `CustomEvent` | ✅ | ✅ | Event subclass is not preserved |
107
122
  | `EventTarget` | ✅ | ✅ | revives as a listener-only façade: `add`/`removeEventListener` proxy to the source; you can't dispatch through it |
108
- | Other structured-clonables (`ImageData`, `DOMRect`, `CryptoKey`, …) | ✅ | ❌ | pass through structured clone untouched |
109
- | Transfer-only host objects (`OffscreenCanvas`, `MediaStreamTrack`, `RTCDataChannel`, …) | ✅ | ❌ | always moved to the peer |
110
- | `ImageBitmap`, `VideoFrame`, `AudioData` | ✅ | ❌ | copied by structured clone; wrap in `transfer()` to move |
111
- | `WeakMap` / `WeakSet`, other unclonables | ❌ | ❌ | coerce to `{}` at runtime, rejected at compile time |
112
-
113
- ## Transports
114
-
115
- ### Worker
123
+ | Structured-clonables (`ImageData`, `DOMRect`, `CryptoKey`, …) | ✅ | ❌ | |
124
+ | Transfer-only host objects (`OffscreenCanvas`, `MediaStreamTrack`, `RTCDataChannel`, …) | ✅ | ❌ | |
125
+ | `ImageBitmap`, `VideoFrame`, `AudioData` | ✅ | ❌ | |
126
+ | `WeakMap` / `WeakSet`, other unclonables | ❌ | ❌ | |
116
127
 
117
- Pass the `Worker` on the page side and `globalThis` (the `DedicatedWorkerGlobalScope`) inside the worker; see [Quick Start](#quick-start). Inside a worker, pass `globalThis` directly; the `Transport` union includes a structural `WorkerSelf` member, so it typechecks without a cast even in code compiled under `lib.dom`.
118
128
 
119
- ### Window ↔ iframe
129
+ ## Identity
120
130
 
121
- `message` events fire on the window that receives them, so each side pairs the *other* window for emit with its *own* window for receive. `origin` is applied in both directions, with one exception: the initial announce beacon (which carries only channel identifiers) is posted with target origin `'*'` so it can reach an iframe whose document has not committed yet; announce replies and all later traffic use the configured origin, and inbound filtering always applies.
131
+ `identity(value)` preserves reference equality across contexts, sending the same identity wrapped value twice results in the same object reference on the peer.
122
132
 
133
+ `worker.ts`
123
134
  ```ts
124
- // parent
125
- const iframe = document.querySelector('iframe')!
126
- const remote = await expose<IframeApi>(parentApi, {
127
- transport: { emit: iframe.contentWindow!, receive: window },
128
- origin: 'https://app.example.com',
129
- })
130
- ```
131
-
132
- ```ts
133
- // iframe
134
- const remote = await expose<ParentApi>(iframeApi, {
135
- transport: { emit: window.parent, receive: window },
136
- origin: 'https://host.example.com',
137
- })
138
- ```
139
-
140
- ### SharedWorker
135
+ import { expose, identity } from 'osra'
141
136
 
142
- Pass the `SharedWorker` instance directly on the page side; osra rides its `.port` internally. Inside the worker, expose per connected port:
137
+ const value = { foo: 'bar' }
138
+ const payload = { value, ref1: identity(value), ref2: identity(value) }
143
139
 
144
- ```ts
145
- // page
146
- const sharedWorker = new SharedWorker(new URL('./shared.ts', import.meta.url), { type: 'module' })
147
- const remote = await expose<Api>({}, { transport: sharedWorker })
140
+ expose(payload, { transport: globalThis })
141
+ export type Payload = typeof payload
148
142
  ```
149
143
 
144
+ `main.ts`
150
145
  ```ts
151
- // shared.ts
146
+ import type { Payload } from './worker'
152
147
  import { expose } from 'osra'
153
148
 
154
- const api = { add: async (a: number, b: number) => a + b }
155
-
156
- globalThis.addEventListener('connect', event => {
157
- for (const port of (event as MessageEvent).ports) expose(api, { transport: port })
158
- })
159
- ```
160
-
161
- ### WebSocket
162
-
163
- JSON mode. You can `expose()` while the socket is still `CONNECTING`; outbound envelopes queue until open. The other end is anything that relays frames to a peer also running osra:
149
+ const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
150
+ const { value, ref1, ref2 } = await expose<Payload>({}, { transport: worker })
164
151
 
165
- ```ts
166
- const socket = new WebSocket('wss://relay.example.com')
167
- const remote = await expose<PeerApi>(localApi, { transport: socket })
152
+ value === ref1 // false
153
+ ref1 === ref2 // true
168
154
  ```
169
155
 
170
- ### Service worker
171
-
172
- A `ServiceWorker` can only emit and a `ServiceWorkerContainer` can only receive, so combine them as a custom pair:
173
-
174
- ```ts
175
- const registration = await navigator.serviceWorker.ready
176
- const remote = await expose<SwApi>(pageApi, {
177
- transport: { emit: registration.active!, receive: navigator.serviceWorker },
178
- })
179
- ```
180
156
 
181
- ### Web extension
157
+ ## Transfer
182
158
 
183
- JSON mode. `runtime.Port` and the runtime itself (`sendMessage`/`onMessage`) work as standalone transports. `onConnect` and `onMessage` are receive-only: pass them as the `receive` half of a custom `{ emit, receive }` pair, or expose per connected port as below; `expose()` requires a transport that can both emit and receive.
159
+ By default, osra will always copy values, if the value you want to send is a [transferable](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects), wrapping it with `transfer(value)` will properly transfer it to the other context. [Transfer behavior](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#transfer) is preserved, which means the value can no longer be used in the sender context once it has been transferred.
184
160
 
185
161
  ```ts
186
- // content script
187
- const port = browser.runtime.connect()
188
- const background = await expose<BackgroundApi>(contentApi, { transport: port })
189
- ```
162
+ import { transfer } from 'osra'
190
163
 
191
- ```ts
192
- // background
193
- browser.runtime.onConnect.addListener(port => {
194
- expose(backgroundApi, { transport: port })
195
- })
164
+ const buffer = new ArrayBuffer(16_000_000)
165
+ await remote.transferBuffer(transfer(buffer)) // moved - buffer is detached locally
196
166
  ```
197
167
 
198
- If you accept `onConnectExternal`/`onMessageExternal`, filter senders yourself inside a custom `receive` wrapper before invoking osra's listener; `expose()` does not surface the per-message context. The `MessageContext` (with `sender`) reaches only direct users of `registerOsraMessageListener`.
168
+ ### Options
199
169
 
200
- ### Custom transports
170
+ | Option | Default | Description |
171
+ |---|---|---|
172
+ | `transport` | required | The channel to communicate over (see [Transport modes](#transport-modes)), should be equal to the place where `addEventListener('message')` and `postMessage()` calls target the remote context you want to communicate with |
173
+ | `key` | `'__OSRA_DEFAULT_KEY__'` | Namespacing tag that lets multiple independent osra connections share one channel |
174
+ | `origin` | `'*'` | Similar to [`postMessage`'s `origin`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#origin), It restricts the remote origin |
175
+ | `name` | - | Defines the name that will be used for the announcement |
176
+ | `remoteName` | - | Filters any incoming messages that are not equal to the `name` of the remote peer |
177
+ | `unregisterSignal` | - | `AbortSignal` that will tear down the connection when aborted |
178
+ | `uuid` / `remoteUuid` | random / - | Same as `name` and `remoteName`, but automatically generated at announce time |
179
+ | `revivableModules` | - | `defaults => modules` function to add, drop, reorder, or override revivable modules |
180
+ | `connection` | `({ value }) => value` | What one connection resolves to, for the await and for iteration alike (see [Connections](#connections)) |
201
181
 
202
- Any plain object with `emit` and `receive` works. Each may be a platform transport or a function; a function `receive` may return an unsubscribe callback. Set `isJson: true` when the channel can't carry transferables:
182
+ ## Connections
203
183
 
204
- ```ts
205
- const channel = new BroadcastChannel('app')
206
-
207
- const remote = await expose<PeerApi>(localApi, {
208
- transport: {
209
- isJson: true,
210
- emit: message => channel.postMessage(message),
211
- receive: listener => {
212
- const handler = (event: MessageEvent) => listener(event.data, {})
213
- channel.addEventListener('message', handler)
214
- return () => channel.removeEventListener('message', handler)
215
- },
216
- },
217
- })
218
- ```
184
+ `expose()` is awaitable and async-iterable. Awaiting gives the first peer, iterating gives every peer as it connects:
219
185
 
220
- Custom transports **must be plain objects**: prototype-based objects (e.g. Node `EventEmitter`s) with `emit` methods are deliberately not detected as custom transports.
186
+ ```typescript
187
+ import { expose } from 'osra'
221
188
 
222
- ## `identity()`
189
+ type PeerApi = { version: () => string }
223
190
 
224
- `identity(value)` preserves reference identity across the connection: sending the same wrapped value twice revives as the same object on the peer, and when the peer wraps the revived object in `identity()` and sends it back, you receive your original reference (`===`). Without it, every send produces an independent copy, including the return trip: a revived value passed back *bare* arrives as a fresh copy, so the returning side must re-wrap it.
191
+ const api = { log: (line: string) => console.log(line) }
225
192
 
226
- ```ts
227
- import { expose, identity } from 'osra'
193
+ // the first peer to connect
194
+ const remote = await expose<PeerApi>(api, { transport: window })
195
+ await remote.version()
228
196
 
229
- const settings = { theme: 'dark' }
230
- expose({
231
- getSettings: async () => identity(settings),
232
- saveSettings: async (saved: typeof settings) => {
233
- // when the remote sends back identity(saved): saved === settings
234
- },
235
- }, { transport: worker })
197
+ // every peer, as each one arrives
198
+ for await (const peer of expose<PeerApi>(api, { transport: window })) {
199
+ console.log('peer connected, running', await peer.version())
200
+ }
236
201
  ```
237
202
 
238
- ## `transfer()`
239
-
240
- `transfer(value)` opts a `Transferable` (`ArrayBuffer`, typed-array views, `ImageBitmap`, `VideoFrame`, `AudioData`, …) into move semantics: ownership transfers to the peer instead of copying. Detachment applies to full-window views only (`byteOffset` 0 spanning the whole buffer): `transfer()` on a subarray view ships a copy of just its window and leaves the sender's buffer intact. On JSON transports it silently degrades to a copy. `ReadableStream`/`WritableStream` are never moved: they are proxied chunk by chunk, so `transfer()` adds nothing for them.
203
+ Each loop is one peer, so a page embedding several iframes serves them all from one `expose()`. Several loops over the same `expose()` each see every peer, and peers that connect before anything iterates are buffered and replayed.
241
204
 
242
- ```ts
243
- import { transfer } from 'osra'
205
+ Pass `connection` to decide what a peer resolves to, which is also how you reach its origin and its per-peer `abort`:
244
206
 
245
- const pixels = new ArrayBuffer(16_000_000)
246
- await remote.render(transfer(pixels)) // moved - pixels is detached locally
207
+ ```typescript
208
+ for await (const peer of expose({}, {
209
+ transport: window,
210
+ connection: ({ value, context }) => ({ value, context })
211
+ })) {
212
+ if (!allowed(peer.context.origin)) peer.context.abort?.()
213
+ }
247
214
  ```
248
215
 
249
- ## Error handling & lifecycle
216
+ The context holds only what the transport observed, plus `abort`. A window or iframe gives `origin` and `source`; a WebExtension gives `port` and `sender`; a WebSocket gives the socket URL as `origin`; a `MessagePort` or `Worker` observes nothing at all.
250
217
 
251
- - Remote functions that throw reject the caller's promise with the revived error. Built-in classes (`TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`, `AggregateError`, `DOMException`) revive as instances of the same class; custom `Error` subclasses revive as plain `Error` with `name`, `message`, `stack`, and `cause` preserved.
252
- - `expose()` rejects when the transport can't both emit and receive (`{ emit }` or `{ receive }` alone is a configuration error), and when a peer sends a malformed `init` payload (the revive error surfaces instead of hanging).
253
- - Aborting `unregisterSignal`:
254
- - the pending `expose()` rejects with the abort reason,
255
- - a protocol `close` is sent to every connected peer and per-connection state is disposed,
256
- - pending RPC calls reject with `'osra: connection closed'` on **both** sides (the peer receiving `close` rejects its pending calls too),
257
- - proxied streams on wire-routed channels (JSON transports) are cancelled/aborted with the same error.
258
- - An already-aborted `unregisterSignal` short-circuits: nothing starts and `expose()` rejects immediately with the signal's abort reason.
259
- - Promises and streams riding real transferred `MessagePort`s on structured-clone transports live independently of the connection and survive its closure; wire-routed traffic does not.
260
- - After aborting, calling `expose()` again on the same transport performs a fresh handshake.
218
+ Wrap a value in `context()` to build it once per connection, so one server can answer each realm differently instead of sharing one object with all of them:
261
219
 
262
- ```ts
263
- const controller = new AbortController()
264
- const remote = await expose<Api>({}, { transport: worker, unregisterSignal: controller.signal })
220
+ ```typescript
221
+ import { expose, context } from 'osra'
265
222
 
266
- const pending = remote.slowCall()
267
- controller.abort(new Error('shutting down'))
268
- // pending rejects with 'osra: connection closed'
223
+ expose(context(({ origin }) => ({ read: readFor(origin) })), { transport: window })
269
224
  ```
270
225
 
271
- **Trust model**: `key` is a namespacing tag that lets independent connections share one channel; it does not identify peers. `origin` scopes window messaging to a named origin in both directions; set it whenever you talk across origins. Malformed payloads surface as errors rather than hangs, and per-connection port buffers are bounded (2048-message reorder buffer per port, 1024 pending ports, 128 remembered closed ports); flood-resistance hardening beyond these caps is incomplete.
226
+ It runs before your value is sent, so calling `ctx.abort()` inside it refuses that peer outright.
272
227
 
273
228
  ## Limitations
274
229
 
275
230
  - **Circular structures throw** a `TypeError` at send time; break the cycle or restructure.
276
- - **Shared references duplicate**: two fields pointing at the same object arrive as two copies unless wrapped with `identity()`.
277
- - **Classes/prototypes are not preserved**: class instances are not walked by the serializer; structured-clonable ones cross as prototype-less data, and an instance carrying function-valued own properties silently coerces to `{}` via the unclonable path (the compile-time `Capable` check flags it first). Expose plain objects and functions.
278
- - **Unclonable values** (`WeakMap`, `WeakSet`, exotic host objects) coerce to `{}` and fail the compile-time check.
279
- - **One-shot bodies**: sending the same `Request`/`Response`/`ReadableStream` twice fails; the body locks at first send.
280
- - **Generic functions collapse** in `Remote<T>`: mapped types can't preserve generic signatures.
281
- - **Multi-peer**: only the first peer's value is accessible through the returned promise.
282
- - **Everything is async**: sync return values still arrive as `Promise`s.
283
-
284
- ## TypeScript
285
-
286
- `Remote<T>` is what the other side sees: functions become `(...args) => Promise<Awaited<R>>`, containers map recursively, platform objects revive as themselves.
287
-
288
- `expose()` validates the value you pass at compile time against `Capable`, the union of everything serializable for the inferred transport (narrower on JSON transports). Failures pinpoint the offending path (for elements of non-tuple arrays the report stops at the array itself; only tuples are indexed element by element):
289
-
290
- ```ts
291
- expose({ ok: async () => 1, cache: new WeakMap() }, { transport: worker })
292
- // type error: Value type must resolve to a Capable, with `cache` identified as the bad field
293
- ```
294
-
295
- The published declarations require **TypeScript >= 5.9** with `strict` mode (validated on 5.9 by the documentation build, which type-checks every example against the published types, and on TypeScript 7 by `npm run check-consumer-types`).
296
-
297
- ## Documentation
298
-
299
- - [API reference](./docs/API.md)
300
- - [Advanced usage](./docs/ADVANCED.md)
301
-
302
- ## Development
303
-
304
- ```sh
305
- npm test # build lib + test bundle, run the Playwright matrix (chromium/firefox/webkit)
306
- npm run test-extension # web extension suite (needs a headed browser/display)
307
- npm run check-consumer-types # validate the published .d.ts as an npm consumer sees it
308
- ```
309
-
310
- ## License
311
-
312
- [MIT](./LICENSE)
231
+ - **Classes/prototypes are not preserved**: Classes and their instances are not preserved, please use plain objects and functions instead.
232
+ - **Synchronous functions become asynchronous**: `() => number` will become `() => Promise<number>`.
@@ -46,10 +46,11 @@ export declare const startBidirectionalConnection: <TModules extends readonly Re
46
46
  readonly box: (value: import("../revivables/fallbacks.js").Transferable, _context: import("../index.js").RevivableContext<any>) => import("../revivables/fallbacks.js").Transferable;
47
47
  readonly revive: (value: import("../revivables/fallbacks.js").BoxedTransferable, _context: import("../index.js").RevivableContext<any>) => import("../revivables/fallbacks.js").Transferable;
48
48
  }, {
49
- readonly type: 'blobGuard';
50
- readonly isType: (value: unknown) => value is never;
51
- readonly box: (value: never, context: import("../index.js").RevivableContext<any>) => Blob;
52
- readonly revive: (value: import("../revivables/fallbacks.js").BoxedBlobGuard, _context: import("../index.js").RevivableContext<any>) => Blob;
49
+ readonly type: 'blob';
50
+ readonly capableOnly: true;
51
+ readonly isType: (value: unknown) => value is Blob;
52
+ readonly box: (value: Blob, context: import("../index.js").RevivableContext<any>) => Blob;
53
+ readonly revive: (value: import("../revivables/fallbacks.js").BoxedBlob, _context: import("../index.js").RevivableContext<any>) => Blob;
53
54
  }, typeof import("../revivables/event-target.js"), {
54
55
  readonly type: 'unclonable';
55
56
  readonly isType: (value: unknown) => value is never;
@@ -2,6 +2,7 @@ import type { DefaultRevivableModules, RevivableModule } from '../revivables/ind
2
2
  import type { ConnectionContext as BidirectionalConnectionContext } from './bidirectional.js';
3
3
  import type { Capable } from '../types.js';
4
4
  import type { ProtocolContext, StartConnectionsOptions } from './utils.js';
5
+ import type { Contextual, Exposed } from './utils.js';
5
6
  import * as bidirectional from './bidirectional.js';
6
7
  export * from './bidirectional.js';
7
8
  export * from './relay.js';
@@ -18,4 +19,4 @@ export type ConnectionMessage<TModules extends readonly RevivableModule[] = Defa
18
19
  Messages: (modules: TModules, value: T) => infer R;
19
20
  } ? R : never;
20
21
  export type ConnectionContext<TModules extends readonly RevivableModule[] = DefaultRevivableModules> = BidirectionalConnectionContext<TModules>;
21
- export declare const startConnections: <T = unknown, const TModules extends readonly RevivableModule[] = DefaultRevivableModules>(value: Capable<TModules>, { transport: _transport, name, remoteName, key, origin, unregisterSignal, revivableModules: configureRevivableModules, uuid: _uuid, remoteUuid: presetRemoteUuid, }: StartConnectionsOptions<TModules>) => Promise<T>;
22
+ export declare const startConnections: <T = unknown, const TModules extends readonly RevivableModule[] = DefaultRevivableModules, TResult = T>(value: Capable<TModules> | Contextual<Capable<TModules>>, { transport: _transport, name, remoteName, key, origin, unregisterSignal, revivableModules: configureRevivableModules, uuid: _uuid, remoteUuid: presetRemoteUuid, connection: selectConnection, }: StartConnectionsOptions<TModules>) => Exposed<TResult>;
@@ -1,6 +1,6 @@
1
1
  import type { Message, MessageVariant, Uuid, Capable, MessageEventMap } from '../types.js';
2
2
  import type { DefaultRevivableModules, RevivableModule } from '../revivables/index.js';
3
- import type { Transport } from '../utils/transport.js';
3
+ import type { Context, Transport } from '../utils/transport.js';
4
4
  import type { ConnectionContext } from './index.js';
5
5
  import type { TypedEventTarget } from '../utils/typed-event-target.js';
6
6
  export declare const normalizeTransport: (transport: Transport) => Transport;
@@ -10,12 +10,17 @@ export declare const normalizeTransport: (transport: Transport) => Transport;
10
10
  * omitted, the defaults are used as-is. */
11
11
  export declare const mergeRevivableModules: <TModules extends readonly RevivableModule[] = DefaultRevivableModules>(configure: ((defaults: DefaultRevivableModules) => TModules) | undefined) => TModules;
12
12
  export type ProtocolEventMap<TModules extends readonly RevivableModule[] = DefaultRevivableModules> = {
13
- message: CustomEvent<Message<TModules>>;
13
+ message: CustomEvent<{
14
+ message: Message<TModules>;
15
+ peer: () => Context;
16
+ }>;
14
17
  };
15
18
  export type ProtocolEventTarget<TModules extends readonly RevivableModule[] = DefaultRevivableModules> = TypedEventTarget<ProtocolEventMap<TModules>>;
16
19
  export type ProtocolContext<TModules extends readonly RevivableModule[] = DefaultRevivableModules> = {
17
20
  transport: Transport;
18
- value: Capable<TModules>;
21
+ /** The exposed value for ONE peer. A factory rather than a value so a server can answer each realm
22
+ * differently (scoped resolvers per origin) instead of sharing one object across every connection. */
23
+ valueFor: (peer: Context) => Capable<TModules>;
19
24
  revivableModules: TModules;
20
25
  connectionContexts: Map<string, ConnectionContext<TModules>>;
21
26
  getUuid: () => Uuid;
@@ -24,8 +29,14 @@ export type ProtocolContext<TModules extends readonly RevivableModule[] = Defaul
24
29
  * the unsolicited announce beacon broadcasts with '*'. */
25
30
  sendMessage: (message: MessageVariant, targetOrigin?: string) => void;
26
31
  protocolEventTarget: ProtocolEventTarget<TModules>;
27
- resolveRemoteValue: (value: Capable<TModules>) => void;
28
32
  rejectRemoteValue: (error: unknown) => void;
33
+ /** reports an established connection: settles the first-connection promise and feeds iteration, so
34
+ * a caller sees every realm rather than only the one that happened to connect first */
35
+ addConnection: (ctx: Context, value: Capable<TModules>) => void;
36
+ /** tears down one connection: close to the peer, teardown locally, drop it from tracking */
37
+ abortConnection: (remoteUuid: Uuid) => void;
38
+ /** true when this uuid was aborted before it was registered, which means refuse the registration */
39
+ claimPendingAbort: (remoteUuid: Uuid) => boolean;
29
40
  createConnectionEventTarget: () => TypedEventTarget<MessageEventMap<TModules>>;
30
41
  unregisterSignal?: AbortSignal;
31
42
  };
@@ -42,4 +53,75 @@ export type StartConnectionsOptions<TModules extends readonly RevivableModule[]
42
53
  revivableModules?: (defaults: DefaultRevivableModules) => TModules;
43
54
  uuid?: Uuid;
44
55
  remoteUuid?: Uuid;
56
+ /** Decides what one connection resolves to, for the await and for iteration alike. Omit it and that
57
+ * is the peer's value, which is what `expose` has always given back:
58
+ *
59
+ * ```ts
60
+ * const remote = await expose(api, { transport })
61
+ *
62
+ * const { value, context } = await expose(api, {
63
+ * transport,
64
+ * connection: ({ value, context }) => ({ value, context }),
65
+ * })
66
+ *
67
+ * for await (const origin of expose(api, {
68
+ * transport,
69
+ * connection: ({ context }) => context.origin,
70
+ * })) { }
71
+ * ```
72
+ *
73
+ * It runs per connection, on this side, after the handshake. It cannot change what is sent, and
74
+ * nothing it returns crosses the wire. */
75
+ connection?: (connected: Connected<unknown>) => unknown;
45
76
  };
77
+ /** An established connection: the value that realm exposed, and what this side knows about the realm
78
+ * it came from. `context` is whatever the transport observed, plus an `abort` that drops this one
79
+ * peer. Anything derived from it is the caller's to compute, in the value factory or in
80
+ * `connection:`, rather than something to declare up front. */
81
+ export type Connected<TValue> = {
82
+ value: TValue;
83
+ context: Context;
84
+ };
85
+ /** The result of `expose`. Awaiting it gives the first peer, iterating it gives every peer as it
86
+ * connects, and both hand back the same thing: one shape, read once or read repeatedly.
87
+ *
88
+ * What that shape IS comes from the `connection:` option. Without one it is the peer's value, which
89
+ * is what `expose` has always resolved to. With one it is whatever that function returns. */
90
+ export type Exposed<TResult> = Promise<TResult> & AsyncIterable<TResult>;
91
+ export type ConnectionQueue<TRemote> = {
92
+ push: (connection: Connected<TRemote>) => void;
93
+ close: () => void;
94
+ iterate: () => AsyncIterableIterator<Connected<TRemote>>;
95
+ };
96
+ /** @internal protocol plumbing, not part of the public api */
97
+ export declare const createConnectionQueue: <TRemote>() => ConnectionQueue<TRemote>;
98
+ /** The awaited-and-iterable result, with every connection passed through `select` first. The promise
99
+ * is DERIVED from the first-connection promise, so it needs its own no-op catch: a fire-and-forget
100
+ * `expose(...)` handles the original, and an unhandled derived rejection would still reach the
101
+ * console. */
102
+ /** @internal not part of the public api */
103
+ export declare const asExposed: <T, TResult>(first: Promise<Connected<T>>, queue: ConnectionQueue<T>, select: (connected: Connected<T>) => TResult) => Exposed<TResult>;
104
+ /** An exposed value can itself be a function - osra exposes functions as endpoints - so a bare
105
+ * `typeof value === 'function'` cannot tell a per-peer factory from a plain function value. The
106
+ * marker makes the intent explicit and unambiguous. */
107
+ /** @internal */
108
+ export declare const CONTEXT: unique symbol;
109
+ export type Contextual<TValue> = {
110
+ [CONTEXT]: (ctx: Context) => TValue;
111
+ };
112
+ /** Build the exposed value once per connection, from that connection's context, rather than sharing
113
+ * one value across every realm that connects. It runs BEFORE the value is boxed and sent, which is
114
+ * what lets one server answer each realm differently:
115
+ *
116
+ * ```ts
117
+ * expose(context(({ origin }) => resolvers(idFor(origin))), { transport })
118
+ * ```
119
+ *
120
+ * A wrapper rather than "pass a function", because osra exposes functions as endpoints, so a bare
121
+ * `typeof value === 'function'` cannot tell a per-peer factory from a plain function value.
122
+ *
123
+ * What the read side needs is not declared here: `connection:` sees the same context and derives its
124
+ * own. */
125
+ export declare const context: <TValue>(make: (ctx: Context) => TValue) => Contextual<TValue>;
126
+ /** @internal */
127
+ export declare const isContextual: <TValue>(value: unknown) => value is Contextual<TValue>;