osra 0.6.2 → 0.6.3
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 +23 -24
- package/build/index.js +6 -3
- package/build/index.js.map +1 -1
- package/build/types.d.ts +11 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
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, degrading gracefully to a JSON-only mode on text channels.
|
|
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
7
|
|
|
8
8
|
## Features
|
|
9
9
|
|
|
10
|
-
- **Zero runtime dependencies**: one ESM module
|
|
10
|
+
- **Zero runtime dependencies**: one ESM module; the single declared dependency (`@types/webextension-polyfill`) is types-only, supporting the published declarations
|
|
11
11
|
- **Symmetric API**: both sides call `expose()`; either side can pass functions, both can call
|
|
12
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**:
|
|
13
|
+
- **JSON-mode degradation**: most 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
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
|
|
@@ -25,8 +25,6 @@ npm install osra
|
|
|
25
25
|
|
|
26
26
|
```ts
|
|
27
27
|
// worker.ts
|
|
28
|
-
import type { Transport } from 'osra'
|
|
29
|
-
|
|
30
28
|
import { expose } from 'osra'
|
|
31
29
|
|
|
32
30
|
const api = {
|
|
@@ -42,7 +40,7 @@ const api = {
|
|
|
42
40
|
|
|
43
41
|
export type Api = typeof api
|
|
44
42
|
|
|
45
|
-
expose(api, { transport: globalThis
|
|
43
|
+
expose(api, { transport: globalThis })
|
|
46
44
|
```
|
|
47
45
|
|
|
48
46
|
```ts
|
|
@@ -73,12 +71,12 @@ Both sides call `expose()`; the returned promise resolves with the remote side's
|
|
|
73
71
|
| Option | Default | Description |
|
|
74
72
|
|---|---|---|
|
|
75
73
|
| `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
|
|
77
|
-
| `origin` | `'*'` | On window transports: sets the outbound `postMessage` target origin **and** filters inbound messages by `event.origin` |
|
|
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) |
|
|
78
76
|
| `name` / `remoteName` | - | Label your endpoint / only accept envelopes from a matching peer name |
|
|
79
77
|
| `unregisterSignal` | - | `AbortSignal` that tears the connection down (see [Lifecycle](#error-handling--lifecycle)) |
|
|
80
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 |
|
|
81
|
-
| `revivableModules` | - | `defaults => modules` function to add, drop, reorder, or override
|
|
79
|
+
| `revivableModules` | - | `defaults => modules` function to add, drop, reorder, or override revivable modules |
|
|
82
80
|
|
|
83
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.
|
|
84
82
|
|
|
@@ -91,17 +89,17 @@ Transports are either **structured-clone** (Worker, Window, MessagePort, SharedW
|
|
|
91
89
|
| JSON primitives, plain objects, arrays | ✅ | ✅ | |
|
|
92
90
|
| `undefined`, `NaN`, `±Infinity` | ✅ | ✅ | preserved even over JSON |
|
|
93
91
|
| `Date`, `BigInt`, `Map`, `Set` | ✅ | ✅ | |
|
|
94
|
-
| Typed arrays, `ArrayBuffer` | ✅ | ✅ | subarray views
|
|
95
|
-
| `Error` + subclasses | ✅ | ✅ | `TypeError`, `RangeError`, `AggregateError`
|
|
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`) |
|
|
96
94
|
| `Symbol` | ✅ | ✅ | `Symbol.for` registry symbols round-trip by key; others keep per-connection identity |
|
|
97
95
|
| `RegExp` | ✅ | ❌ | |
|
|
98
96
|
| `SharedArrayBuffer` | ✅ | ❌ | shared memory across the contexts |
|
|
99
97
|
| Function | ✅ | ✅ | becomes `(...args) => Promise<result>`; arguments and results recurse through the same boxing |
|
|
100
98
|
| `Promise` | ✅ | ✅ | |
|
|
101
99
|
| Async generators / async iterables | ✅ | ✅ | `next`/`return`/`throw` proxied; `for await` works; early `break` runs the source's `finally` |
|
|
102
|
-
| `ReadableStream` | ✅ | ✅ |
|
|
103
|
-
| `WritableStream` | ✅ | ✅ | write/close/abort with acks; sink errors reject the writer |
|
|
104
|
-
| `MessagePort` | ✅ | ✅ | revives as a real `MessagePort` on both transport kinds |
|
|
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 |
|
|
105
103
|
| `AbortSignal` | ✅ | ✅ | abort and reason propagate |
|
|
106
104
|
| `File` / `FileList` | ✅ | ❌ | revive as themselves via structured clone (clone transports only); `Blob` is **not** supported |
|
|
107
105
|
| `Request` / `Response` / `Headers` | ✅ | ✅ | streamed bodies; `Request.signal` propagates; `Response.url`/`redirected` restored; opaque status-0 revives as `Response.error()` |
|
|
@@ -116,11 +114,11 @@ Transports are either **structured-clone** (Worker, Window, MessagePort, SharedW
|
|
|
116
114
|
|
|
117
115
|
### Worker
|
|
118
116
|
|
|
119
|
-
Pass the `Worker` on the page side and `globalThis` (the `DedicatedWorkerGlobalScope`) inside the worker; see [Quick Start](#quick-start).
|
|
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`.
|
|
120
118
|
|
|
121
119
|
### Window ↔ iframe
|
|
122
120
|
|
|
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:
|
|
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.
|
|
124
122
|
|
|
125
123
|
```ts
|
|
126
124
|
// parent
|
|
@@ -182,7 +180,7 @@ const remote = await expose<SwApi>(pageApi, {
|
|
|
182
180
|
|
|
183
181
|
### Web extension
|
|
184
182
|
|
|
185
|
-
JSON mode. `runtime.Port
|
|
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.
|
|
186
184
|
|
|
187
185
|
```ts
|
|
188
186
|
// content script
|
|
@@ -197,7 +195,7 @@ browser.runtime.onConnect.addListener(port => {
|
|
|
197
195
|
})
|
|
198
196
|
```
|
|
199
197
|
|
|
200
|
-
If you accept `onConnectExternal`/`onMessageExternal`,
|
|
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`.
|
|
201
199
|
|
|
202
200
|
### Custom transports
|
|
203
201
|
|
|
@@ -239,7 +237,7 @@ expose({
|
|
|
239
237
|
|
|
240
238
|
## `transfer()`
|
|
241
239
|
|
|
242
|
-
`transfer(value)` opts a `Transferable` (`ArrayBuffer`,
|
|
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.
|
|
243
241
|
|
|
244
242
|
```ts
|
|
245
243
|
import { transfer } from 'osra'
|
|
@@ -250,13 +248,14 @@ await remote.render(transfer(pixels)) // moved - pixels is detached locally
|
|
|
250
248
|
|
|
251
249
|
## Error handling & lifecycle
|
|
252
250
|
|
|
253
|
-
- Remote functions that throw reject the caller's promise with the revived error
|
|
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.
|
|
254
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).
|
|
255
253
|
- Aborting `unregisterSignal`:
|
|
256
254
|
- the pending `expose()` rejects with the abort reason,
|
|
257
255
|
- a protocol `close` is sent to every connected peer and per-connection state is disposed,
|
|
258
256
|
- pending RPC calls reject with `'osra: connection closed'` on **both** sides (the peer receiving `close` rejects its pending calls too),
|
|
259
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.
|
|
260
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.
|
|
261
260
|
- After aborting, calling `expose()` again on the same transport performs a fresh handshake.
|
|
262
261
|
|
|
@@ -269,13 +268,13 @@ controller.abort(new Error('shutting down'))
|
|
|
269
268
|
// pending rejects with 'osra: connection closed'
|
|
270
269
|
```
|
|
271
270
|
|
|
272
|
-
**Trust model**: `key` is namespacing
|
|
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.
|
|
273
272
|
|
|
274
273
|
## Limitations
|
|
275
274
|
|
|
276
275
|
- **Circular structures throw** a `TypeError` at send time; break the cycle or restructure.
|
|
277
276
|
- **Shared references duplicate**: two fields pointing at the same object arrive as two copies unless wrapped with `identity()`.
|
|
278
|
-
- **Classes/prototypes are not preserved**:
|
|
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.
|
|
279
278
|
- **Unclonable values** (`WeakMap`, `WeakSet`, exotic host objects) coerce to `{}` and fail the compile-time check.
|
|
280
279
|
- **One-shot bodies**: sending the same `Request`/`Response`/`ReadableStream` twice fails; the body locks at first send.
|
|
281
280
|
- **Generic functions collapse** in `Remote<T>`: mapped types can't preserve generic signatures.
|
|
@@ -286,14 +285,14 @@ controller.abort(new Error('shutting down'))
|
|
|
286
285
|
|
|
287
286
|
`Remote<T>` is what the other side sees: functions become `(...args) => Promise<Awaited<R>>`, containers map recursively, platform objects revive as themselves.
|
|
288
287
|
|
|
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:
|
|
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):
|
|
290
289
|
|
|
291
290
|
```ts
|
|
292
291
|
expose({ ok: async () => 1, cache: new WeakMap() }, { transport: worker })
|
|
293
292
|
// type error: Value type must resolve to a Capable, with `cache` identified as the bad field
|
|
294
293
|
```
|
|
295
294
|
|
|
296
|
-
The published declarations require **TypeScript >= 5.9** with `strict` mode.
|
|
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`).
|
|
297
296
|
|
|
298
297
|
## Documentation
|
|
299
298
|
|
package/build/index.js
CHANGED
|
@@ -251,7 +251,9 @@ var de = (e) => {
|
|
|
251
251
|
globalThis.WritableStream,
|
|
252
252
|
globalThis.TransformStream,
|
|
253
253
|
globalThis.ImageBitmap,
|
|
254
|
-
globalThis.OffscreenCanvas
|
|
254
|
+
globalThis.OffscreenCanvas,
|
|
255
|
+
globalThis.VideoFrame,
|
|
256
|
+
globalThis.AudioData
|
|
255
257
|
]) : !1, Ze = (e) => Xe(e) ? {
|
|
256
258
|
[qe]: !0,
|
|
257
259
|
value: e
|
|
@@ -1525,7 +1527,7 @@ var de = (e) => {
|
|
|
1525
1527
|
createConnectionEventTarget: Er,
|
|
1526
1528
|
unregisterSignal: c
|
|
1527
1529
|
};
|
|
1528
|
-
l({
|
|
1530
|
+
if (l({
|
|
1529
1531
|
listener: (e, t) => {
|
|
1530
1532
|
e.uuid !== v && x.dispatchEvent(new CustomEvent("message", { detail: e }));
|
|
1531
1533
|
},
|
|
@@ -1534,7 +1536,8 @@ var de = (e) => {
|
|
|
1534
1536
|
key: o,
|
|
1535
1537
|
origin: s,
|
|
1536
1538
|
unregisterSignal: c
|
|
1537
|
-
}), c?.
|
|
1539
|
+
}), c?.aborted) return _(c.reason), g;
|
|
1540
|
+
c?.addEventListener("abort", () => {
|
|
1538
1541
|
for (let [e, t] of h) y({
|
|
1539
1542
|
type: "close",
|
|
1540
1543
|
remoteUuid: e
|