osra 0.5.4 → 0.5.6
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 +31 -31
- package/build/connections/utils.d.ts +2 -2
- package/build/index.js +634 -511
- package/build/index.js.map +1 -1
- package/build/revivables/abort-signal.d.ts +1 -1
- package/build/revivables/identity.d.ts +1 -1
- package/build/revivables/readable-stream.d.ts +15 -1
- package/build/revivables/transfer.d.ts +1 -1
- package/build/types.d.ts +1 -1
- package/build/utils/event-channel.d.ts +3 -1
- package/build/utils/gc-tracker.d.ts +1 -1
- package/build/utils/type-guards.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/osra)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
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
|
|
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
|
-
- **Zero runtime dependencies
|
|
11
|
-
- **Symmetric API
|
|
12
|
-
- **Deep type support
|
|
13
|
-
- **JSON-mode degradation
|
|
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
14
|
- **`identity()`** for reference-preserving sends, **`transfer()`** for zero-copy moves
|
|
15
|
-
- **Strict TypeScript
|
|
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
16
|
- **Tested** on Chromium, Firefox, and WebKit via Playwright
|
|
17
17
|
|
|
18
18
|
## Install
|
|
@@ -66,19 +66,19 @@ for await (const n of await remote.streamData()) {
|
|
|
66
66
|
}
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
Both sides call `expose()
|
|
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
70
|
|
|
71
71
|
### Options
|
|
72
72
|
|
|
73
73
|
| Option | Default | Description |
|
|
74
74
|
|---|---|---|
|
|
75
75
|
| `transport` | required | The channel to communicate over (see [Transports](#transports)) |
|
|
76
|
-
| `key` | `'__OSRA_DEFAULT_KEY__'` | Namespacing tag
|
|
76
|
+
| `key` | `'__OSRA_DEFAULT_KEY__'` | Namespacing tag that lets multiple independent osra connections share one channel. **Not authentication.** |
|
|
77
77
|
| `origin` | `'*'` | On window transports: sets the outbound `postMessage` target origin **and** filters inbound messages by `event.origin` |
|
|
78
|
-
| `name` / `remoteName` |
|
|
79
|
-
| `unregisterSignal` |
|
|
80
|
-
| `uuid` / `remoteUuid` | random /
|
|
81
|
-
| `revivableModules` |
|
|
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
82
|
|
|
83
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
84
|
|
|
@@ -106,7 +106,7 @@ Transports are either **structured-clone** (Worker, Window, MessagePort, SharedW
|
|
|
106
106
|
| `Blob` / `File` | ✅ | ✅ | revive as `Promise<Blob>` / `Promise<File>` (bytes fetched async) |
|
|
107
107
|
| `Request` / `Response` / `Headers` | ✅ | ✅ | streamed bodies; `Request.signal` propagates; `Response.url`/`redirected` restored; opaque status-0 revives as `Response.error()` |
|
|
108
108
|
| `Event` / `CustomEvent` | ✅ | ✅ | subclass fields beyond `detail` are dropped |
|
|
109
|
-
| `EventTarget` | ✅ | ✅ | revives as a listener-only façade
|
|
109
|
+
| `EventTarget` | ✅ | ✅ | revives as a listener-only façade: `add`/`removeEventListener` proxy to the source; you can't dispatch through it |
|
|
110
110
|
| Other structured-clonables (`FileList`, `ImageData`, `DOMRect`, `CryptoKey`, …) | ✅ | ❌ | pass through structured clone untouched |
|
|
111
111
|
| Transfer-only host objects (`OffscreenCanvas`, `MediaStreamTrack`, `RTCDataChannel`, …) | ✅ | ❌ | always moved to the peer |
|
|
112
112
|
| `ImageBitmap`, `VideoFrame`, `AudioData` | ✅ | ❌ | copied by structured clone; wrap in `transfer()` to move |
|
|
@@ -116,7 +116,7 @@ Transports are either **structured-clone** (Worker, Window, MessagePort, SharedW
|
|
|
116
116
|
|
|
117
117
|
### Worker
|
|
118
118
|
|
|
119
|
-
Pass the `Worker` on the page side and `globalThis` (the `DedicatedWorkerGlobalScope`) inside the worker
|
|
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
120
|
|
|
121
121
|
### Window ↔ iframe
|
|
122
122
|
|
|
@@ -141,7 +141,7 @@ const remote = await expose<ParentApi>(iframeApi, {
|
|
|
141
141
|
|
|
142
142
|
### SharedWorker
|
|
143
143
|
|
|
144
|
-
Pass the `SharedWorker` instance directly on the page side
|
|
144
|
+
Pass the `SharedWorker` instance directly on the page side; osra rides its `.port` internally. Inside the worker, expose per connected port:
|
|
145
145
|
|
|
146
146
|
```ts
|
|
147
147
|
// page
|
|
@@ -162,7 +162,7 @@ globalThis.addEventListener('connect', event => {
|
|
|
162
162
|
|
|
163
163
|
### WebSocket
|
|
164
164
|
|
|
165
|
-
JSON mode. You can `expose()` while the socket is still `CONNECTING
|
|
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:
|
|
166
166
|
|
|
167
167
|
```ts
|
|
168
168
|
const socket = new WebSocket('wss://relay.example.com')
|
|
@@ -171,7 +171,7 @@ const remote = await expose<PeerApi>(localApi, { transport: socket })
|
|
|
171
171
|
|
|
172
172
|
### Service worker
|
|
173
173
|
|
|
174
|
-
A `ServiceWorker` can only emit and a `ServiceWorkerContainer` can only receive
|
|
174
|
+
A `ServiceWorker` can only emit and a `ServiceWorkerContainer` can only receive, so combine them as a custom pair:
|
|
175
175
|
|
|
176
176
|
```ts
|
|
177
177
|
const registration = await navigator.serviceWorker.ready
|
|
@@ -197,7 +197,7 @@ browser.runtime.onConnect.addListener(port => {
|
|
|
197
197
|
})
|
|
198
198
|
```
|
|
199
199
|
|
|
200
|
-
If you accept `onConnectExternal`/`onMessageExternal`, validate senders yourself
|
|
200
|
+
If you accept `onConnectExternal`/`onMessageExternal`, validate senders yourself; the `MessageContext` passed to custom receive listeners exposes `sender`.
|
|
201
201
|
|
|
202
202
|
### Custom transports
|
|
203
203
|
|
|
@@ -219,11 +219,11 @@ const remote = await expose<PeerApi>(localApi, {
|
|
|
219
219
|
})
|
|
220
220
|
```
|
|
221
221
|
|
|
222
|
-
Custom transports **must be plain objects
|
|
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.
|
|
223
223
|
|
|
224
224
|
## `identity()`
|
|
225
225
|
|
|
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
|
|
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.
|
|
227
227
|
|
|
228
228
|
```ts
|
|
229
229
|
import { expose, identity } from 'osra'
|
|
@@ -239,13 +239,13 @@ expose({
|
|
|
239
239
|
|
|
240
240
|
## `transfer()`
|
|
241
241
|
|
|
242
|
-
`transfer(value)` opts a `Transferable` (`ArrayBuffer`, `MessagePort`, streams, `ImageBitmap`, `OffscreenCanvas`, …) into move semantics
|
|
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.
|
|
243
243
|
|
|
244
244
|
```ts
|
|
245
245
|
import { transfer } from 'osra'
|
|
246
246
|
|
|
247
247
|
const pixels = new ArrayBuffer(16_000_000)
|
|
248
|
-
await remote.render(transfer(pixels)) // moved
|
|
248
|
+
await remote.render(transfer(pixels)) // moved - pixels is detached locally
|
|
249
249
|
```
|
|
250
250
|
|
|
251
251
|
## Error handling & lifecycle
|
|
@@ -255,7 +255,7 @@ await remote.render(transfer(pixels)) // moved — pixels is detached locally
|
|
|
255
255
|
- Aborting `unregisterSignal`:
|
|
256
256
|
- the pending `expose()` rejects with the abort reason,
|
|
257
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'`
|
|
258
|
+
- pending RPC calls reject with `'osra: connection closed'` on **both** sides (the peer receiving `close` rejects its pending calls too),
|
|
259
259
|
- proxied streams on wire-routed channels (JSON transports) are cancelled/aborted with the same error.
|
|
260
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
261
|
- After aborting, calling `expose()` again on the same transport performs a fresh handshake.
|
|
@@ -269,16 +269,16 @@ controller.abort(new Error('shutting down'))
|
|
|
269
269
|
// pending rejects with 'osra: connection closed'
|
|
270
270
|
```
|
|
271
271
|
|
|
272
|
-
**Trust model**: `key` is namespacing, not authentication. `origin` filters window messages in both directions
|
|
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.
|
|
273
273
|
|
|
274
274
|
## Limitations
|
|
275
275
|
|
|
276
|
-
- **Circular structures throw** a `TypeError` at send time
|
|
276
|
+
- **Circular structures throw** a `TypeError` at send time; break the cycle or restructure.
|
|
277
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
|
|
279
|
-
- **Unclonable values** (`WeakMap`, `WeakSet`, exotic host objects) coerce to `{}`
|
|
280
|
-
- **One-shot bodies**: sending the same `Request`/`Response`/`ReadableStream` twice fails
|
|
281
|
-
- **Generic functions collapse** in `Remote<T
|
|
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
282
|
- **Multi-peer**: only the first peer's value is accessible through the returned promise.
|
|
283
283
|
- **Everything is async**: sync return values still arrive as `Promise`s.
|
|
284
284
|
|
|
@@ -286,11 +286,11 @@ controller.abort(new Error('shutting down'))
|
|
|
286
286
|
|
|
287
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.
|
|
288
288
|
|
|
289
|
-
`expose()` validates the value you pass at compile time against `Capable
|
|
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
290
|
|
|
291
291
|
```ts
|
|
292
292
|
expose({ ok: async () => 1, cache: new WeakMap() }, { transport: worker })
|
|
293
|
-
// type error: Value type must resolve to a Capable
|
|
293
|
+
// type error: Value type must resolve to a Capable, with `cache` identified as the bad field
|
|
294
294
|
```
|
|
295
295
|
|
|
296
296
|
The published declarations require **TypeScript >= 5.9** with `strict` mode.
|
|
@@ -6,7 +6,7 @@ import type { TypedEventTarget } from '../utils/typed-event-target.js';
|
|
|
6
6
|
export declare const normalizeTransport: (transport: Transport) => Transport;
|
|
7
7
|
/** Resolves the final revivable module list. The user supplies a function
|
|
8
8
|
* that takes the defaults and returns whatever ordering/composition they
|
|
9
|
-
* want
|
|
9
|
+
* want - add modules, drop defaults, reorder, override per-type. When
|
|
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> = {
|
|
@@ -35,7 +35,7 @@ export type StartConnectionsOptions<TModules extends readonly RevivableModule[]
|
|
|
35
35
|
origin?: string;
|
|
36
36
|
unregisterSignal?: AbortSignal;
|
|
37
37
|
/** Configure the revivable module list. Receives the defaults and
|
|
38
|
-
* returns the final ordered list
|
|
38
|
+
* returns the final ordered list - add modules, drop defaults, reorder,
|
|
39
39
|
* or override per-type as needed. */
|
|
40
40
|
revivableModules?: (defaults: DefaultRevivableModules) => TModules;
|
|
41
41
|
uuid?: Uuid;
|