osra 0.4.5 → 0.5.1

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,320 +1,313 @@
1
- # Osra - Easy Communication Between Workers
1
+ # osra
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/osra.svg)](https://www.npmjs.com/package/osra)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
 
6
- Osra is a powerful, type-safe communication library for JavaScript/TypeScript that enables seamless inter-context communication with support for complex data types that normally wouldn't be transferable.
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
7
 
8
8
  ## Features
9
9
 
10
- - **Universal Communication** - Works across Workers, SharedWorkers, ServiceWorkers, Windows, MessagePorts, WebSockets, and Browser Extensions
11
- - **Rich Type Support** - Seamlessly handle Promises, Functions, Streams, Dates, Errors, TypedArrays, and more
12
- - **Full TypeScript Support** - Complete type safety with automatic type inference
13
- - **Two Transport Modes** - Capable mode for structured-clone transports, JSON mode for string-only channels selected from the transport itself
14
- - **Zero Dependencies** - Lightweight with no external runtime dependencies
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, `Blob`/`File`, `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
15
17
 
16
- ## Installation
18
+ ## Install
17
19
 
18
- ```bash
20
+ ```sh
19
21
  npm install osra
20
22
  ```
21
23
 
22
24
  ## Quick Start
23
25
 
24
- ### Basic Worker Communication
26
+ ```ts
27
+ // worker.ts
28
+ import type { Transport } from 'osra'
25
29
 
26
- **Worker file (`worker.ts`):**
27
- ```typescript
28
30
  import { expose } from 'osra'
29
31
 
30
32
  const api = {
31
- // Simple function
32
33
  add: async (a: number, b: number) => a + b,
33
-
34
- // Function returning complex objects
35
- getUser: async (id: string) => ({
36
- id,
37
- name: 'John Doe',
38
- createdAt: new Date(),
39
- // Even functions work!
40
- greet: () => `Hello, I'm user ${id}`,
41
- }),
42
-
43
- // Streaming data
34
+ makeCounter: async () => {
35
+ let count = 0
36
+ return async () => ++count
37
+ },
44
38
  streamData: async function* () {
45
- for (let i = 0; i < 10; i++) {
46
- yield i
47
- await new Promise(r => setTimeout(r, 100))
48
- }
49
- }
39
+ for (let i = 0; i < 3; i++) yield i
40
+ },
50
41
  }
51
42
 
52
- export type WorkerAPI = typeof api
43
+ export type Api = typeof api
53
44
 
54
- // Expose the API through the worker
55
- expose(api, { transport: self })
45
+ expose(api, { transport: globalThis as unknown as Transport })
56
46
  ```
57
47
 
58
- **Main thread (`main.ts`):**
59
- ```typescript
48
+ ```ts
49
+ // main.ts
50
+ import type { Api } from './worker'
51
+
60
52
  import { expose } from 'osra'
61
- import type { WorkerAPI } from './worker'
62
53
 
63
- const worker = new Worker('./worker.js', { type: 'module' })
54
+ const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
64
55
 
65
- // Connect to the worker with full type safety
66
- const api = await expose<WorkerAPI>({}, { transport: worker })
56
+ const remote = await expose<Api>({}, { transport: worker })
67
57
 
68
- // Call functions as if they were local
69
- const sum = await api.add(5, 3) // 8
58
+ await remote.add(40, 2) // 42
70
59
 
71
- // Complex objects work seamlessly
72
- const user = await api.getUser('123')
73
- console.log(user.createdAt) // Date object, not string!
74
- const greeting = await user.greet() // "Hello, I'm user 123"
60
+ const counter = await remote.makeCounter()
61
+ await counter() // 1
62
+ await counter() // 2
75
63
 
76
- // Stream data
77
- for await (const value of api.streamData()) {
78
- console.log(value) // 0, 1, 2, ...
64
+ for await (const n of await remote.streamData()) {
65
+ console.log(n) // 0, 1, 2
79
66
  }
80
67
  ```
81
68
 
82
- ## Advanced Examples
83
-
84
- ### Window to Window Communication
85
-
86
- ```typescript
87
- // Parent window
88
- import { expose } from 'osra'
89
-
90
- const childWindow = window.open('child.html')
91
-
92
- const parentAPI = {
93
- notifyParent: async (message: string) => {
94
- console.log('Child says:', message)
95
- }
96
- }
97
-
98
- const childAPI = await expose<ChildAPI>(parentAPI, {
99
- transport: childWindow,
100
- origin: 'https://child-domain.com' // Optional: restrict origin
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 — 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 |
82
+
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.
84
+
85
+ ## Supported types
86
+
87
+ Transports are either **structured-clone** (Worker, Window, MessagePort, SharedWorker) or **JSON** (WebSocket, web extension messaging, custom transports with `isJson: true`).
88
+
89
+ | Type | Clone | JSON | Notes |
90
+ |---|---|---|---|
91
+ | JSON primitives, plain objects, arrays | ✅ | ✅ | |
92
+ | `undefined`, `NaN`, `±Infinity` | ✅ | ✅ | preserved even over JSON |
93
+ | `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 |
97
+ | `RegExp` | ✅ | ❌ | |
98
+ | `SharedArrayBuffer` | ✅ | ❌ | shared memory across the contexts |
99
+ | Function | ✅ | ✅ | becomes `(...args) => Promise<result>`; arguments and results recurse through the same boxing |
100
+ | `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
+ | `Blob` / `File` | ✅ | ✅ | revive as `Promise<Blob>` / `Promise<File>` (bytes fetched async) |
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 |
109
+ | `EventTarget` | ✅ | ✅ | revives as a listener-only façade — `add`/`removeEventListener` proxy to the source; you can't dispatch through it |
110
+ | Other structured-clonables (`FileList`, `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
116
+
117
+ ### Worker
118
+
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`.
120
+
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:
124
+
125
+ ```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',
101
131
  })
102
-
103
- // Child window (child.html)
104
- const childAPI = {
105
- initialize: async () => {
106
- console.log('Child initialized!')
107
- return true
108
- }
109
- }
110
-
111
- expose(childAPI, { transport: window.parent })
112
132
  ```
113
133
 
114
- ### SharedWorker Communication
115
-
116
- ```typescript
117
- // Shared Worker
118
- import { expose } from 'osra'
119
-
120
- const connections = new Set<string>()
121
-
122
- const api = {
123
- connect: async (clientId: string) => {
124
- connections.add(clientId)
125
- return {
126
- broadcast: async (message: string) => {
127
- // Broadcast to all connected clients
128
- console.log(`${clientId} broadcasts: ${message}`)
129
- }
130
- }
131
- }
132
- }
133
-
134
- self.addEventListener('connect', (event) => {
135
- const port = event.ports[0]
136
- expose(api, { transport: port })
134
+ ```ts
135
+ // iframe
136
+ const remote = await expose<ParentApi>(iframeApi, {
137
+ transport: { emit: window.parent, receive: window },
138
+ origin: 'https://host.example.com',
137
139
  })
138
-
139
- // Client
140
- const sharedWorker = new SharedWorker('./shared-worker.js')
141
- const api = await expose<SharedWorkerAPI>({}, { transport: sharedWorker })
142
- const connection = await api.connect('client-1')
143
- await connection.broadcast('Hello everyone!')
144
140
  ```
145
141
 
146
- ### Browser Extension Communication
147
-
148
- ```typescript
149
- // Background script
150
- import { expose } from 'osra'
151
-
152
- const api = {
153
- fetchData: async (url: string) => {
154
- const response = await fetch(url)
155
- return response.json()
156
- }
157
- }
142
+ ### SharedWorker
158
143
 
159
- expose(api, { transport: chrome.runtime })
144
+ Pass the `SharedWorker` instance directly on the page side — osra rides its `.port` internally. Inside the worker, expose per connected port:
160
145
 
161
- // Content script or popup
162
- const api = await expose<BackgroundAPI>({}, { transport: chrome.runtime })
163
- const data = await api.fetchData('https://api.example.com/data')
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 })
164
150
  ```
165
151
 
166
- ### Custom Transport
167
-
168
- ```typescript
152
+ ```ts
153
+ // shared.ts
169
154
  import { expose } from 'osra'
170
155
 
171
- // Create custom transport for any communication channel
172
- const customTransport = {
173
- emit: (message: any, transferables?: Transferable[]) => {
174
- // Send message through your custom channel
175
- myCustomChannel.send(message, transferables)
176
- },
177
- receive: (listener: (message: any) => void) => {
178
- // Listen for messages from your custom channel
179
- myCustomChannel.on('message', listener)
180
-
181
- // Return cleanup function
182
- return () => myCustomChannel.off('message', listener)
183
- }
184
- }
156
+ const api = { add: async (a: number, b: number) => a + b }
185
157
 
186
- const api = await expose<RemoteAPI>({}, { transport: customTransport })
158
+ globalThis.addEventListener('connect', event => {
159
+ for (const port of (event as MessageEvent).ports) expose(api, { transport: port })
160
+ })
187
161
  ```
188
162
 
189
- ## Supported Types
190
-
191
- Osra automatically handles serialization/deserialization of:
163
+ ### WebSocket
192
164
 
193
- - **Primitives**: `boolean`, `number`, `string`, `null`, `undefined`, `BigInt`
194
- - **Objects & Arrays**: Including nested structures
195
- - **Built-in Objects**: `Date`, `RegExp`, `Map`, `Set`, `Error`
196
- - **Binary Data**: `ArrayBuffer`, `TypedArray`, `Blob`, `File`
197
- - **Functions**: Callable across contexts with full async support
198
- - **Promises**: Seamlessly await remote promises
199
- - **Streams**: `ReadableStream` support (WritableStream coming soon)
200
- - **Transferables**: `MessagePort`, `ImageBitmap`, `OffscreenCanvas`
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:
201
166
 
202
- ## API Reference
167
+ ```ts
168
+ const socket = new WebSocket('wss://relay.example.com')
169
+ const remote = await expose<PeerApi>(localApi, { transport: socket })
170
+ ```
203
171
 
204
- ### `expose<T>(value, options)`
172
+ ### Service worker
205
173
 
206
- The main function for establishing communication between contexts.
174
+ A `ServiceWorker` can only emit and a `ServiceWorkerContainer` can only receive — combine them as a custom pair:
207
175
 
208
- #### Parameters
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
+ ```
209
182
 
210
- - `value`: The object/value to expose (server-side) or an empty object (client-side)
211
- - `options`: Configuration object
212
- - `transport`: The transport to use (Worker, Window, MessagePort, etc.)
213
- - `name?`: Optional name for this endpoint (default: random UUID)
214
- - `remoteName?`: Name of the remote endpoint to connect to
215
- - `key?`: Optional key for additional security
216
- - `origin?`: Origin restriction for Window communication
217
- - `unregisterSignal?`: AbortSignal to clean up the connection
183
+ ### Web extension
218
184
 
219
- #### Returns
185
+ JSON mode. `runtime.Port`, the runtime itself (`sendMessage`/`onMessage`), `onConnect`, and `onMessage` are all accepted:
220
186
 
221
- Promise resolving to the remote API object with full type safety.
187
+ ```ts
188
+ // content script
189
+ const port = browser.runtime.connect()
190
+ const background = await expose<BackgroundApi>(contentApi, { transport: port })
191
+ ```
222
192
 
223
- ### Transfer Optimization
193
+ ```ts
194
+ // background
195
+ browser.runtime.onConnect.addListener(port => {
196
+ expose(backgroundApi, { transport: port })
197
+ })
198
+ ```
224
199
 
225
- Osra copies transferables by defaultyour buffers stay usable on the
226
- sender after an RPC. When you want to hand off ownership instead (large
227
- uploads, one-shot buffers, streams you won't read locally), wrap the value
228
- in `transfer()`:
200
+ If you accept `onConnectExternal`/`onMessageExternal`, validate senders yourself the `MessageContext` passed to custom receive listeners exposes `sender`.
229
201
 
230
- ```typescript
231
- import { expose, transfer } from 'osra'
202
+ ### Custom transports
232
203
 
233
- const buffer = new Uint8Array(largeData).buffer
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:
234
205
 
235
- // Default: copy. `buffer` is still usable after this call.
236
- await remote.preview(buffer)
206
+ ```ts
207
+ const channel = new BroadcastChannel('app')
237
208
 
238
- // Opt-in transfer: `buffer` is neutered on the sender, no copy made.
239
- await remote.upload(transfer(buffer))
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
+ })
240
220
  ```
241
221
 
242
- `transfer()` works for `ArrayBuffer`, typed array views, `MessagePort`,
243
- streams, `ImageBitmap`, and `OffscreenCanvas`. It's idempotent and a no-op
244
- for primitives and plain objects. Must-transfer types (`MessagePort`,
245
- streams, `OffscreenCanvas`) are always moved regardless of the wrapper —
246
- structured clone can't copy them.
247
-
248
- ## Protocol Modes
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.
249
223
 
250
- ### Bidirectional Mode (Default)
224
+ ## `identity()`
251
225
 
252
- Both sides can expose APIs and call each other:
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.
253
227
 
254
- ```typescript
255
- // Side A
256
- const remoteAPI = await expose<RemoteAPI>(localAPI, { transport })
228
+ ```ts
229
+ import { expose, identity } from 'osra'
257
230
 
258
- // Side B
259
- const remoteAPI = await expose<RemoteAPI>(localAPI, { transport })
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 })
260
238
  ```
261
239
 
262
- ### Unidirectional Mode
240
+ ## `transfer()`
263
241
 
264
- One-way communication when only one side needs to call the other:
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.
265
243
 
266
- ```typescript
267
- // Server (exposes API)
268
- expose(api, { transport })
244
+ ```ts
245
+ import { transfer } from 'osra'
269
246
 
270
- // Client (calls API)
271
- const api = await expose<API>({}, { transport })
247
+ const pixels = new ArrayBuffer(16_000_000)
248
+ await remote.render(transfer(pixels)) // moved pixels is detached locally
272
249
  ```
273
250
 
274
- ## Transport Modes
251
+ ## Error handling & lifecycle
252
+
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.
262
+
263
+ ```ts
264
+ const controller = new AbortController()
265
+ const remote = await expose<Api>({}, { transport: worker, unregisterSignal: controller.signal })
266
+
267
+ const pending = remote.slowCall()
268
+ controller.abort(new Error('shutting down'))
269
+ // pending rejects with 'osra: connection closed'
270
+ ```
275
271
 
276
- Osra picks between two modes based on the transport you hand it:
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.
277
273
 
278
- - **Capable mode** — Workers, SharedWorkers, ServiceWorkers, Windows,
279
- MessagePorts, and any custom transport without `isJson: true`. Uses
280
- structured clone natively and moves transferables when you opt in with
281
- `transfer()`.
282
- - **JSON mode** — WebSockets, browser extension runtime/port APIs, and any
283
- custom transport flagged with `isJson: true`. Complex types (Functions,
284
- Promises, Dates, Errors, TypedArrays, streams, …) still work: the
285
- box/reviver system serializes them into JSON-safe representations and
286
- revives them on the other side.
274
+ ## Limitations
287
275
 
288
- You generally don't need to configure anythingpass your transport and
289
- osra does the right thing. For a custom transport that tunnels JSON (e.g.
290
- over a `string`-only channel), set `isJson: true` on it.
276
+ - **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.
291
284
 
292
- ## Performance Tips
285
+ ## TypeScript
293
286
 
294
- 1. **Use Transfer for Large Data**: Transfer ArrayBuffers and TypedArrays instead of cloning
295
- 2. **Batch Operations**: Group multiple calls when possible
296
- 3. **Stream Large Datasets**: Use async generators for large data sets
297
- 4. **Reuse Connections**: Keep connections alive for multiple operations
287
+ `Remote<T>` is what the other side sees: functions become `(...args) => Promise<Awaited<R>>`, `Blob` becomes `Promise<Blob>`, containers map recursively, platform objects revive as themselves.
298
288
 
299
- ## Browser Compatibility
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:
300
290
 
301
- - Chrome/Edge 88+
302
- - Firefox 85+
303
- - Safari 15+
304
- - Node.js 16+ (with Worker Threads)
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
+ ```
305
295
 
306
- ## Contributing
296
+ The published declarations require **TypeScript >= 5.9** with `strict` mode.
307
297
 
308
- Contributions are welcome! Please feel free to submit a Pull Request.
298
+ ## Documentation
309
299
 
310
- ## License
300
+ - [API reference](./docs/API.md)
301
+ - [Advanced usage](./docs/ADVANCED.md)
311
302
 
312
- MIT © [Banou26](https://github.com/Banou26)
303
+ ## Development
313
304
 
314
- ## Roadmap
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
315
312
 
316
- - [ ] WritableStream support
317
- - [ ] Custom revivable plugins for user-defined types
318
- - [ ] Performance optimizations for large object graphs
319
- - [ ] Better error handling and debugging tools
320
- - [ ] WebRTC DataChannel transport
313
+ [MIT](./LICENSE)
@@ -23,7 +23,17 @@ export type ConnectionRevivableContext<TModules extends readonly RevivableModule
23
23
  revivableModules: TModules;
24
24
  eventTarget: MessageEventTarget<TModules>;
25
25
  };
26
- export declare const startBidirectionalConnection: <TModules extends readonly RevivableModule[] = readonly [typeof import("../revivables/transfer.js"), typeof import("../revivables/identity.js"), typeof import("../revivables/array-buffer.js"), typeof import("../revivables/date.js"), typeof import("../revivables/headers.js"), typeof import("../revivables/error.js"), typeof import("../revivables/typed-array.js"), typeof import("../revivables/blob.js"), typeof import("../revivables/promise.js"), typeof import("../revivables/function.js"), typeof import("../revivables/message-port.js"), typeof import("../revivables/readable-stream.js"), typeof import("../revivables/writable-stream.js"), typeof import("../revivables/abort-signal.js"), typeof import("../revivables/response.js"), typeof import("../revivables/request.js"), typeof import("../revivables/map.js"), typeof import("../revivables/set.js"), typeof import("../revivables/bigint.js"), typeof import("../revivables/symbol.js"), typeof import("../revivables/event.js"), {
26
+ export declare const startBidirectionalConnection: <TModules extends readonly RevivableModule[] = readonly [typeof import("../revivables/transfer.js"), typeof import("../revivables/identity.js"), typeof import("../revivables/array-buffer.js"), typeof import("../revivables/date.js"), typeof import("../revivables/headers.js"), typeof import("../revivables/error.js"), typeof import("../revivables/typed-array.js"), typeof import("../revivables/blob.js"), typeof import("../revivables/promise.js"), typeof import("../revivables/function.js"), typeof import("../revivables/message-port.js"), typeof import("../revivables/readable-stream.js"), typeof import("../revivables/writable-stream.js"), typeof import("../revivables/abort-signal.js"), typeof import("../revivables/response.js"), typeof import("../revivables/request.js"), typeof import("../revivables/map.js"), typeof import("../revivables/set.js"), typeof import("../revivables/bigint.js"), typeof import("../revivables/symbol.js"), typeof import("../revivables/event.js"), typeof import("../revivables/async-iterator.js"), {
27
+ readonly type: 'nonFiniteNumber';
28
+ readonly isType: (value: unknown) => value is number;
29
+ readonly box: (value: number, context: import("../index.js").RevivableContext<any>) => import("../revivables/json-primitives.js").BoxedNonFiniteNumber | number;
30
+ readonly revive: (value: import("../revivables/json-primitives.js").BoxedNonFiniteNumber, _context: import("../index.js").RevivableContext<any>) => number;
31
+ }, {
32
+ readonly type: 'undefined';
33
+ readonly isType: (value: unknown) => value is undefined;
34
+ readonly box: (value: undefined, context: import("../index.js").RevivableContext<any>) => import("../revivables/json-primitives.js").BoxedUndefined | undefined;
35
+ readonly revive: (_value: import("../revivables/json-primitives.js").BoxedUndefined, _context: import("../index.js").RevivableContext<any>) => undefined;
36
+ }, {
27
37
  readonly type: 'clonable';
28
38
  readonly capableOnly: true;
29
39
  readonly isType: (value: unknown) => value is import("../revivables/fallbacks.js").Clonable;
@@ -1,7 +1,7 @@
1
1
  import type { DefaultRevivableModules, RevivableModule } from '../revivables/index.js';
2
2
  import type { ConnectionContext as BidirectionalConnectionContext } from './bidirectional.js';
3
- import type { ProtocolContext, StartConnectionsOptions } from '../utils/index.js';
4
3
  import type { Capable } from '../types.js';
4
+ import type { ProtocolContext, StartConnectionsOptions } from './utils.js';
5
5
  import * as bidirectional from './bidirectional.js';
6
6
  export * from './bidirectional.js';
7
7
  export * from './relay.js';
@@ -23,7 +23,9 @@ export type ProtocolContext<TModules extends readonly RevivableModule[] = Defaul
23
23
  sendMessage: (message: MessageVariant) => void;
24
24
  protocolEventTarget: ProtocolEventTarget<TModules>;
25
25
  resolveRemoteValue: (value: Capable<TModules>) => void;
26
+ rejectRemoteValue: (error: unknown) => void;
26
27
  createConnectionEventTarget: () => TypedEventTarget<MessageEventMap<TModules>>;
28
+ unregisterSignal?: AbortSignal;
27
29
  };
28
30
  export type StartConnectionsOptions<TModules extends readonly RevivableModule[] = DefaultRevivableModules> = {
29
31
  transport: Transport;
package/build/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Capable } from './types.js';
1
+ import type { Capable, Remote } from './types.js';
2
2
  import type { DefaultRevivableModules, RevivableContext } from './revivables/index.js';
3
3
  import type { RevivableModule } from './revivables/index.js';
4
4
  import type { StartConnectionsOptions } from './connections/utils.js';
@@ -22,4 +22,4 @@ type CapableCheck<T, TModules extends readonly RevivableModule[] = DefaultReviva
22
22
  };
23
23
  export declare const expose: <T = unknown, const TModules extends readonly RevivableModule[] = DefaultRevivableModules, const TTransport extends Transport = Transport, const TValue = Capable<TModules, ContextOf<TTransport>>>(value: CapableCheck<TValue, TModules, ContextOf<TTransport>>, options: StartConnectionsOptions<TModules> & {
24
24
  transport: TTransport;
25
- }) => Promise<T>;
25
+ }) => Promise<Remote<T>>;