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 CHANGED
@@ -3,14 +3,14 @@
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 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**: the same value types work over text-only transports (WebSocket, extension messaging); `Date`, `Map`, typed arrays, even `NaN`/`±Infinity` survive
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 as unknown as Transport })
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. **Not authentication.** |
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 type-handling modules |
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 keep `byteOffset`/`length` |
95
- | `Error` + subclasses | ✅ | ✅ | `TypeError`, `RangeError`, `AggregateError` (nested errors), `DOMException`, … with `cause` and `stack` |
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` | ✅ | ✅ | 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 |
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). The worker scope is detected at runtime but isn't part of the `Transport` type union, so cast it: `globalThis as unknown as Transport`.
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`, the runtime itself (`sendMessage`/`onMessage`), `onConnect`, and `onMessage` are all accepted:
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`, validate senders yourself; the `MessageContext` passed to custom receive listeners exposes `sender`.
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`, `MessagePort`, streams, `ImageBitmap`, `OffscreenCanvas`, …) into move semantics: ownership transfers to the peer instead of copying. On JSON transports it silently degrades to a copy.
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, subclass and all.
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, 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.
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**: values cross as plain data; a class instance's methods are not proxied. Expose plain objects and functions.
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?.addEventListener("abort", () => {
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