osra 0.6.2 → 0.6.4

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,86 +1,97 @@
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 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
-
8
- ## Features
9
-
10
- - **Zero runtime dependencies**: one ESM module
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**: the same 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
- ```
23
-
24
- ## Quick Start
25
-
26
- ```ts
27
- // worker.ts
28
- import type { Transport } from 'osra'
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.
29
8
 
9
+ `worker.ts`
10
+ ```typescript
30
11
  import { expose } from 'osra'
31
12
 
32
- const api = {
33
- add: async (a: number, b: number) => a + b,
34
- makeCounter: async () => {
13
+ const payload = {
14
+ hash: crypto.getRandomValues(new Uint8Array(10)),
15
+ add: (a: number, b: number) => a + b,
16
+ makeCounter: () => {
35
17
  let count = 0
36
- return async () => ++count
37
- },
38
- streamData: async function* () {
39
- for (let i = 0; i < 3; i++) yield i
18
+ return () => ++count
40
19
  },
20
+ streamData: async function* () { yield* [0, 1, 2] }
41
21
  }
22
+ export type Payload = typeof payload
42
23
 
43
- export type Api = typeof api
44
-
45
- expose(api, { transport: globalThis as unknown as Transport })
24
+ expose(payload, { transport: globalThis })
46
25
  ```
47
26
 
48
- ```ts
49
- // main.ts
50
- import type { Api } from './worker'
51
-
27
+ `main.ts`
28
+ ```typescript
29
+ import type { Payload } from './worker'
52
30
  import { expose } from 'osra'
53
31
 
54
32
  const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
55
33
 
56
- const remote = await expose<Api>({}, { transport: worker })
34
+ export const {
35
+ hash, // Uint8Array
36
+ add, // (a: number, b: number) => Promise<number>
37
+ makeCounter, // () => Promise<() => Promise<number>>,
38
+ streamData, // () => Promise<AsyncIterableIterator<number>>
39
+ } = await expose<Payload>({}, { transport: worker })
40
+
41
+ hash.byteLength // 10
57
42
 
58
- await remote.add(40, 2) // 42
43
+ await add(40, 2) // 42
59
44
 
60
- const counter = await remote.makeCounter()
45
+ const counter = await makeCounter()
61
46
  await counter() // 1
62
47
  await counter() // 2
63
48
 
64
- for await (const n of await remote.streamData()) {
49
+ for await (const n of await streamData()) {
65
50
  console.log(n) // 0, 1, 2
66
51
  }
67
52
  ```
68
53
 
69
- 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.
70
-
71
- ### Options
72
-
73
- | Option | Default | Description |
74
- |---|---|---|
75
- | `transport` | required | The channel to communicate over (see [Transports](#transports)) |
76
- | `key` | `'__OSRA_DEFAULT_KEY__'` | Namespacing tag that lets multiple independent osra connections share one channel. **Not authentication.** |
77
- | `origin` | `'*'` | On window transports: sets the outbound `postMessage` target origin **and** filters inbound messages by `event.origin` |
78
- | `name` / `remoteName` | - | Label your endpoint / only accept envelopes from a matching peer name |
79
- | `unregisterSignal` | - | `AbortSignal` that tears the connection down (see [Lifecycle](#error-handling--lifecycle)) |
80
- | `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 |
81
- | `revivableModules` | - | `defaults => modules` function to add, drop, reorder, or override type-handling modules |
54
+ ## Features
82
55
 
83
- 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.
56
+ - **Efficient transport modes**:
57
+ - 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.
58
+ - JSON (default for `WebSocket`, WebExtensions, [etc...](#transport-modes)) is slower but supports more transport targets (e.g WebSocket, WebExtensions, etc...).
59
+
60
+ - **Wide type support**: Support all of the native platform types like `Function`, `Promise`, `ReadableStream`, `Response`, `Map`, `Uint8Array`, and [many more](#supported-types)...
61
+
62
+ - **Explicit typescript errors**: The codebase is entirely and extensively strictly typed. Anything that CAN cause issues at runtime will throw compile time errors.
63
+
64
+ As an example, trying to transfer a `File` value over a JSON transport, like so, will throw a compile time error:
65
+ ```typescript
66
+ ┌─────────────────────────────────────────────────────────────────────────────────────────────────────────┐
67
+ │ ... { │
68
+ │ [ErrorMessage]: "Value type is only supported on structured-clone transports, not on JSON transports";│
69
+ │ [BadValue]: File; │
70
+ │ [Path]: "foo"; │
71
+ │ [ParentObject]: { ...; }; │
72
+ │ }'. │
73
+ │ Type '{ foo: File; }' ... │
74
+ └─────────────────────────────────────────────────────────────────────────────────────────────────────────┘
75
+ ^^^^^^^^^^^^^^^^^^^^^^^^^
76
+ expose({ foo: new File([], '') }, { transport: new WebSocket('') })
77
+ ```
78
+
79
+ - **Extensive automated test suite** on Chromium, Firefox, and WebKit via Playwright
80
+
81
+ ## Transport modes
82
+
83
+ - **Structured-clone** (
84
+ [Window](https://developer.mozilla.org/en-US/docs/Web/API/Window),
85
+ [Worker](https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker),
86
+ [SharedWorker](https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker),
87
+ [ServiceWorker](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker),
88
+ [MessagePort](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort),
89
+ custom transports)
90
+ - **JSON** (
91
+ [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket),
92
+ [WebExtension runtime](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions) `connect()` and `onMessage`,
93
+ [WebExtension port](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/Port),
94
+ custom transports with `isJson: true`)
84
95
 
85
96
  ## Supported types
86
97
 
@@ -89,225 +100,131 @@ Transports are either **structured-clone** (Worker, Window, MessagePort, SharedW
89
100
  | Type | Clone | JSON | Notes |
90
101
  |---|---|---|---|
91
102
  | JSON primitives, plain objects, arrays | ✅ | ✅ | |
92
- | `undefined`, `NaN`, `±Infinity` | ✅ | ✅ | preserved even over JSON |
103
+ | `undefined`, `NaN`, `±Infinity` | ✅ | ✅ | |
93
104
  | `Date`, `BigInt`, `Map`, `Set` | ✅ | ✅ | |
94
- | Typed arrays, `ArrayBuffer` | | | subarray views keep `byteOffset`/`length` |
95
- | `Error` + subclasses | ✅ | ✅ | `TypeError`, `RangeError`, `AggregateError` (nested errors), `DOMException`, with `cause` and `stack` |
96
- | `Symbol` | ✅ | ✅ | `Symbol.for` registry symbols round-trip by key; others keep per-connection identity |
105
+ | `ArrayBuffer`, `Int8Array`, `Uint8Array`, `Uint8ClampedArray`, `Int16Array`, `Uint16Array`, `Int32Array`, `Uint32Array`, `Float16Array`, `Float32Array`, `Float64Array`, `BigInt64Array`, `BigUint64Array` | ✅ | ✅ | |
106
+ | `Error` + subclasses | ✅ | ✅ | built-ins errors properly preserve their subclass; custom error classes becomes generic `Error` |
107
+ | `Symbol` | ✅ | ✅ | `Symbol.for` properly preserves the Symbol's key; `Symbol()` is automatically wrapped with [`identity()`](#identity) |
97
108
  | `RegExp` | ✅ | ❌ | |
98
- | `SharedArrayBuffer` | ✅ | ❌ | shared memory across the contexts |
99
- | Function | ✅ | ✅ | becomes `(...args) => Promise<result>`; arguments and results recurse through the same boxing |
109
+ | `SharedArrayBuffer` | ✅ | ❌ | |
110
+ | Function | ✅ | ✅ | becomes `(...args) => Promise<result>`; arguments and results are properly handled too |
100
111
  | `Promise` | ✅ | ✅ | |
101
- | Async generators / async iterables | ✅ | ✅ | `next`/`return`/`throw` proxied; `for await` works; early `break` runs the source's `finally` |
102
- | `ReadableStream` | ✅ | ✅ | pull-based backpressure; cancel reason crosses |
103
- | `WritableStream` | ✅ | ✅ | write/close/abort with acks; sink errors reject the writer |
104
- | `MessagePort` | ✅ | ✅ | revives as a real `MessagePort` on both transport kinds |
105
- | `AbortSignal` | ✅ | ✅ | abort and reason propagate |
106
- | `File` / `FileList` | ✅ | ❌ | revive as themselves via structured clone (clone transports only); `Blob` is **not** supported |
107
- | `Request` / `Response` / `Headers` | ✅ | ✅ | streamed bodies; `Request.signal` propagates; `Response.url`/`redirected` restored; opaque status-0 revives as `Response.error()` |
108
- | `Event` / `CustomEvent` | ✅ | ✅ | subclass fields beyond `detail` are dropped |
112
+ | Async generators / async iterables | ✅ | ✅ | |
113
+ | `ReadableStream` | ✅ | ✅ | |
114
+ | `WritableStream` | ✅ | ✅ | |
115
+ | `MessagePort` | ✅ | ✅ | |
116
+ | `AbortSignal` | ✅ | ✅ | |
117
+ | `File` / `FileList` / `Blob` | ✅ | ❌ | |
118
+ | `Request` / `Response` / `Headers` | ✅ | ✅ | |
119
+ | `Event` / `CustomEvent` | ✅ | ✅ | Event subclass is not preserved |
109
120
  | `EventTarget` | ✅ | ✅ | revives as a listener-only façade: `add`/`removeEventListener` proxy to the source; you can't dispatch through it |
110
- | Other structured-clonables (`ImageData`, `DOMRect`, `CryptoKey`, …) | ✅ | ❌ | pass through structured clone untouched |
111
- | Transfer-only host objects (`OffscreenCanvas`, `MediaStreamTrack`, `RTCDataChannel`, …) | ✅ | ❌ | always moved to the peer |
112
- | `ImageBitmap`, `VideoFrame`, `AudioData` | ✅ | ❌ | copied by structured clone; wrap in `transfer()` to move |
113
- | `WeakMap` / `WeakSet`, other unclonables | ❌ | ❌ | coerce to `{}` at runtime, rejected at compile time |
114
-
115
- ## Transports
121
+ | Structured-clonables (`ImageData`, `DOMRect`, `CryptoKey`, …) | ✅ | ❌ | |
122
+ | Transfer-only host objects (`OffscreenCanvas`, `MediaStreamTrack`, `RTCDataChannel`, …) | ✅ | ❌ | |
123
+ | `ImageBitmap`, `VideoFrame`, `AudioData` | ✅ | ❌ | |
124
+ | `WeakMap` / `WeakSet`, other unclonables | ❌ | ❌ | |
116
125
 
117
- ### Worker
118
126
 
119
- Pass the `Worker` on the page side and `globalThis` (the `DedicatedWorkerGlobalScope`) inside the worker; see [Quick Start](#quick-start). The worker scope is detected at runtime but isn't part of the `Transport` type union, so cast it: `globalThis as unknown as Transport`.
127
+ ## Identity
120
128
 
121
- ### Window iframe
122
-
123
- `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:
129
+ `identity(value)` preserves reference equality across contexts, sending the same identity wrapped value twice results in the same object reference on the peer.
124
130
 
131
+ `worker.ts`
125
132
  ```ts
126
- // parent
127
- const iframe = document.querySelector('iframe')!
128
- const remote = await expose<IframeApi>(parentApi, {
129
- transport: { emit: iframe.contentWindow!, receive: window },
130
- origin: 'https://app.example.com',
131
- })
132
- ```
133
-
134
- ```ts
135
- // iframe
136
- const remote = await expose<ParentApi>(iframeApi, {
137
- transport: { emit: window.parent, receive: window },
138
- origin: 'https://host.example.com',
139
- })
140
- ```
141
-
142
- ### SharedWorker
133
+ import { expose, identity } from 'osra'
143
134
 
144
- Pass the `SharedWorker` instance directly on the page side; osra rides its `.port` internally. Inside the worker, expose per connected port:
135
+ const value = { foo: 'bar' }
136
+ const payload = { value, ref1: identity(value), ref2: identity(value) }
145
137
 
146
- ```ts
147
- // page
148
- const sharedWorker = new SharedWorker(new URL('./shared.ts', import.meta.url), { type: 'module' })
149
- const remote = await expose<Api>({}, { transport: sharedWorker })
138
+ expose(payload, { transport: globalThis })
139
+ export type Payload = typeof payload
150
140
  ```
151
141
 
142
+ `main.ts`
152
143
  ```ts
153
- // shared.ts
144
+ import type { Payload } from './worker'
154
145
  import { expose } from 'osra'
155
146
 
156
- const api = { add: async (a: number, b: number) => a + b }
157
-
158
- globalThis.addEventListener('connect', event => {
159
- for (const port of (event as MessageEvent).ports) expose(api, { transport: port })
160
- })
161
- ```
162
-
163
- ### WebSocket
164
-
165
- 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:
147
+ const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
148
+ const { value, ref1, ref2 } = await expose<Payload>({}, { transport: worker })
166
149
 
167
- ```ts
168
- const socket = new WebSocket('wss://relay.example.com')
169
- const remote = await expose<PeerApi>(localApi, { transport: socket })
150
+ value === ref1 // false
151
+ ref1 === ref2 // true
170
152
  ```
171
153
 
172
- ### Service worker
173
-
174
- A `ServiceWorker` can only emit and a `ServiceWorkerContainer` can only receive, so combine them as a custom pair:
175
154
 
176
- ```ts
177
- const registration = await navigator.serviceWorker.ready
178
- const remote = await expose<SwApi>(pageApi, {
179
- transport: { emit: registration.active!, receive: navigator.serviceWorker },
180
- })
181
- ```
182
-
183
- ### Web extension
155
+ ## Transfer
184
156
 
185
- JSON mode. `runtime.Port`, the runtime itself (`sendMessage`/`onMessage`), `onConnect`, and `onMessage` are all accepted:
157
+ 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.
186
158
 
187
159
  ```ts
188
- // content script
189
- const port = browser.runtime.connect()
190
- const background = await expose<BackgroundApi>(contentApi, { transport: port })
191
- ```
160
+ import { transfer } from 'osra'
192
161
 
193
- ```ts
194
- // background
195
- browser.runtime.onConnect.addListener(port => {
196
- expose(backgroundApi, { transport: port })
197
- })
162
+ const buffer = new ArrayBuffer(16_000_000)
163
+ await remote.transferBuffer(transfer(buffer)) // moved - buffer is detached locally
198
164
  ```
199
165
 
200
- If you accept `onConnectExternal`/`onMessageExternal`, validate senders yourself; the `MessageContext` passed to custom receive listeners exposes `sender`.
166
+ ### Options
201
167
 
202
- ### Custom transports
168
+ | Option | Default | Description |
169
+ |---|---|---|
170
+ | `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 |
171
+ | `key` | `'__OSRA_DEFAULT_KEY__'` | Namespacing tag that lets multiple independent osra connections share one channel |
172
+ | `origin` | `'*'` | Similar to [`postMessage`'s `origin`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#origin), It restricts the remote origin |
173
+ | `name` | - | Defines the name that will be used for the announcement |
174
+ | `remoteName` | - | Filters any incoming messages that are not equal to the `name` of the remote peer |
175
+ | `unregisterSignal` | - | `AbortSignal` that will tear down the connection when aborted |
176
+ | `uuid` / `remoteUuid` | random / - | Same as `name` and `remoteName`, but automatically generated at announce time |
177
+ | `revivableModules` | - | `defaults => modules` function to add, drop, reorder, or override revivable modules |
178
+ | `connection` | `({ value }) => value` | What one connection resolves to, for the await and for iteration alike (see [Connections](#connections)) |
203
179
 
204
- 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:
180
+ ## Connections
205
181
 
206
- ```ts
207
- const channel = new BroadcastChannel('app')
208
-
209
- const remote = await expose<PeerApi>(localApi, {
210
- transport: {
211
- isJson: true,
212
- emit: message => channel.postMessage(message),
213
- receive: listener => {
214
- const handler = (event: MessageEvent) => listener(event.data, {})
215
- channel.addEventListener('message', handler)
216
- return () => channel.removeEventListener('message', handler)
217
- },
218
- },
219
- })
220
- ```
182
+ `expose()` is awaitable and async-iterable. Awaiting gives the first peer, iterating gives every peer as it connects:
221
183
 
222
- Custom transports **must be plain objects**: prototype-based objects (e.g. Node `EventEmitter`s) with `emit` methods are deliberately not detected as custom transports.
184
+ ```typescript
185
+ import { expose } from 'osra'
223
186
 
224
- ## `identity()`
187
+ type PeerApi = { version: () => string }
225
188
 
226
- `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.
189
+ const api = { log: (line: string) => console.log(line) }
227
190
 
228
- ```ts
229
- import { expose, identity } from 'osra'
191
+ // the first peer to connect
192
+ const remote = await expose<PeerApi>(api, { transport: window })
193
+ await remote.version()
230
194
 
231
- const settings = { theme: 'dark' }
232
- expose({
233
- getSettings: async () => identity(settings),
234
- saveSettings: async (saved: typeof settings) => {
235
- // when the remote sends back identity(saved): saved === settings
236
- },
237
- }, { transport: worker })
195
+ // every peer, as each one arrives
196
+ for await (const peer of expose<PeerApi>(api, { transport: window })) {
197
+ console.log('peer connected, running', await peer.version())
198
+ }
238
199
  ```
239
200
 
240
- ## `transfer()`
241
-
242
- `transfer(value)` opts a `Transferable` (`ArrayBuffer`, `MessagePort`, streams, `ImageBitmap`, `OffscreenCanvas`, …) into move semantics: ownership transfers to the peer instead of copying. On JSON transports it silently degrades to a copy.
201
+ 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.
243
202
 
244
- ```ts
245
- import { transfer } from 'osra'
203
+ Pass `connection` to decide what a peer resolves to, which is also how you reach its origin and its per-peer `abort`:
246
204
 
247
- const pixels = new ArrayBuffer(16_000_000)
248
- await remote.render(transfer(pixels)) // moved - pixels is detached locally
205
+ ```typescript
206
+ for await (const peer of expose({}, {
207
+ transport: window,
208
+ connection: ({ value, context }) => ({ value, context })
209
+ })) {
210
+ if (!allowed(peer.context.origin)) peer.context.abort?.()
211
+ }
249
212
  ```
250
213
 
251
- ## Error handling & lifecycle
214
+ 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.
252
215
 
253
- - Remote functions that throw reject the caller's promise with the revived error, subclass and all.
254
- - `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).
255
- - Aborting `unregisterSignal`:
256
- - the pending `expose()` rejects with the abort reason,
257
- - a protocol `close` is sent to every connected peer and per-connection state is disposed,
258
- - pending RPC calls reject with `'osra: connection closed'` on **both** sides (the peer receiving `close` rejects its pending calls too),
259
- - proxied streams on wire-routed channels (JSON transports) are cancelled/aborted with the same error.
260
- - 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.
261
- - After aborting, calling `expose()` again on the same transport performs a fresh handshake.
216
+ 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:
262
217
 
263
- ```ts
264
- const controller = new AbortController()
265
- const remote = await expose<Api>({}, { transport: worker, unregisterSignal: controller.signal })
218
+ ```typescript
219
+ import { expose, context } from 'osra'
266
220
 
267
- const pending = remote.slowCall()
268
- controller.abort(new Error('shutting down'))
269
- // pending rejects with 'osra: connection closed'
221
+ expose(context(({ origin }) => ({ read: readFor(origin) })), { transport: window })
270
222
  ```
271
223
 
272
- **Trust model**: `key` is namespacing, not authentication. `origin` filters window messages in both directions; set it whenever you talk across origins. Treat peers as semi-trusted: malformed payloads are handled, but DoS-hardening against hostile peers is not complete.
224
+ It runs before your value is sent, so calling `ctx.abort()` inside it refuses that peer outright.
273
225
 
274
226
  ## Limitations
275
227
 
276
228
  - **Circular structures throw** a `TypeError` at send time; break the cycle or restructure.
277
- - **Shared references duplicate**: two fields pointing at the same object arrive as two copies unless wrapped with `identity()`.
278
- - **Classes/prototypes are not preserved**: values cross as plain data; a class instance's methods are not proxied. Expose plain objects and functions.
279
- - **Unclonable values** (`WeakMap`, `WeakSet`, exotic host objects) coerce to `{}` and fail the compile-time check.
280
- - **One-shot bodies**: sending the same `Request`/`Response`/`ReadableStream` twice fails; the body locks at first send.
281
- - **Generic functions collapse** in `Remote<T>`: mapped types can't preserve generic signatures.
282
- - **Multi-peer**: only the first peer's value is accessible through the returned promise.
283
- - **Everything is async**: sync return values still arrive as `Promise`s.
284
-
285
- ## TypeScript
286
-
287
- `Remote<T>` is what the other side sees: functions become `(...args) => Promise<Awaited<R>>`, containers map recursively, platform objects revive as themselves.
288
-
289
- `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:
290
-
291
- ```ts
292
- expose({ ok: async () => 1, cache: new WeakMap() }, { transport: worker })
293
- // type error: Value type must resolve to a Capable, with `cache` identified as the bad field
294
- ```
295
-
296
- The published declarations require **TypeScript >= 5.9** with `strict` mode.
297
-
298
- ## Documentation
299
-
300
- - [API reference](./docs/API.md)
301
- - [Advanced usage](./docs/ADVANCED.md)
302
-
303
- ## Development
304
-
305
- ```sh
306
- npm test # build lib + test bundle, run the Playwright matrix (chromium/firefox/webkit)
307
- npm run test-extension # web extension suite (needs a headed browser/display)
308
- npm run check-consumer-types # validate the published .d.ts as an npm consumer sees it
309
- ```
310
-
311
- ## License
312
-
313
- [MIT](./LICENSE)
229
+ - **Classes/prototypes are not preserved**: Classes and their instances are not preserved, please use plain objects and functions instead.
230
+ - **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>;