osra 0.4.5 → 0.5.1
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 +224 -231
- package/build/connections/bidirectional.d.ts +11 -1
- package/build/connections/index.d.ts +1 -1
- package/build/connections/utils.d.ts +2 -0
- package/build/index.d.ts +2 -2
- package/build/index.js +813 -541
- package/build/index.js.map +1 -1
- package/build/revivables/abort-signal.d.ts +3 -1
- package/build/revivables/async-iterator.d.ts +16 -0
- package/build/revivables/error.d.ts +3 -0
- package/build/revivables/event-target.d.ts +4 -3
- package/build/revivables/index.d.ts +13 -4
- package/build/revivables/json-primitives.d.ts +17 -0
- package/build/revivables/readable-stream.d.ts +5 -1
- package/build/revivables/request.d.ts +2 -0
- package/build/revivables/symbol.d.ts +3 -1
- package/build/revivables/utils.d.ts +0 -5
- package/build/types.d.ts +9 -1
- package/build/utils/index.d.ts +1 -2
- package/build/utils/replace.d.ts +0 -3
- package/build/utils/teardown.d.ts +7 -0
- package/build/utils/transport.d.ts +11 -13
- package/build/utils/type-guards.d.ts +10 -2
- package/build/utils/typed-message-channel.d.ts +1 -0
- package/package.json +11 -7
package/README.md
CHANGED
|
@@ -1,320 +1,313 @@
|
|
|
1
|
-
#
|
|
1
|
+
# osra
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/osra)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
osra is a zero-runtime-dependency TypeScript RPC library that connects two JavaScript contexts over any message channel. Both sides call `expose(value, { transport })` and each receives the other's value with live semantics: functions become callable async proxies, async generators stream with `for await`, streams keep backpressure, errors keep their subclasses, `AbortSignal`s propagate aborts. It works across Workers, SharedWorkers, windows/iframes, MessagePorts, WebSockets, web extensions, and anything else you can wrap in a custom `{ emit, receive }` pair — degrading gracefully to a JSON-only mode on text channels.
|
|
7
7
|
|
|
8
8
|
## Features
|
|
9
9
|
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
-
|
|
10
|
+
- **Zero runtime dependencies** — one ESM module
|
|
11
|
+
- **Symmetric API** — both sides call `expose()`; either side can pass functions, both can call
|
|
12
|
+
- **Deep type support** — functions, promises, async generators, `ReadableStream`/`WritableStream`, `MessagePort`, `AbortSignal`, `Error` subclasses, `Blob`/`File`, `Request`/`Response`, `Map`/`Set`, typed arrays, `BigInt`, `Symbol`, …
|
|
13
|
+
- **JSON-mode degradation** — the same value types work over text-only transports (WebSocket, extension messaging); `Date`, `Map`, typed arrays, even `NaN`/`±Infinity` survive
|
|
14
|
+
- **`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
|
|
15
17
|
|
|
16
|
-
##
|
|
18
|
+
## Install
|
|
17
19
|
|
|
18
|
-
```
|
|
20
|
+
```sh
|
|
19
21
|
npm install osra
|
|
20
22
|
```
|
|
21
23
|
|
|
22
24
|
## Quick Start
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
```ts
|
|
27
|
+
// worker.ts
|
|
28
|
+
import type { Transport } from 'osra'
|
|
25
29
|
|
|
26
|
-
**Worker file (`worker.ts`):**
|
|
27
|
-
```typescript
|
|
28
30
|
import { expose } from 'osra'
|
|
29
31
|
|
|
30
32
|
const api = {
|
|
31
|
-
// Simple function
|
|
32
33
|
add: async (a: number, b: number) => a + b,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
name: 'John Doe',
|
|
38
|
-
createdAt: new Date(),
|
|
39
|
-
// Even functions work!
|
|
40
|
-
greet: () => `Hello, I'm user ${id}`,
|
|
41
|
-
}),
|
|
42
|
-
|
|
43
|
-
// Streaming data
|
|
34
|
+
makeCounter: async () => {
|
|
35
|
+
let count = 0
|
|
36
|
+
return async () => ++count
|
|
37
|
+
},
|
|
44
38
|
streamData: async function* () {
|
|
45
|
-
for (let i = 0; i <
|
|
46
|
-
|
|
47
|
-
await new Promise(r => setTimeout(r, 100))
|
|
48
|
-
}
|
|
49
|
-
}
|
|
39
|
+
for (let i = 0; i < 3; i++) yield i
|
|
40
|
+
},
|
|
50
41
|
}
|
|
51
42
|
|
|
52
|
-
export type
|
|
43
|
+
export type Api = typeof api
|
|
53
44
|
|
|
54
|
-
|
|
55
|
-
expose(api, { transport: self })
|
|
45
|
+
expose(api, { transport: globalThis as unknown as Transport })
|
|
56
46
|
```
|
|
57
47
|
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
```ts
|
|
49
|
+
// main.ts
|
|
50
|
+
import type { Api } from './worker'
|
|
51
|
+
|
|
60
52
|
import { expose } from 'osra'
|
|
61
|
-
import type { WorkerAPI } from './worker'
|
|
62
53
|
|
|
63
|
-
const worker = new Worker('./worker.
|
|
54
|
+
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
|
|
64
55
|
|
|
65
|
-
|
|
66
|
-
const api = await expose<WorkerAPI>({}, { transport: worker })
|
|
56
|
+
const remote = await expose<Api>({}, { transport: worker })
|
|
67
57
|
|
|
68
|
-
|
|
69
|
-
const sum = await api.add(5, 3) // 8
|
|
58
|
+
await remote.add(40, 2) // 42
|
|
70
59
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const greeting = await user.greet() // "Hello, I'm user 123"
|
|
60
|
+
const counter = await remote.makeCounter()
|
|
61
|
+
await counter() // 1
|
|
62
|
+
await counter() // 2
|
|
75
63
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
console.log(value) // 0, 1, 2, ...
|
|
64
|
+
for await (const n of await remote.streamData()) {
|
|
65
|
+
console.log(n) // 0, 1, 2
|
|
79
66
|
}
|
|
80
67
|
```
|
|
81
68
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
###
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
69
|
+
Both sides call `expose()` — the returned promise resolves with the remote side's value once the handshake completes. A side that only serves (like the worker above) can ignore the returned promise.
|
|
70
|
+
|
|
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 — 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 |
|
|
82
|
+
|
|
83
|
+
If multiple peers connect over the same transport, the returned promise resolves with the **first** peer's value; later peers still connect and can call your exposed value.
|
|
84
|
+
|
|
85
|
+
## Supported types
|
|
86
|
+
|
|
87
|
+
Transports are either **structured-clone** (Worker, Window, MessagePort, SharedWorker) or **JSON** (WebSocket, web extension messaging, custom transports with `isJson: true`).
|
|
88
|
+
|
|
89
|
+
| Type | Clone | JSON | Notes |
|
|
90
|
+
|---|---|---|---|
|
|
91
|
+
| JSON primitives, plain objects, arrays | ✅ | ✅ | |
|
|
92
|
+
| `undefined`, `NaN`, `±Infinity` | ✅ | ✅ | preserved even over JSON |
|
|
93
|
+
| `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` |
|
|
96
|
+
| `Symbol` | ✅ | ✅ | `Symbol.for` registry symbols round-trip by key; others keep per-connection identity |
|
|
97
|
+
| `RegExp` | ✅ | ❌ | |
|
|
98
|
+
| `SharedArrayBuffer` | ✅ | ❌ | shared memory across the contexts |
|
|
99
|
+
| Function | ✅ | ✅ | becomes `(...args) => Promise<result>`; arguments and results recurse through the same boxing |
|
|
100
|
+
| `Promise` | ✅ | ✅ | |
|
|
101
|
+
| 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 |
|
|
105
|
+
| `AbortSignal` | ✅ | ✅ | abort and reason propagate |
|
|
106
|
+
| `Blob` / `File` | ✅ | ✅ | revive as `Promise<Blob>` / `Promise<File>` (bytes fetched async) |
|
|
107
|
+
| `Request` / `Response` / `Headers` | ✅ | ✅ | streamed bodies; `Request.signal` propagates; `Response.url`/`redirected` restored; opaque status-0 revives as `Response.error()` |
|
|
108
|
+
| `Event` / `CustomEvent` | ✅ | ✅ | subclass fields beyond `detail` are dropped |
|
|
109
|
+
| `EventTarget` | ✅ | ✅ | revives as a listener-only façade — `add`/`removeEventListener` proxy to the source; you can't dispatch through it |
|
|
110
|
+
| Other structured-clonables (`FileList`, `ImageData`, `DOMRect`, `CryptoKey`, …) | ✅ | ❌ | pass through structured clone untouched |
|
|
111
|
+
| Transfer-only host objects (`OffscreenCanvas`, `MediaStreamTrack`, `RTCDataChannel`, …) | ✅ | ❌ | always moved to the peer |
|
|
112
|
+
| `ImageBitmap`, `VideoFrame`, `AudioData` | ✅ | ❌ | copied by structured clone; wrap in `transfer()` to move |
|
|
113
|
+
| `WeakMap` / `WeakSet`, other unclonables | ❌ | ❌ | coerce to `{}` at runtime, rejected at compile time |
|
|
114
|
+
|
|
115
|
+
## Transports
|
|
116
|
+
|
|
117
|
+
### Worker
|
|
118
|
+
|
|
119
|
+
Pass the `Worker` on the page side and `globalThis` (the `DedicatedWorkerGlobalScope`) inside the worker — see [Quick Start](#quick-start). The worker scope is detected at runtime but isn't part of the `Transport` type union, so cast it: `globalThis as unknown as Transport`.
|
|
120
|
+
|
|
121
|
+
### Window ↔ iframe
|
|
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:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
// parent
|
|
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',
|
|
101
131
|
})
|
|
102
|
-
|
|
103
|
-
// Child window (child.html)
|
|
104
|
-
const childAPI = {
|
|
105
|
-
initialize: async () => {
|
|
106
|
-
console.log('Child initialized!')
|
|
107
|
-
return true
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
expose(childAPI, { transport: window.parent })
|
|
112
132
|
```
|
|
113
133
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const connections = new Set<string>()
|
|
121
|
-
|
|
122
|
-
const api = {
|
|
123
|
-
connect: async (clientId: string) => {
|
|
124
|
-
connections.add(clientId)
|
|
125
|
-
return {
|
|
126
|
-
broadcast: async (message: string) => {
|
|
127
|
-
// Broadcast to all connected clients
|
|
128
|
-
console.log(`${clientId} broadcasts: ${message}`)
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
self.addEventListener('connect', (event) => {
|
|
135
|
-
const port = event.ports[0]
|
|
136
|
-
expose(api, { transport: port })
|
|
134
|
+
```ts
|
|
135
|
+
// iframe
|
|
136
|
+
const remote = await expose<ParentApi>(iframeApi, {
|
|
137
|
+
transport: { emit: window.parent, receive: window },
|
|
138
|
+
origin: 'https://host.example.com',
|
|
137
139
|
})
|
|
138
|
-
|
|
139
|
-
// Client
|
|
140
|
-
const sharedWorker = new SharedWorker('./shared-worker.js')
|
|
141
|
-
const api = await expose<SharedWorkerAPI>({}, { transport: sharedWorker })
|
|
142
|
-
const connection = await api.connect('client-1')
|
|
143
|
-
await connection.broadcast('Hello everyone!')
|
|
144
140
|
```
|
|
145
141
|
|
|
146
|
-
###
|
|
147
|
-
|
|
148
|
-
```typescript
|
|
149
|
-
// Background script
|
|
150
|
-
import { expose } from 'osra'
|
|
151
|
-
|
|
152
|
-
const api = {
|
|
153
|
-
fetchData: async (url: string) => {
|
|
154
|
-
const response = await fetch(url)
|
|
155
|
-
return response.json()
|
|
156
|
-
}
|
|
157
|
-
}
|
|
142
|
+
### SharedWorker
|
|
158
143
|
|
|
159
|
-
|
|
144
|
+
Pass the `SharedWorker` instance directly on the page side — osra rides its `.port` internally. Inside the worker, expose per connected port:
|
|
160
145
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
146
|
+
```ts
|
|
147
|
+
// page
|
|
148
|
+
const sharedWorker = new SharedWorker(new URL('./shared.ts', import.meta.url), { type: 'module' })
|
|
149
|
+
const remote = await expose<Api>({}, { transport: sharedWorker })
|
|
164
150
|
```
|
|
165
151
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
```typescript
|
|
152
|
+
```ts
|
|
153
|
+
// shared.ts
|
|
169
154
|
import { expose } from 'osra'
|
|
170
155
|
|
|
171
|
-
|
|
172
|
-
const customTransport = {
|
|
173
|
-
emit: (message: any, transferables?: Transferable[]) => {
|
|
174
|
-
// Send message through your custom channel
|
|
175
|
-
myCustomChannel.send(message, transferables)
|
|
176
|
-
},
|
|
177
|
-
receive: (listener: (message: any) => void) => {
|
|
178
|
-
// Listen for messages from your custom channel
|
|
179
|
-
myCustomChannel.on('message', listener)
|
|
180
|
-
|
|
181
|
-
// Return cleanup function
|
|
182
|
-
return () => myCustomChannel.off('message', listener)
|
|
183
|
-
}
|
|
184
|
-
}
|
|
156
|
+
const api = { add: async (a: number, b: number) => a + b }
|
|
185
157
|
|
|
186
|
-
|
|
158
|
+
globalThis.addEventListener('connect', event => {
|
|
159
|
+
for (const port of (event as MessageEvent).ports) expose(api, { transport: port })
|
|
160
|
+
})
|
|
187
161
|
```
|
|
188
162
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
Osra automatically handles serialization/deserialization of:
|
|
163
|
+
### WebSocket
|
|
192
164
|
|
|
193
|
-
|
|
194
|
-
- **Objects & Arrays**: Including nested structures
|
|
195
|
-
- **Built-in Objects**: `Date`, `RegExp`, `Map`, `Set`, `Error`
|
|
196
|
-
- **Binary Data**: `ArrayBuffer`, `TypedArray`, `Blob`, `File`
|
|
197
|
-
- **Functions**: Callable across contexts with full async support
|
|
198
|
-
- **Promises**: Seamlessly await remote promises
|
|
199
|
-
- **Streams**: `ReadableStream` support (WritableStream coming soon)
|
|
200
|
-
- **Transferables**: `MessagePort`, `ImageBitmap`, `OffscreenCanvas`
|
|
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:
|
|
201
166
|
|
|
202
|
-
|
|
167
|
+
```ts
|
|
168
|
+
const socket = new WebSocket('wss://relay.example.com')
|
|
169
|
+
const remote = await expose<PeerApi>(localApi, { transport: socket })
|
|
170
|
+
```
|
|
203
171
|
|
|
204
|
-
###
|
|
172
|
+
### Service worker
|
|
205
173
|
|
|
206
|
-
|
|
174
|
+
A `ServiceWorker` can only emit and a `ServiceWorkerContainer` can only receive — combine them as a custom pair:
|
|
207
175
|
|
|
208
|
-
|
|
176
|
+
```ts
|
|
177
|
+
const registration = await navigator.serviceWorker.ready
|
|
178
|
+
const remote = await expose<SwApi>(pageApi, {
|
|
179
|
+
transport: { emit: registration.active!, receive: navigator.serviceWorker },
|
|
180
|
+
})
|
|
181
|
+
```
|
|
209
182
|
|
|
210
|
-
|
|
211
|
-
- `options`: Configuration object
|
|
212
|
-
- `transport`: The transport to use (Worker, Window, MessagePort, etc.)
|
|
213
|
-
- `name?`: Optional name for this endpoint (default: random UUID)
|
|
214
|
-
- `remoteName?`: Name of the remote endpoint to connect to
|
|
215
|
-
- `key?`: Optional key for additional security
|
|
216
|
-
- `origin?`: Origin restriction for Window communication
|
|
217
|
-
- `unregisterSignal?`: AbortSignal to clean up the connection
|
|
183
|
+
### Web extension
|
|
218
184
|
|
|
219
|
-
|
|
185
|
+
JSON mode. `runtime.Port`, the runtime itself (`sendMessage`/`onMessage`), `onConnect`, and `onMessage` are all accepted:
|
|
220
186
|
|
|
221
|
-
|
|
187
|
+
```ts
|
|
188
|
+
// content script
|
|
189
|
+
const port = browser.runtime.connect()
|
|
190
|
+
const background = await expose<BackgroundApi>(contentApi, { transport: port })
|
|
191
|
+
```
|
|
222
192
|
|
|
223
|
-
|
|
193
|
+
```ts
|
|
194
|
+
// background
|
|
195
|
+
browser.runtime.onConnect.addListener(port => {
|
|
196
|
+
expose(backgroundApi, { transport: port })
|
|
197
|
+
})
|
|
198
|
+
```
|
|
224
199
|
|
|
225
|
-
|
|
226
|
-
sender after an RPC. When you want to hand off ownership instead (large
|
|
227
|
-
uploads, one-shot buffers, streams you won't read locally), wrap the value
|
|
228
|
-
in `transfer()`:
|
|
200
|
+
If you accept `onConnectExternal`/`onMessageExternal`, validate senders yourself — the `MessageContext` passed to custom receive listeners exposes `sender`.
|
|
229
201
|
|
|
230
|
-
|
|
231
|
-
import { expose, transfer } from 'osra'
|
|
202
|
+
### Custom transports
|
|
232
203
|
|
|
233
|
-
|
|
204
|
+
Any plain object with `emit` and `receive` works. Each may be a platform transport or a function; a function `receive` may return an unsubscribe callback. Set `isJson: true` when the channel can't carry transferables:
|
|
234
205
|
|
|
235
|
-
|
|
236
|
-
|
|
206
|
+
```ts
|
|
207
|
+
const channel = new BroadcastChannel('app')
|
|
237
208
|
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
})
|
|
240
220
|
```
|
|
241
221
|
|
|
242
|
-
`
|
|
243
|
-
streams, `ImageBitmap`, and `OffscreenCanvas`. It's idempotent and a no-op
|
|
244
|
-
for primitives and plain objects. Must-transfer types (`MessagePort`,
|
|
245
|
-
streams, `OffscreenCanvas`) are always moved regardless of the wrapper —
|
|
246
|
-
structured clone can't copy them.
|
|
247
|
-
|
|
248
|
-
## Protocol Modes
|
|
222
|
+
Custom transports **must be plain objects** — prototype-based objects (e.g. Node `EventEmitter`s) with `emit` methods are deliberately not detected as custom transports.
|
|
249
223
|
|
|
250
|
-
|
|
224
|
+
## `identity()`
|
|
251
225
|
|
|
252
|
-
|
|
226
|
+
`identity(value)` preserves reference identity across the connection: sending the same wrapped value twice revives as the same object on the peer, and when the peer wraps the revived object in `identity()` and sends it back, you receive your original reference (`===`). Without it, every send produces an independent copy — including the return trip: a revived value passed back *bare* arrives as a fresh copy, so the returning side must re-wrap it.
|
|
253
227
|
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
const remoteAPI = await expose<RemoteAPI>(localAPI, { transport })
|
|
228
|
+
```ts
|
|
229
|
+
import { expose, identity } from 'osra'
|
|
257
230
|
|
|
258
|
-
|
|
259
|
-
|
|
231
|
+
const settings = { theme: 'dark' }
|
|
232
|
+
expose({
|
|
233
|
+
getSettings: async () => identity(settings),
|
|
234
|
+
saveSettings: async (saved: typeof settings) => {
|
|
235
|
+
// when the remote sends back identity(saved): saved === settings
|
|
236
|
+
},
|
|
237
|
+
}, { transport: worker })
|
|
260
238
|
```
|
|
261
239
|
|
|
262
|
-
|
|
240
|
+
## `transfer()`
|
|
263
241
|
|
|
264
|
-
|
|
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.
|
|
265
243
|
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
expose(api, { transport })
|
|
244
|
+
```ts
|
|
245
|
+
import { transfer } from 'osra'
|
|
269
246
|
|
|
270
|
-
|
|
271
|
-
|
|
247
|
+
const pixels = new ArrayBuffer(16_000_000)
|
|
248
|
+
await remote.render(transfer(pixels)) // moved — pixels is detached locally
|
|
272
249
|
```
|
|
273
250
|
|
|
274
|
-
##
|
|
251
|
+
## Error handling & lifecycle
|
|
252
|
+
|
|
253
|
+
- Remote functions that throw reject the caller's promise with the revived error, subclass and all.
|
|
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.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
const controller = new AbortController()
|
|
265
|
+
const remote = await expose<Api>({}, { transport: worker, unregisterSignal: controller.signal })
|
|
266
|
+
|
|
267
|
+
const pending = remote.slowCall()
|
|
268
|
+
controller.abort(new Error('shutting down'))
|
|
269
|
+
// pending rejects with 'osra: connection closed'
|
|
270
|
+
```
|
|
275
271
|
|
|
276
|
-
|
|
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.
|
|
277
273
|
|
|
278
|
-
|
|
279
|
-
MessagePorts, and any custom transport without `isJson: true`. Uses
|
|
280
|
-
structured clone natively and moves transferables when you opt in with
|
|
281
|
-
`transfer()`.
|
|
282
|
-
- **JSON mode** — WebSockets, browser extension runtime/port APIs, and any
|
|
283
|
-
custom transport flagged with `isJson: true`. Complex types (Functions,
|
|
284
|
-
Promises, Dates, Errors, TypedArrays, streams, …) still work: the
|
|
285
|
-
box/reviver system serializes them into JSON-safe representations and
|
|
286
|
-
revives them on the other side.
|
|
274
|
+
## Limitations
|
|
287
275
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
276
|
+
- **Circular structures throw** a `TypeError` at send time — break the cycle or restructure.
|
|
277
|
+
- **Shared references duplicate**: two fields pointing at the same object arrive as two copies unless wrapped with `identity()`.
|
|
278
|
+
- **Classes/prototypes are not preserved** — values cross as plain data; a class instance's methods are not proxied. Expose plain objects and functions.
|
|
279
|
+
- **Unclonable values** (`WeakMap`, `WeakSet`, exotic host objects) coerce to `{}` — and fail the compile-time check.
|
|
280
|
+
- **One-shot bodies**: sending the same `Request`/`Response`/`ReadableStream` twice fails — the body locks at first send.
|
|
281
|
+
- **Generic functions collapse** in `Remote<T>` — mapped types can't preserve generic signatures.
|
|
282
|
+
- **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.
|
|
291
284
|
|
|
292
|
-
##
|
|
285
|
+
## TypeScript
|
|
293
286
|
|
|
294
|
-
|
|
295
|
-
2. **Batch Operations**: Group multiple calls when possible
|
|
296
|
-
3. **Stream Large Datasets**: Use async generators for large data sets
|
|
297
|
-
4. **Reuse Connections**: Keep connections alive for multiple operations
|
|
287
|
+
`Remote<T>` is what the other side sees: functions become `(...args) => Promise<Awaited<R>>`, `Blob` becomes `Promise<Blob>`, containers map recursively, platform objects revive as themselves.
|
|
298
288
|
|
|
299
|
-
|
|
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:
|
|
300
290
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
+
```
|
|
305
295
|
|
|
306
|
-
|
|
296
|
+
The published declarations require **TypeScript >= 5.9** with `strict` mode.
|
|
307
297
|
|
|
308
|
-
|
|
298
|
+
## Documentation
|
|
309
299
|
|
|
310
|
-
|
|
300
|
+
- [API reference](./docs/API.md)
|
|
301
|
+
- [Advanced usage](./docs/ADVANCED.md)
|
|
311
302
|
|
|
312
|
-
|
|
303
|
+
## Development
|
|
313
304
|
|
|
314
|
-
|
|
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
|
|
315
312
|
|
|
316
|
-
|
|
317
|
-
- [ ] Custom revivable plugins for user-defined types
|
|
318
|
-
- [ ] Performance optimizations for large object graphs
|
|
319
|
-
- [ ] Better error handling and debugging tools
|
|
320
|
-
- [ ] WebRTC DataChannel transport
|
|
313
|
+
[MIT](./LICENSE)
|
|
@@ -23,7 +23,17 @@ export type ConnectionRevivableContext<TModules extends readonly RevivableModule
|
|
|
23
23
|
revivableModules: TModules;
|
|
24
24
|
eventTarget: MessageEventTarget<TModules>;
|
|
25
25
|
};
|
|
26
|
-
export declare const startBidirectionalConnection: <TModules extends readonly RevivableModule[] = readonly [typeof import("../revivables/transfer.js"), typeof import("../revivables/identity.js"), typeof import("../revivables/array-buffer.js"), typeof import("../revivables/date.js"), typeof import("../revivables/headers.js"), typeof import("../revivables/error.js"), typeof import("../revivables/typed-array.js"), typeof import("../revivables/blob.js"), typeof import("../revivables/promise.js"), typeof import("../revivables/function.js"), typeof import("../revivables/message-port.js"), typeof import("../revivables/readable-stream.js"), typeof import("../revivables/writable-stream.js"), typeof import("../revivables/abort-signal.js"), typeof import("../revivables/response.js"), typeof import("../revivables/request.js"), typeof import("../revivables/map.js"), typeof import("../revivables/set.js"), typeof import("../revivables/bigint.js"), typeof import("../revivables/symbol.js"), typeof import("../revivables/event.js"), {
|
|
26
|
+
export declare const startBidirectionalConnection: <TModules extends readonly RevivableModule[] = readonly [typeof import("../revivables/transfer.js"), typeof import("../revivables/identity.js"), typeof import("../revivables/array-buffer.js"), typeof import("../revivables/date.js"), typeof import("../revivables/headers.js"), typeof import("../revivables/error.js"), typeof import("../revivables/typed-array.js"), typeof import("../revivables/blob.js"), typeof import("../revivables/promise.js"), typeof import("../revivables/function.js"), typeof import("../revivables/message-port.js"), typeof import("../revivables/readable-stream.js"), typeof import("../revivables/writable-stream.js"), typeof import("../revivables/abort-signal.js"), typeof import("../revivables/response.js"), typeof import("../revivables/request.js"), typeof import("../revivables/map.js"), typeof import("../revivables/set.js"), typeof import("../revivables/bigint.js"), typeof import("../revivables/symbol.js"), typeof import("../revivables/event.js"), typeof import("../revivables/async-iterator.js"), {
|
|
27
|
+
readonly type: 'nonFiniteNumber';
|
|
28
|
+
readonly isType: (value: unknown) => value is number;
|
|
29
|
+
readonly box: (value: number, context: import("../index.js").RevivableContext<any>) => import("../revivables/json-primitives.js").BoxedNonFiniteNumber | number;
|
|
30
|
+
readonly revive: (value: import("../revivables/json-primitives.js").BoxedNonFiniteNumber, _context: import("../index.js").RevivableContext<any>) => number;
|
|
31
|
+
}, {
|
|
32
|
+
readonly type: 'undefined';
|
|
33
|
+
readonly isType: (value: unknown) => value is undefined;
|
|
34
|
+
readonly box: (value: undefined, context: import("../index.js").RevivableContext<any>) => import("../revivables/json-primitives.js").BoxedUndefined | undefined;
|
|
35
|
+
readonly revive: (_value: import("../revivables/json-primitives.js").BoxedUndefined, _context: import("../index.js").RevivableContext<any>) => undefined;
|
|
36
|
+
}, {
|
|
27
37
|
readonly type: 'clonable';
|
|
28
38
|
readonly capableOnly: true;
|
|
29
39
|
readonly isType: (value: unknown) => value is import("../revivables/fallbacks.js").Clonable;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DefaultRevivableModules, RevivableModule } from '../revivables/index.js';
|
|
2
2
|
import type { ConnectionContext as BidirectionalConnectionContext } from './bidirectional.js';
|
|
3
|
-
import type { ProtocolContext, StartConnectionsOptions } from '../utils/index.js';
|
|
4
3
|
import type { Capable } from '../types.js';
|
|
4
|
+
import type { ProtocolContext, StartConnectionsOptions } from './utils.js';
|
|
5
5
|
import * as bidirectional from './bidirectional.js';
|
|
6
6
|
export * from './bidirectional.js';
|
|
7
7
|
export * from './relay.js';
|
|
@@ -23,7 +23,9 @@ export type ProtocolContext<TModules extends readonly RevivableModule[] = Defaul
|
|
|
23
23
|
sendMessage: (message: MessageVariant) => void;
|
|
24
24
|
protocolEventTarget: ProtocolEventTarget<TModules>;
|
|
25
25
|
resolveRemoteValue: (value: Capable<TModules>) => void;
|
|
26
|
+
rejectRemoteValue: (error: unknown) => void;
|
|
26
27
|
createConnectionEventTarget: () => TypedEventTarget<MessageEventMap<TModules>>;
|
|
28
|
+
unregisterSignal?: AbortSignal;
|
|
27
29
|
};
|
|
28
30
|
export type StartConnectionsOptions<TModules extends readonly RevivableModule[] = DefaultRevivableModules> = {
|
|
29
31
|
transport: Transport;
|
package/build/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Capable } from './types.js';
|
|
1
|
+
import type { Capable, Remote } from './types.js';
|
|
2
2
|
import type { DefaultRevivableModules, RevivableContext } from './revivables/index.js';
|
|
3
3
|
import type { RevivableModule } from './revivables/index.js';
|
|
4
4
|
import type { StartConnectionsOptions } from './connections/utils.js';
|
|
@@ -22,4 +22,4 @@ type CapableCheck<T, TModules extends readonly RevivableModule[] = DefaultReviva
|
|
|
22
22
|
};
|
|
23
23
|
export declare const expose: <T = unknown, const TModules extends readonly RevivableModule[] = DefaultRevivableModules, const TTransport extends Transport = Transport, const TValue = Capable<TModules, ContextOf<TTransport>>>(value: CapableCheck<TValue, TModules, ContextOf<TTransport>>, options: StartConnectionsOptions<TModules> & {
|
|
24
24
|
transport: TTransport;
|
|
25
|
-
}) => Promise<T
|
|
25
|
+
}) => Promise<Remote<T>>;
|