osra 0.6.2 → 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 -231
- 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 +683 -541
- 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 +13 -7
- package/build/utils/transport.d.ts +17 -0
- package/package.json +14 -7
package/README.md
CHANGED
|
@@ -1,86 +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
|
-
|
|
8
|
-
## Features
|
|
9
|
-
|
|
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, `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
|
|
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
|
|
28
|
-
import type { Transport } from 'osra'
|
|
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.
|
|
29
8
|
|
|
9
|
+
`worker.ts`
|
|
10
|
+
```typescript
|
|
30
11
|
import { expose } from 'osra'
|
|
31
12
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
13
|
+
const payload = {
|
|
14
|
+
hash: crypto.getRandomValues(new Uint8Array(10)),
|
|
15
|
+
add: (a: number, b: number) => a + b,
|
|
16
|
+
makeCounter: () => {
|
|
35
17
|
let count = 0
|
|
36
|
-
return
|
|
37
|
-
},
|
|
38
|
-
streamData: async function* () {
|
|
39
|
-
for (let i = 0; i < 3; i++) yield i
|
|
18
|
+
return () => ++count
|
|
40
19
|
},
|
|
20
|
+
streamData: async function* () { yield* [0, 1, 2] }
|
|
41
21
|
}
|
|
22
|
+
export type Payload = typeof payload
|
|
42
23
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
expose(api, { transport: globalThis as unknown as Transport })
|
|
24
|
+
expose(payload, { transport: globalThis })
|
|
46
25
|
```
|
|
47
26
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
import type {
|
|
51
|
-
|
|
27
|
+
`main.ts`
|
|
28
|
+
```typescript
|
|
29
|
+
import type { Payload } from './worker'
|
|
52
30
|
import { expose } from 'osra'
|
|
53
31
|
|
|
54
32
|
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
|
|
55
33
|
|
|
56
|
-
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
|
|
57
42
|
|
|
58
|
-
await
|
|
43
|
+
await add(40, 2) // 42
|
|
59
44
|
|
|
60
|
-
const counter = await
|
|
45
|
+
const counter = await makeCounter()
|
|
61
46
|
await counter() // 1
|
|
62
47
|
await counter() // 2
|
|
63
48
|
|
|
64
|
-
for await (const n of await
|
|
49
|
+
for await (const n of await streamData()) {
|
|
65
50
|
console.log(n) // 0, 1, 2
|
|
66
51
|
}
|
|
67
52
|
```
|
|
68
53
|
|
|
69
|
-
|
|
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 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` |
|
|
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 |
|
|
54
|
+
## Features
|
|
82
55
|
|
|
83
|
-
|
|
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`)
|
|
84
95
|
|
|
85
96
|
## Supported types
|
|
86
97
|
|
|
@@ -89,225 +100,131 @@ Transports are either **structured-clone** (Worker, Window, MessagePort, SharedW
|
|
|
89
100
|
| Type | Clone | JSON | Notes |
|
|
90
101
|
|---|---|---|---|
|
|
91
102
|
| JSON primitives, plain objects, arrays | ✅ | ✅ | |
|
|
92
|
-
| `undefined`, `NaN`, `±Infinity` | ✅ | ✅ |
|
|
103
|
+
| `undefined`, `NaN`, `±Infinity` | ✅ | ✅ | |
|
|
93
104
|
| `Date`, `BigInt`, `Map`, `Set` | ✅ | ✅ | |
|
|
94
|
-
|
|
|
95
|
-
| `Error` + subclasses | ✅ | ✅ |
|
|
96
|
-
| `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) |
|
|
97
108
|
| `RegExp` | ✅ | ❌ | |
|
|
98
|
-
| `SharedArrayBuffer` | ✅ | ❌ |
|
|
99
|
-
| Function | ✅ | ✅ | becomes `(...args) => Promise<result>`; arguments and results
|
|
109
|
+
| `SharedArrayBuffer` | ✅ | ❌ | |
|
|
110
|
+
| Function | ✅ | ✅ | becomes `(...args) => Promise<result>`; arguments and results are properly handled too |
|
|
100
111
|
| `Promise` | ✅ | ✅ | |
|
|
101
|
-
| Async generators / async iterables | ✅ | ✅ |
|
|
102
|
-
| `ReadableStream` | ✅ | ✅ |
|
|
103
|
-
| `WritableStream` | ✅ | ✅ |
|
|
104
|
-
| `MessagePort` | ✅ | ✅ |
|
|
105
|
-
| `AbortSignal` | ✅ | ✅ |
|
|
106
|
-
| `File` / `FileList` | ✅ | ❌ |
|
|
107
|
-
| `Request` / `Response` / `Headers` | ✅ | ✅ |
|
|
108
|
-
| `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 |
|
|
109
120
|
| `EventTarget` | ✅ | ✅ | revives as a listener-only façade: `add`/`removeEventListener` proxy to the source; you can't dispatch through it |
|
|
110
|
-
|
|
|
111
|
-
| Transfer-only host objects (`OffscreenCanvas`, `MediaStreamTrack`, `RTCDataChannel`, …) | ✅ | ❌ |
|
|
112
|
-
| `ImageBitmap`, `VideoFrame`, `AudioData` | ✅ | ❌ |
|
|
113
|
-
| `WeakMap` / `WeakSet`, other unclonables | ❌ | ❌ |
|
|
114
|
-
|
|
115
|
-
## Transports
|
|
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
|
-
### Worker
|
|
118
126
|
|
|
119
|
-
|
|
127
|
+
## Identity
|
|
120
128
|
|
|
121
|
-
|
|
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:
|
|
129
|
+
`identity(value)` preserves reference equality across contexts, sending the same identity wrapped value twice results in the same object reference on the peer.
|
|
124
130
|
|
|
131
|
+
`worker.ts`
|
|
125
132
|
```ts
|
|
126
|
-
|
|
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',
|
|
131
|
-
})
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
```ts
|
|
135
|
-
// iframe
|
|
136
|
-
const remote = await expose<ParentApi>(iframeApi, {
|
|
137
|
-
transport: { emit: window.parent, receive: window },
|
|
138
|
-
origin: 'https://host.example.com',
|
|
139
|
-
})
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
### SharedWorker
|
|
133
|
+
import { expose, identity } from 'osra'
|
|
143
134
|
|
|
144
|
-
|
|
135
|
+
const value = { foo: 'bar' }
|
|
136
|
+
const payload = { value, ref1: identity(value), ref2: identity(value) }
|
|
145
137
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const sharedWorker = new SharedWorker(new URL('./shared.ts', import.meta.url), { type: 'module' })
|
|
149
|
-
const remote = await expose<Api>({}, { transport: sharedWorker })
|
|
138
|
+
expose(payload, { transport: globalThis })
|
|
139
|
+
export type Payload = typeof payload
|
|
150
140
|
```
|
|
151
141
|
|
|
142
|
+
`main.ts`
|
|
152
143
|
```ts
|
|
153
|
-
|
|
144
|
+
import type { Payload } from './worker'
|
|
154
145
|
import { expose } from 'osra'
|
|
155
146
|
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
globalThis.addEventListener('connect', event => {
|
|
159
|
-
for (const port of (event as MessageEvent).ports) expose(api, { transport: port })
|
|
160
|
-
})
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
### WebSocket
|
|
164
|
-
|
|
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:
|
|
147
|
+
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
|
|
148
|
+
const { value, ref1, ref2 } = await expose<Payload>({}, { transport: worker })
|
|
166
149
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const remote = await expose<PeerApi>(localApi, { transport: socket })
|
|
150
|
+
value === ref1 // false
|
|
151
|
+
ref1 === ref2 // true
|
|
170
152
|
```
|
|
171
153
|
|
|
172
|
-
### Service worker
|
|
173
|
-
|
|
174
|
-
A `ServiceWorker` can only emit and a `ServiceWorkerContainer` can only receive, so combine them as a custom pair:
|
|
175
154
|
|
|
176
|
-
|
|
177
|
-
const registration = await navigator.serviceWorker.ready
|
|
178
|
-
const remote = await expose<SwApi>(pageApi, {
|
|
179
|
-
transport: { emit: registration.active!, receive: navigator.serviceWorker },
|
|
180
|
-
})
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
### Web extension
|
|
155
|
+
## Transfer
|
|
184
156
|
|
|
185
|
-
|
|
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.
|
|
186
158
|
|
|
187
159
|
```ts
|
|
188
|
-
|
|
189
|
-
const port = browser.runtime.connect()
|
|
190
|
-
const background = await expose<BackgroundApi>(contentApi, { transport: port })
|
|
191
|
-
```
|
|
160
|
+
import { transfer } from 'osra'
|
|
192
161
|
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
browser.runtime.onConnect.addListener(port => {
|
|
196
|
-
expose(backgroundApi, { transport: port })
|
|
197
|
-
})
|
|
162
|
+
const buffer = new ArrayBuffer(16_000_000)
|
|
163
|
+
await remote.transferBuffer(transfer(buffer)) // moved - buffer is detached locally
|
|
198
164
|
```
|
|
199
165
|
|
|
200
|
-
|
|
166
|
+
### Options
|
|
201
167
|
|
|
202
|
-
|
|
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)) |
|
|
203
179
|
|
|
204
|
-
|
|
180
|
+
## Connections
|
|
205
181
|
|
|
206
|
-
|
|
207
|
-
const channel = new BroadcastChannel('app')
|
|
208
|
-
|
|
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
|
-
})
|
|
220
|
-
```
|
|
182
|
+
`expose()` is awaitable and async-iterable. Awaiting gives the first peer, iterating gives every peer as it connects:
|
|
221
183
|
|
|
222
|
-
|
|
184
|
+
```typescript
|
|
185
|
+
import { expose } from 'osra'
|
|
223
186
|
|
|
224
|
-
|
|
187
|
+
type PeerApi = { version: () => string }
|
|
225
188
|
|
|
226
|
-
|
|
189
|
+
const api = { log: (line: string) => console.log(line) }
|
|
227
190
|
|
|
228
|
-
|
|
229
|
-
|
|
191
|
+
// the first peer to connect
|
|
192
|
+
const remote = await expose<PeerApi>(api, { transport: window })
|
|
193
|
+
await remote.version()
|
|
230
194
|
|
|
231
|
-
|
|
232
|
-
expose({
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
// when the remote sends back identity(saved): saved === settings
|
|
236
|
-
},
|
|
237
|
-
}, { 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
|
+
}
|
|
238
199
|
```
|
|
239
200
|
|
|
240
|
-
|
|
241
|
-
|
|
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.
|
|
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.
|
|
243
202
|
|
|
244
|
-
|
|
245
|
-
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`:
|
|
246
204
|
|
|
247
|
-
|
|
248
|
-
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
|
+
}
|
|
249
212
|
```
|
|
250
213
|
|
|
251
|
-
|
|
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.
|
|
252
215
|
|
|
253
|
-
|
|
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.
|
|
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:
|
|
262
217
|
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
const remote = await expose<Api>({}, { transport: worker, unregisterSignal: controller.signal })
|
|
218
|
+
```typescript
|
|
219
|
+
import { expose, context } from 'osra'
|
|
266
220
|
|
|
267
|
-
|
|
268
|
-
controller.abort(new Error('shutting down'))
|
|
269
|
-
// pending rejects with 'osra: connection closed'
|
|
221
|
+
expose(context(({ origin }) => ({ read: readFor(origin) })), { transport: window })
|
|
270
222
|
```
|
|
271
223
|
|
|
272
|
-
|
|
224
|
+
It runs before your value is sent, so calling `ctx.abort()` inside it refuses that peer outright.
|
|
273
225
|
|
|
274
226
|
## Limitations
|
|
275
227
|
|
|
276
228
|
- **Circular structures throw** a `TypeError` at send time; break the cycle or restructure.
|
|
277
|
-
- **
|
|
278
|
-
- **
|
|
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.
|
|
284
|
-
|
|
285
|
-
## TypeScript
|
|
286
|
-
|
|
287
|
-
`Remote<T>` is what the other side sees: functions become `(...args) => Promise<Awaited<R>>`, containers map recursively, platform objects revive as themselves.
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
295
|
-
|
|
296
|
-
The published declarations require **TypeScript >= 5.9** with `strict` mode.
|
|
297
|
-
|
|
298
|
-
## Documentation
|
|
299
|
-
|
|
300
|
-
- [API reference](./docs/API.md)
|
|
301
|
-
- [Advanced usage](./docs/ADVANCED.md)
|
|
302
|
-
|
|
303
|
-
## Development
|
|
304
|
-
|
|
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
|
|
312
|
-
|
|
313
|
-
[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>;
|