osra 0.6.3 → 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 +148 -230
- package/build/connections/bidirectional.d.ts +5 -4
- package/build/connections/index.d.ts +2 -1
- package/build/connections/utils.d.ts +86 -4
- package/build/index.d.ts +52 -6
- package/build/index.js +678 -539
- package/build/index.js.map +1 -1
- package/build/revivables/fallbacks.d.ts +8 -6
- package/build/revivables/index.d.ts +5 -4
- package/build/types.d.ts +2 -2
- package/build/utils/transport.d.ts +17 -0
- package/package.json +14 -7
package/README.md
CHANGED
|
@@ -1,84 +1,97 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<h2 align="center">Osra</h2>
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
[
|
|
4
|
-
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[Documentation](https://osra.banou.dev)
|
|
5
6
|
|
|
6
|
-
|
|
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.
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
- **Zero runtime dependencies**: one ESM module; the single declared dependency (`@types/webextension-polyfill`) is types-only, supporting the published declarations
|
|
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**: most 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
|
|
9
|
+
`worker.ts`
|
|
10
|
+
```typescript
|
|
28
11
|
import { expose } from 'osra'
|
|
29
12
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
13
|
+
const payload = {
|
|
14
|
+
hash: crypto.getRandomValues(new Uint8Array(10)),
|
|
15
|
+
add: (a: number, b: number) => a + b,
|
|
16
|
+
makeCounter: () => {
|
|
33
17
|
let count = 0
|
|
34
|
-
return
|
|
35
|
-
},
|
|
36
|
-
streamData: async function* () {
|
|
37
|
-
for (let i = 0; i < 3; i++) yield i
|
|
18
|
+
return () => ++count
|
|
38
19
|
},
|
|
20
|
+
streamData: async function* () { yield* [0, 1, 2] }
|
|
39
21
|
}
|
|
22
|
+
export type Payload = typeof payload
|
|
40
23
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
expose(api, { transport: globalThis })
|
|
24
|
+
expose(payload, { transport: globalThis })
|
|
44
25
|
```
|
|
45
26
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
import type {
|
|
49
|
-
|
|
27
|
+
`main.ts`
|
|
28
|
+
```typescript
|
|
29
|
+
import type { Payload } from './worker'
|
|
50
30
|
import { expose } from 'osra'
|
|
51
31
|
|
|
52
32
|
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
|
|
53
33
|
|
|
54
|
-
const
|
|
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
|
|
55
42
|
|
|
56
|
-
await
|
|
43
|
+
await add(40, 2) // 42
|
|
57
44
|
|
|
58
|
-
const counter = await
|
|
45
|
+
const counter = await makeCounter()
|
|
59
46
|
await counter() // 1
|
|
60
47
|
await counter() // 2
|
|
61
48
|
|
|
62
|
-
for await (const n of await
|
|
49
|
+
for await (const n of await streamData()) {
|
|
63
50
|
console.log(n) // 0, 1, 2
|
|
64
51
|
}
|
|
65
52
|
```
|
|
66
53
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
### Options
|
|
70
|
-
|
|
71
|
-
| Option | Default | Description |
|
|
72
|
-
|---|---|---|
|
|
73
|
-
| `transport` | required | The channel to communicate over (see [Transports](#transports)) |
|
|
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) |
|
|
76
|
-
| `name` / `remoteName` | - | Label your endpoint / only accept envelopes from a matching peer name |
|
|
77
|
-
| `unregisterSignal` | - | `AbortSignal` that tears the connection down (see [Lifecycle](#error-handling--lifecycle)) |
|
|
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 |
|
|
79
|
-
| `revivableModules` | - | `defaults => modules` function to add, drop, reorder, or override revivable modules |
|
|
54
|
+
## Features
|
|
80
55
|
|
|
81
|
-
|
|
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`)
|
|
82
95
|
|
|
83
96
|
## Supported types
|
|
84
97
|
|
|
@@ -87,226 +100,131 @@ Transports are either **structured-clone** (Worker, Window, MessagePort, SharedW
|
|
|
87
100
|
| Type | Clone | JSON | Notes |
|
|
88
101
|
|---|---|---|---|
|
|
89
102
|
| JSON primitives, plain objects, arrays | ✅ | ✅ | |
|
|
90
|
-
| `undefined`, `NaN`, `±Infinity` | ✅ | ✅ |
|
|
103
|
+
| `undefined`, `NaN`, `±Infinity` | ✅ | ✅ | |
|
|
91
104
|
| `Date`, `BigInt`, `Map`, `Set` | ✅ | ✅ | |
|
|
92
|
-
|
|
|
93
|
-
| `Error` + subclasses | ✅ | ✅ | built-ins
|
|
94
|
-
| `Symbol` | ✅ | ✅ | `Symbol.for`
|
|
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) |
|
|
95
108
|
| `RegExp` | ✅ | ❌ | |
|
|
96
|
-
| `SharedArrayBuffer` | ✅ | ❌ |
|
|
97
|
-
| Function | ✅ | ✅ | becomes `(...args) => Promise<result>`; arguments and results
|
|
109
|
+
| `SharedArrayBuffer` | ✅ | ❌ | |
|
|
110
|
+
| Function | ✅ | ✅ | becomes `(...args) => Promise<result>`; arguments and results are properly handled too |
|
|
98
111
|
| `Promise` | ✅ | ✅ | |
|
|
99
|
-
| Async generators / async iterables | ✅ | ✅ |
|
|
100
|
-
| `ReadableStream` | ✅ | ✅ |
|
|
101
|
-
| `WritableStream` | ✅ | ✅ |
|
|
102
|
-
| `MessagePort` | ✅ | ✅ |
|
|
103
|
-
| `AbortSignal` | ✅ | ✅ |
|
|
104
|
-
| `File` / `FileList` | ✅ | ❌ |
|
|
105
|
-
| `Request` / `Response` / `Headers` | ✅ | ✅ |
|
|
106
|
-
| `Event` / `CustomEvent` | ✅ | ✅ | subclass
|
|
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 |
|
|
107
120
|
| `EventTarget` | ✅ | ✅ | revives as a listener-only façade: `add`/`removeEventListener` proxy to the source; you can't dispatch through it |
|
|
108
|
-
|
|
|
109
|
-
| Transfer-only host objects (`OffscreenCanvas`, `MediaStreamTrack`, `RTCDataChannel`, …) | ✅ | ❌ |
|
|
110
|
-
| `ImageBitmap`, `VideoFrame`, `AudioData` | ✅ | ❌ |
|
|
111
|
-
| `WeakMap` / `WeakSet`, other unclonables | ❌ | ❌ |
|
|
112
|
-
|
|
113
|
-
## Transports
|
|
114
|
-
|
|
115
|
-
### Worker
|
|
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
|
-
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`.
|
|
118
126
|
|
|
119
|
-
|
|
127
|
+
## Identity
|
|
120
128
|
|
|
121
|
-
`
|
|
129
|
+
`identity(value)` preserves reference equality across contexts, sending the same identity wrapped value twice results in the same object reference on the peer.
|
|
122
130
|
|
|
131
|
+
`worker.ts`
|
|
123
132
|
```ts
|
|
124
|
-
|
|
125
|
-
const iframe = document.querySelector('iframe')!
|
|
126
|
-
const remote = await expose<IframeApi>(parentApi, {
|
|
127
|
-
transport: { emit: iframe.contentWindow!, receive: window },
|
|
128
|
-
origin: 'https://app.example.com',
|
|
129
|
-
})
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
```ts
|
|
133
|
-
// iframe
|
|
134
|
-
const remote = await expose<ParentApi>(iframeApi, {
|
|
135
|
-
transport: { emit: window.parent, receive: window },
|
|
136
|
-
origin: 'https://host.example.com',
|
|
137
|
-
})
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
### SharedWorker
|
|
133
|
+
import { expose, identity } from 'osra'
|
|
141
134
|
|
|
142
|
-
|
|
135
|
+
const value = { foo: 'bar' }
|
|
136
|
+
const payload = { value, ref1: identity(value), ref2: identity(value) }
|
|
143
137
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const sharedWorker = new SharedWorker(new URL('./shared.ts', import.meta.url), { type: 'module' })
|
|
147
|
-
const remote = await expose<Api>({}, { transport: sharedWorker })
|
|
138
|
+
expose(payload, { transport: globalThis })
|
|
139
|
+
export type Payload = typeof payload
|
|
148
140
|
```
|
|
149
141
|
|
|
142
|
+
`main.ts`
|
|
150
143
|
```ts
|
|
151
|
-
|
|
144
|
+
import type { Payload } from './worker'
|
|
152
145
|
import { expose } from 'osra'
|
|
153
146
|
|
|
154
|
-
const
|
|
147
|
+
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
|
|
148
|
+
const { value, ref1, ref2 } = await expose<Payload>({}, { transport: worker })
|
|
155
149
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
})
|
|
150
|
+
value === ref1 // false
|
|
151
|
+
ref1 === ref2 // true
|
|
159
152
|
```
|
|
160
153
|
|
|
161
|
-
### WebSocket
|
|
162
|
-
|
|
163
|
-
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:
|
|
164
154
|
|
|
165
|
-
|
|
166
|
-
const socket = new WebSocket('wss://relay.example.com')
|
|
167
|
-
const remote = await expose<PeerApi>(localApi, { transport: socket })
|
|
168
|
-
```
|
|
155
|
+
## Transfer
|
|
169
156
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
A `ServiceWorker` can only emit and a `ServiceWorkerContainer` can only receive, so combine them as a custom pair:
|
|
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.
|
|
173
158
|
|
|
174
159
|
```ts
|
|
175
|
-
|
|
176
|
-
const remote = await expose<SwApi>(pageApi, {
|
|
177
|
-
transport: { emit: registration.active!, receive: navigator.serviceWorker },
|
|
178
|
-
})
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
### Web extension
|
|
182
|
-
|
|
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.
|
|
184
|
-
|
|
185
|
-
```ts
|
|
186
|
-
// content script
|
|
187
|
-
const port = browser.runtime.connect()
|
|
188
|
-
const background = await expose<BackgroundApi>(contentApi, { transport: port })
|
|
189
|
-
```
|
|
160
|
+
import { transfer } from 'osra'
|
|
190
161
|
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
browser.runtime.onConnect.addListener(port => {
|
|
194
|
-
expose(backgroundApi, { transport: port })
|
|
195
|
-
})
|
|
162
|
+
const buffer = new ArrayBuffer(16_000_000)
|
|
163
|
+
await remote.transferBuffer(transfer(buffer)) // moved - buffer is detached locally
|
|
196
164
|
```
|
|
197
165
|
|
|
198
|
-
|
|
166
|
+
### Options
|
|
199
167
|
|
|
200
|
-
|
|
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)) |
|
|
201
179
|
|
|
202
|
-
|
|
180
|
+
## Connections
|
|
203
181
|
|
|
204
|
-
|
|
205
|
-
const channel = new BroadcastChannel('app')
|
|
206
|
-
|
|
207
|
-
const remote = await expose<PeerApi>(localApi, {
|
|
208
|
-
transport: {
|
|
209
|
-
isJson: true,
|
|
210
|
-
emit: message => channel.postMessage(message),
|
|
211
|
-
receive: listener => {
|
|
212
|
-
const handler = (event: MessageEvent) => listener(event.data, {})
|
|
213
|
-
channel.addEventListener('message', handler)
|
|
214
|
-
return () => channel.removeEventListener('message', handler)
|
|
215
|
-
},
|
|
216
|
-
},
|
|
217
|
-
})
|
|
218
|
-
```
|
|
182
|
+
`expose()` is awaitable and async-iterable. Awaiting gives the first peer, iterating gives every peer as it connects:
|
|
219
183
|
|
|
220
|
-
|
|
184
|
+
```typescript
|
|
185
|
+
import { expose } from 'osra'
|
|
221
186
|
|
|
222
|
-
|
|
187
|
+
type PeerApi = { version: () => string }
|
|
223
188
|
|
|
224
|
-
|
|
189
|
+
const api = { log: (line: string) => console.log(line) }
|
|
225
190
|
|
|
226
|
-
|
|
227
|
-
|
|
191
|
+
// the first peer to connect
|
|
192
|
+
const remote = await expose<PeerApi>(api, { transport: window })
|
|
193
|
+
await remote.version()
|
|
228
194
|
|
|
229
|
-
|
|
230
|
-
expose({
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// when the remote sends back identity(saved): saved === settings
|
|
234
|
-
},
|
|
235
|
-
}, { 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
|
+
}
|
|
236
199
|
```
|
|
237
200
|
|
|
238
|
-
|
|
239
|
-
|
|
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.
|
|
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.
|
|
241
202
|
|
|
242
|
-
|
|
243
|
-
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`:
|
|
244
204
|
|
|
245
|
-
|
|
246
|
-
await
|
|
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
|
+
}
|
|
247
212
|
```
|
|
248
213
|
|
|
249
|
-
|
|
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.
|
|
250
215
|
|
|
251
|
-
|
|
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).
|
|
253
|
-
- Aborting `unregisterSignal`:
|
|
254
|
-
- the pending `expose()` rejects with the abort reason,
|
|
255
|
-
- a protocol `close` is sent to every connected peer and per-connection state is disposed,
|
|
256
|
-
- pending RPC calls reject with `'osra: connection closed'` on **both** sides (the peer receiving `close` rejects its pending calls too),
|
|
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.
|
|
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.
|
|
260
|
-
- 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:
|
|
261
217
|
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
const remote = await expose<Api>({}, { transport: worker, unregisterSignal: controller.signal })
|
|
218
|
+
```typescript
|
|
219
|
+
import { expose, context } from 'osra'
|
|
265
220
|
|
|
266
|
-
|
|
267
|
-
controller.abort(new Error('shutting down'))
|
|
268
|
-
// pending rejects with 'osra: connection closed'
|
|
221
|
+
expose(context(({ origin }) => ({ read: readFor(origin) })), { transport: window })
|
|
269
222
|
```
|
|
270
223
|
|
|
271
|
-
|
|
224
|
+
It runs before your value is sent, so calling `ctx.abort()` inside it refuses that peer outright.
|
|
272
225
|
|
|
273
226
|
## Limitations
|
|
274
227
|
|
|
275
228
|
- **Circular structures throw** a `TypeError` at send time; break the cycle or restructure.
|
|
276
|
-
- **
|
|
277
|
-
- **
|
|
278
|
-
- **Unclonable values** (`WeakMap`, `WeakSet`, exotic host objects) coerce to `{}` and fail the compile-time check.
|
|
279
|
-
- **One-shot bodies**: sending the same `Request`/`Response`/`ReadableStream` twice fails; the body locks at first send.
|
|
280
|
-
- **Generic functions collapse** in `Remote<T>`: mapped types can't preserve generic signatures.
|
|
281
|
-
- **Multi-peer**: only the first peer's value is accessible through the returned promise.
|
|
282
|
-
- **Everything is async**: sync return values still arrive as `Promise`s.
|
|
283
|
-
|
|
284
|
-
## TypeScript
|
|
285
|
-
|
|
286
|
-
`Remote<T>` is what the other side sees: functions become `(...args) => Promise<Awaited<R>>`, containers map recursively, platform objects revive as themselves.
|
|
287
|
-
|
|
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):
|
|
289
|
-
|
|
290
|
-
```ts
|
|
291
|
-
expose({ ok: async () => 1, cache: new WeakMap() }, { transport: worker })
|
|
292
|
-
// type error: Value type must resolve to a Capable, with `cache` identified as the bad field
|
|
293
|
-
```
|
|
294
|
-
|
|
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`).
|
|
296
|
-
|
|
297
|
-
## Documentation
|
|
298
|
-
|
|
299
|
-
- [API reference](./docs/API.md)
|
|
300
|
-
- [Advanced usage](./docs/ADVANCED.md)
|
|
301
|
-
|
|
302
|
-
## Development
|
|
303
|
-
|
|
304
|
-
```sh
|
|
305
|
-
npm test # build lib + test bundle, run the Playwright matrix (chromium/firefox/webkit)
|
|
306
|
-
npm run test-extension # web extension suite (needs a headed browser/display)
|
|
307
|
-
npm run check-consumer-types # validate the published .d.ts as an npm consumer sees it
|
|
308
|
-
```
|
|
309
|
-
|
|
310
|
-
## License
|
|
311
|
-
|
|
312
|
-
[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: '
|
|
50
|
-
readonly
|
|
51
|
-
readonly
|
|
52
|
-
readonly
|
|
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
|
|
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<
|
|
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
|
|
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>;
|