@tokamakdev/plugin 0.1.0-beta.107 → 0.1.0-beta.112
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 +59 -32
- package/package.json +7 -2
- package/src/bindings.ts +260 -0
- package/src/capabilities.d.mts +11 -0
- package/src/capabilities.mjs +8 -0
- package/src/codec.ts +15 -0
- package/src/context.ts +91 -0
- package/src/entrypoint.js +21 -8
- package/src/events.ts +40 -18
- package/src/index.ts +60 -155
- package/src/native-protocol.d.mts +24 -0
- package/src/native-protocol.mjs +77 -0
- package/src/registry.ts +28 -1
- package/src/resources.ts +101 -0
- package/src/rpc/binding.mjs +109 -0
- package/src/rpc/channel.mjs +219 -0
- package/src/rpc/client.mjs +29 -0
- package/src/rpc/endpoint.mjs +47 -0
- package/src/rpc/frames.mjs +35 -0
- package/src/rpc/http.mjs +28 -0
- package/src/rpc/streams.mjs +52 -0
- package/src/rpc/values.mjs +136 -0
- package/src/streams.ts +120 -0
- package/src/testing.ts +114 -27
- package/src/webview.ts +25 -0
- package/src/worker.ts +32 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { encode, decode, dataCloneError } from './values.mjs';
|
|
2
|
+
import { takeStream, receiveStream } from './streams.mjs';
|
|
3
|
+
import { packFrame, unpackFrame } from './frames.mjs';
|
|
4
|
+
|
|
5
|
+
function deferred() {
|
|
6
|
+
let resolve, reject;
|
|
7
|
+
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
8
|
+
promise.catch(() => {});
|
|
9
|
+
return { promise, resolve, reject };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function cancelSource(source, reason) {
|
|
13
|
+
source.cancelled = true;
|
|
14
|
+
source.cleanup ??= Promise.resolve().then(() => source.port.cancel(reason));
|
|
15
|
+
return source.cleanup;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** One invocation's values and demand-driven byte streams over a WebSocket. */
|
|
19
|
+
export function openChannel(socket) {
|
|
20
|
+
const ready = deferred(), received = deferred(), closed = deferred();
|
|
21
|
+
const outgoing = new Map(), incoming = new Map();
|
|
22
|
+
const pending = new Set();
|
|
23
|
+
let nextStream = 0, sent = false, delivered = false, completed = false, ended = false, failure;
|
|
24
|
+
socket.binaryType = 'arraybuffer';
|
|
25
|
+
|
|
26
|
+
function close(reason = new DOMException('RPC connection closed.', 'AbortError')) {
|
|
27
|
+
if (ended) return;
|
|
28
|
+
ended = true;
|
|
29
|
+
failure = reason;
|
|
30
|
+
ready.reject(reason);
|
|
31
|
+
received.reject(reason);
|
|
32
|
+
for (const port of incoming.values()) {
|
|
33
|
+
port.reading?.reject(reason);
|
|
34
|
+
port.cancelling?.reject(reason);
|
|
35
|
+
}
|
|
36
|
+
const cleanup = [...outgoing.values()].map(source => cancelSource(source, reason));
|
|
37
|
+
incoming.clear();
|
|
38
|
+
outgoing.clear();
|
|
39
|
+
socket.removeEventListener('open', onOpen);
|
|
40
|
+
socket.removeEventListener('message', onMessage);
|
|
41
|
+
socket.removeEventListener('close', onClose);
|
|
42
|
+
socket.removeEventListener('error', onClose);
|
|
43
|
+
try { socket.close(1000); } catch { /* The transport can already be closed. */ }
|
|
44
|
+
Promise.allSettled(cleanup).then(() => closed.resolve(reason));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function finish() {
|
|
48
|
+
if (completed && outgoing.size === 0 && pending.size === 0) close();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function prepare(type, id, value) {
|
|
52
|
+
if (ended) throw failure;
|
|
53
|
+
const encoded = encode(value);
|
|
54
|
+
const ids = encoded.streams.map(() => nextStream++);
|
|
55
|
+
const frame = packFrame({ type, id, data: encoded.data, streams: ids }, encoded.buffers);
|
|
56
|
+
const acquired = [];
|
|
57
|
+
try {
|
|
58
|
+
for (const stream of encoded.streams) acquired.push({ port: takeStream(stream), reading: false, cancelled: false });
|
|
59
|
+
} catch (error) {
|
|
60
|
+
for (const source of acquired) cancelSource(source, error).catch(() => {});
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
ids.forEach((stream, index) => outgoing.set(stream, acquired[index]));
|
|
64
|
+
return frame;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function transmit(frame) {
|
|
68
|
+
await ready.promise;
|
|
69
|
+
if (ended) throw failure;
|
|
70
|
+
try { await socket.send(frame); }
|
|
71
|
+
catch (error) { close(error); throw error; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function write(type, id, value) {
|
|
75
|
+
return transmit(prepare(type, id, value));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function reply(type, id, failed, value) {
|
|
79
|
+
try { return write(type, id, { failed, value }); }
|
|
80
|
+
catch { return write(type, id, { failed: true, value: dataCloneError() }); }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function remoteStream(id) {
|
|
84
|
+
if (!Number.isSafeInteger(id) || id < 0 || incoming.has(id)) throw dataCloneError();
|
|
85
|
+
const port = { reading: null, cancelling: null, done: false };
|
|
86
|
+
incoming.set(id, port);
|
|
87
|
+
return receiveStream({
|
|
88
|
+
read() {
|
|
89
|
+
if (ended) return Promise.reject(failure);
|
|
90
|
+
if (port.done || port.cancelling) return Promise.resolve({ done: true });
|
|
91
|
+
if (port.reading) throw dataCloneError();
|
|
92
|
+
port.reading = deferred();
|
|
93
|
+
write('pull', id).catch(close);
|
|
94
|
+
return port.reading.promise;
|
|
95
|
+
},
|
|
96
|
+
cancel(reason) {
|
|
97
|
+
if (ended) return Promise.reject(failure);
|
|
98
|
+
if (port.done) return Promise.resolve();
|
|
99
|
+
if (port.cancelling) return port.cancelling.promise;
|
|
100
|
+
port.cancelling = deferred();
|
|
101
|
+
port.reading?.resolve({ done: true });
|
|
102
|
+
port.reading = null;
|
|
103
|
+
try { write('cancel', id, reason).catch(close); }
|
|
104
|
+
catch (error) { close(error); }
|
|
105
|
+
return port.cancelling.promise;
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function pull(id) {
|
|
111
|
+
const source = outgoing.get(id);
|
|
112
|
+
if (!source || source.reading || source.cancelled || source.done) throw dataCloneError();
|
|
113
|
+
source.reading = true;
|
|
114
|
+
let value, failed = false;
|
|
115
|
+
try { value = await source.port.read(); }
|
|
116
|
+
catch (error) { value = error; failed = true; }
|
|
117
|
+
if (source.cancelled || ended) return;
|
|
118
|
+
source.reading = false;
|
|
119
|
+
source.done = failed || value.done;
|
|
120
|
+
await reply('read', id, failed, value);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function cancel(id, reason) {
|
|
124
|
+
const source = outgoing.get(id);
|
|
125
|
+
if (source?.done) {
|
|
126
|
+
source.cancelled = true;
|
|
127
|
+
await write('cancelled', id, { terminal: true });
|
|
128
|
+
outgoing.delete(id);
|
|
129
|
+
finish();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
let value, failed = false;
|
|
133
|
+
try { if (source) await cancelSource(source, reason); }
|
|
134
|
+
catch (error) { value = error; failed = true; }
|
|
135
|
+
if (ended) return;
|
|
136
|
+
await reply('cancelled', id, failed, value);
|
|
137
|
+
outgoing.delete(id);
|
|
138
|
+
finish();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function release(id) {
|
|
142
|
+
const source = outgoing.get(id);
|
|
143
|
+
if (!source?.done || source.cancelled) throw dataCloneError();
|
|
144
|
+
outgoing.delete(id);
|
|
145
|
+
finish();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function settle(type, id, result) {
|
|
149
|
+
const port = incoming.get(id);
|
|
150
|
+
if (!port) throw dataCloneError();
|
|
151
|
+
if (type === 'cancelled' && result?.terminal === true) {
|
|
152
|
+
if (!port.terminal) throw dataCloneError();
|
|
153
|
+
result = port.terminal.failed ? port.terminal : { failed: false, value: undefined };
|
|
154
|
+
}
|
|
155
|
+
if (typeof result?.failed !== 'boolean') throw dataCloneError();
|
|
156
|
+
if (type === 'read' && port.cancelling) {
|
|
157
|
+
if (result.failed || result.value.done) port.terminal = result;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const waiting = type === 'read' ? port.reading : port.cancelling;
|
|
161
|
+
if (!waiting) throw dataCloneError();
|
|
162
|
+
if (result.failed) waiting.reject(result.value);
|
|
163
|
+
else waiting.resolve(result.value);
|
|
164
|
+
port.reading = null;
|
|
165
|
+
if (type === 'cancelled' || result.failed || result.value.done) {
|
|
166
|
+
port.done = true;
|
|
167
|
+
const acknowledgement = type === 'read' ? write('release', id) : Promise.resolve();
|
|
168
|
+
acknowledgement.then(() => { incoming.delete(id); finish(); }).catch(close);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function onMessage(event) {
|
|
173
|
+
try {
|
|
174
|
+
const frame = unpackFrame(event.data);
|
|
175
|
+
const streams = frame.streams.map(remoteStream);
|
|
176
|
+
const value = decode({ ...frame, streams });
|
|
177
|
+
switch (frame.type) {
|
|
178
|
+
case 'value':
|
|
179
|
+
if (delivered) throw dataCloneError();
|
|
180
|
+
delivered = true;
|
|
181
|
+
received.resolve(value);
|
|
182
|
+
break;
|
|
183
|
+
case 'pull': pull(frame.id).catch(close); break;
|
|
184
|
+
case 'cancel': cancel(frame.id, value).catch(close); break;
|
|
185
|
+
case 'release': release(frame.id); break;
|
|
186
|
+
case 'read': case 'cancelled': settle(frame.type, frame.id, value); break;
|
|
187
|
+
default: throw dataCloneError();
|
|
188
|
+
}
|
|
189
|
+
} catch (error) { close(error); }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function onOpen() { ready.resolve(); }
|
|
193
|
+
function onClose(event) { close(event.error); }
|
|
194
|
+
socket.addEventListener('open', onOpen);
|
|
195
|
+
socket.addEventListener('message', onMessage);
|
|
196
|
+
socket.addEventListener('close', onClose);
|
|
197
|
+
socket.addEventListener('error', onClose);
|
|
198
|
+
if (socket.readyState === 1) onOpen();
|
|
199
|
+
if (socket.readyState > 1) close();
|
|
200
|
+
return {
|
|
201
|
+
receive: received.promise,
|
|
202
|
+
closed: closed.promise,
|
|
203
|
+
close,
|
|
204
|
+
waitUntil(promise) {
|
|
205
|
+
const task = Promise.resolve(promise);
|
|
206
|
+
pending.add(task);
|
|
207
|
+
const settled = () => { pending.delete(task); finish(); };
|
|
208
|
+
task.then(settled, settled);
|
|
209
|
+
},
|
|
210
|
+
// The callee ends unused input streams after its work and result streams settle.
|
|
211
|
+
complete() { completed = true; finish(); },
|
|
212
|
+
async send(value) {
|
|
213
|
+
if (sent || completed) throw new TypeError('This RPC invocation has already sent its value.');
|
|
214
|
+
const frame = prepare('value', undefined, value);
|
|
215
|
+
sent = true;
|
|
216
|
+
await transmit(frame);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { openChannel } from './channel.mjs';
|
|
2
|
+
import { dataCloneError } from './values.mjs';
|
|
3
|
+
|
|
4
|
+
/** Invoke one operation over a socket owned by the caller. */
|
|
5
|
+
export async function invoke(socket, method, args) {
|
|
6
|
+
const channel = openChannel(socket);
|
|
7
|
+
let result;
|
|
8
|
+
try {
|
|
9
|
+
await channel.send({ method, args });
|
|
10
|
+
result = await channel.receive;
|
|
11
|
+
if (typeof result?.failed !== 'boolean') throw dataCloneError();
|
|
12
|
+
} catch (error) {
|
|
13
|
+
channel.close(error);
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
if (result.failed) throw result.value;
|
|
17
|
+
return result.value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Generate only the declared public operations, with their `capabilities`; each call owns its connection. */
|
|
21
|
+
export function createClient(methods, endpoint, capabilities) {
|
|
22
|
+
const client = Object.fromEntries(methods.map(method => [method, async (...args) => {
|
|
23
|
+
const url = new URL(endpoint, globalThis.location?.href);
|
|
24
|
+
if (url.protocol === 'http:') url.protocol = 'ws:';
|
|
25
|
+
if (url.protocol === 'https:') url.protocol = 'wss:';
|
|
26
|
+
return invoke(new WebSocket(url), method, args);
|
|
27
|
+
}]));
|
|
28
|
+
return Object.assign(client, { capabilities: Object.freeze({ ...capabilities }) });
|
|
29
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { openChannel } from './channel.mjs';
|
|
2
|
+
import { dataCloneError } from './values.mjs';
|
|
3
|
+
|
|
4
|
+
/** Upgrade a same-origin frontend request into one RPC invocation. */
|
|
5
|
+
export function rpcResponse(request, createWorker, methods, ctx) {
|
|
6
|
+
if (request.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
7
|
+
const origin = request.headers.get('origin');
|
|
8
|
+
if (origin && origin !== new URL(request.url).origin) return new Response('Forbidden', { status: 403 });
|
|
9
|
+
if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
|
|
10
|
+
return new Response('WebSocket required', { status: 426 });
|
|
11
|
+
}
|
|
12
|
+
const [client, server] = Object.values(new WebSocketPair());
|
|
13
|
+
server.accept();
|
|
14
|
+
ctx.waitUntil(serveRpc(server, createWorker, methods, ctx));
|
|
15
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Serve one invocation, retaining it until the connection's streams settle. */
|
|
19
|
+
export function serveRpc(socket, createWorker, methods, ctx) {
|
|
20
|
+
const channel = openChannel(socket);
|
|
21
|
+
const waitUntil = promise => {
|
|
22
|
+
ctx.waitUntil(promise);
|
|
23
|
+
channel.waitUntil(promise);
|
|
24
|
+
};
|
|
25
|
+
const context = new Proxy(ctx, {
|
|
26
|
+
get(target, key) {
|
|
27
|
+
if (key === 'waitUntil') return waitUntil;
|
|
28
|
+
const value = Reflect.get(target, key, target);
|
|
29
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
async function handle() {
|
|
33
|
+
try {
|
|
34
|
+
const request = await channel.receive;
|
|
35
|
+
if (!request || typeof request.method !== 'string' || !Array.isArray(request.args)) throw dataCloneError();
|
|
36
|
+
if (!methods.includes(request.method)) throw new TypeError(`Unknown RPC method: ${request.method}`);
|
|
37
|
+
const worker = createWorker(context);
|
|
38
|
+
const value = await worker[request.method](...request.args);
|
|
39
|
+
await channel.send({ failed: false, value });
|
|
40
|
+
} catch (error) {
|
|
41
|
+
try { await channel.send({ failed: true, value: error }); }
|
|
42
|
+
catch { await channel.send({ failed: true, value: dataCloneError() }); }
|
|
43
|
+
} finally { channel.complete(); }
|
|
44
|
+
}
|
|
45
|
+
handle().catch(channel.close);
|
|
46
|
+
return channel.closed;
|
|
47
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { dataCloneError } from './values.mjs';
|
|
2
|
+
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
5
|
+
|
|
6
|
+
/** A length-prefixed metadata record followed by its binary buffers. */
|
|
7
|
+
export function packFrame(metadata, buffers) {
|
|
8
|
+
const header = encoder.encode(JSON.stringify({ ...metadata, buffers: buffers.map(buffer => buffer.byteLength) }));
|
|
9
|
+
const frame = new Uint8Array(4 + header.byteLength + buffers.reduce((size, buffer) => size + buffer.byteLength, 0));
|
|
10
|
+
new DataView(frame.buffer).setUint32(0, header.byteLength);
|
|
11
|
+
frame.set(header, 4);
|
|
12
|
+
let offset = 4 + header.byteLength;
|
|
13
|
+
for (const buffer of buffers) {
|
|
14
|
+
frame.set(new Uint8Array(buffer), offset);
|
|
15
|
+
offset += buffer.byteLength;
|
|
16
|
+
}
|
|
17
|
+
return frame.buffer;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function unpackFrame(frame) {
|
|
21
|
+
if (!(frame instanceof ArrayBuffer) || frame.byteLength < 4) throw dataCloneError();
|
|
22
|
+
const length = new DataView(frame).getUint32(0);
|
|
23
|
+
if (length > frame.byteLength - 4) throw dataCloneError();
|
|
24
|
+
const metadata = JSON.parse(decoder.decode(new Uint8Array(frame, 4, length)));
|
|
25
|
+
if (!Array.isArray(metadata.buffers) || !Array.isArray(metadata.streams)) throw dataCloneError();
|
|
26
|
+
let offset = 4 + length;
|
|
27
|
+
const buffers = metadata.buffers.map(size => {
|
|
28
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > frame.byteLength - offset) throw dataCloneError();
|
|
29
|
+
const buffer = frame.slice(offset, offset + size);
|
|
30
|
+
offset += size;
|
|
31
|
+
return buffer;
|
|
32
|
+
});
|
|
33
|
+
if (offset !== frame.byteLength) throw dataCloneError();
|
|
34
|
+
return { ...metadata, buffers };
|
|
35
|
+
}
|
package/src/rpc/http.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
function headers(value) {
|
|
2
|
+
return [...value].filter(([name]) => name !== 'set-cookie')
|
|
3
|
+
.concat(value.getSetCookie().map(cookie => ['set-cookie', cookie]));
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function encodeRequest(request) {
|
|
7
|
+
return {
|
|
8
|
+
url: request.url, method: request.method, headers: headers(request.headers),
|
|
9
|
+
body: request.body, redirect: request.redirect, cf: request.cf,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function decodeRequest(value) {
|
|
14
|
+
return new Request(value.url, { ...value, duplex: 'half' });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function encodeResponse(response) {
|
|
18
|
+
if (!(response instanceof Response)) throw new TypeError('Service fetch must return a Response');
|
|
19
|
+
if (response.status === 101) throw new TypeError('Service binding WebSocket upgrades are not supported');
|
|
20
|
+
return {
|
|
21
|
+
status: response.status, statusText: response.statusText,
|
|
22
|
+
headers: headers(response.headers), body: response.body,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function decodeResponse(value) {
|
|
27
|
+
return new Response(value.body, value);
|
|
28
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { dataCloneError } from './values.mjs';
|
|
2
|
+
|
|
3
|
+
const chunkSize = 64 * 1024;
|
|
4
|
+
|
|
5
|
+
/** Lock a byte stream for the transport, reading only in response to receiver demand. */
|
|
6
|
+
export function takeStream(stream) {
|
|
7
|
+
let reader, byob = true, remaining = new Uint8Array(0);
|
|
8
|
+
try { reader = stream.getReader({ mode: 'byob' }); }
|
|
9
|
+
catch { byob = false; reader = stream.getReader(); }
|
|
10
|
+
return {
|
|
11
|
+
async read() {
|
|
12
|
+
if (byob) return reader.read(new Uint8Array(chunkSize));
|
|
13
|
+
while (remaining.byteLength === 0) {
|
|
14
|
+
const next = await reader.read();
|
|
15
|
+
if (next.done) return next;
|
|
16
|
+
if (!(next.value instanceof Uint8Array) || (typeof SharedArrayBuffer !== 'undefined' && next.value.buffer instanceof SharedArrayBuffer)) {
|
|
17
|
+
const error = dataCloneError();
|
|
18
|
+
// Cleanup cannot delay or replace the invalid chunk's failure.
|
|
19
|
+
reader.cancel(error).catch(() => {});
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
remaining = next.value;
|
|
23
|
+
}
|
|
24
|
+
const value = remaining.slice(0, chunkSize);
|
|
25
|
+
remaining = remaining.subarray(value.byteLength);
|
|
26
|
+
return { done: false, value };
|
|
27
|
+
},
|
|
28
|
+
cancel: reason => { remaining = new Uint8Array(0); return reader.cancel(reason); },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A byte stream backed by a transport port's read and cancel operations. */
|
|
33
|
+
export function receiveStream(port) {
|
|
34
|
+
return new ReadableStream({
|
|
35
|
+
type: 'bytes',
|
|
36
|
+
async pull(controller) {
|
|
37
|
+
const { done, value } = await port.read();
|
|
38
|
+
if (done) {
|
|
39
|
+
controller.close();
|
|
40
|
+
controller.byobRequest?.respond(0);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!(value instanceof Uint8Array) || value.byteLength === 0 || value.byteLength > chunkSize) {
|
|
44
|
+
const error = dataCloneError();
|
|
45
|
+
Promise.resolve().then(() => port.cancel(error)).catch(() => {});
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
controller.enqueue(value);
|
|
49
|
+
},
|
|
50
|
+
cancel: reason => port.cancel(reason),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const arrays = [
|
|
2
|
+
Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array,
|
|
3
|
+
Int32Array, Uint32Array, Float32Array, Float64Array, BigInt64Array, BigUint64Array,
|
|
4
|
+
];
|
|
5
|
+
if (typeof Float16Array !== 'undefined') arrays.push(Float16Array);
|
|
6
|
+
const views = new Map(arrays.map(Type => [Type.name, Type]));
|
|
7
|
+
views.set('DataView', DataView);
|
|
8
|
+
|
|
9
|
+
export function dataCloneError() {
|
|
10
|
+
return new DOMException('Value is outside the supported RPC contract.', 'DataCloneError');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function byteStream(value) {
|
|
14
|
+
if (value.locked) throw dataCloneError();
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function properties(value, visit, keys = Object.keys(value)) {
|
|
19
|
+
return keys.map(key => [key, visit(value[key])]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function errorProperties(value, visit) {
|
|
23
|
+
const keys = Object.getOwnPropertyNames(value).filter(key => !['name', 'message', 'stack'].includes(key));
|
|
24
|
+
return properties(value, visit, keys);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function encodeObject(value, visit, buffers, streams) {
|
|
28
|
+
if (Array.isArray(value)) return ['array', value.length, properties(value, visit)];
|
|
29
|
+
if (value instanceof Date) return ['date', visit(Date.prototype.getTime.call(value))];
|
|
30
|
+
if (value instanceof RegExp) return ['regexp', value.source, value.flags];
|
|
31
|
+
if (value instanceof Map) return ['map', [...value].map(([key, entry]) => [visit(key), visit(entry)])];
|
|
32
|
+
if (value instanceof Set) return ['set', [...value].map(visit)];
|
|
33
|
+
if (value instanceof DOMException) return ['exception', value.name, value.message, errorProperties(value, visit)];
|
|
34
|
+
if (value instanceof Error) return ['error', value.name, value.message, errorProperties(value, visit)];
|
|
35
|
+
if (value instanceof ReadableStream) return ['stream', streams.push(byteStream(value)) - 1];
|
|
36
|
+
if (value instanceof ArrayBuffer) {
|
|
37
|
+
if (value.detached) throw dataCloneError();
|
|
38
|
+
return ['buffer', buffers.push(value) - 1];
|
|
39
|
+
}
|
|
40
|
+
if (ArrayBuffer.isView(value)) {
|
|
41
|
+
const name = [...views].find(([, Type]) => Object.getPrototypeOf(value) === Type.prototype)?.[0];
|
|
42
|
+
if (!name) throw dataCloneError();
|
|
43
|
+
return ['view', name, visit(value.buffer), value.byteOffset, value.byteLength];
|
|
44
|
+
}
|
|
45
|
+
const prototype = Object.getPrototypeOf(value);
|
|
46
|
+
if (prototype !== null && prototype !== Object.prototype) throw dataCloneError();
|
|
47
|
+
return ['record', properties(value, visit)];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Describe a value graph; the transport copies buffers and transfers streams after validation. */
|
|
51
|
+
export function encode(value) {
|
|
52
|
+
const nodes = [], buffers = [], streams = [], seen = new Map();
|
|
53
|
+
function visit(value) {
|
|
54
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
55
|
+
if (typeof value === 'undefined') return ['undefined'];
|
|
56
|
+
if (typeof value === 'bigint') return ['bigint', String(value)];
|
|
57
|
+
if (typeof value === 'number') {
|
|
58
|
+
return Number.isFinite(value) && !Object.is(value, -0) ? value : ['number', Object.is(value, -0) ? '-0' : String(value)];
|
|
59
|
+
}
|
|
60
|
+
if (typeof value !== 'object') throw dataCloneError();
|
|
61
|
+
if (seen.has(value)) return ['ref', seen.get(value)];
|
|
62
|
+
const index = nodes.length;
|
|
63
|
+
seen.set(value, index);
|
|
64
|
+
nodes.push(null);
|
|
65
|
+
nodes[index] = encodeObject(value, visit, buffers, streams);
|
|
66
|
+
return ['ref', index];
|
|
67
|
+
}
|
|
68
|
+
const root = visit(value);
|
|
69
|
+
return { data: JSON.stringify({ root, nodes }), buffers, streams };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function setProperties(value, entries, read) {
|
|
73
|
+
for (const [key, entry] of entries) {
|
|
74
|
+
Object.defineProperty(value, key, { value: read(entry), enumerable: true, configurable: true, writable: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function allocate(node, read, buffers, streams) {
|
|
79
|
+
const [tag, value, extra, offset, length] = node;
|
|
80
|
+
switch (tag) {
|
|
81
|
+
case 'array': return new Array(value);
|
|
82
|
+
case 'record': return {};
|
|
83
|
+
case 'map': return new Map();
|
|
84
|
+
case 'set': return new Set();
|
|
85
|
+
case 'date': return new Date(read(value));
|
|
86
|
+
case 'regexp': return new RegExp(value, extra);
|
|
87
|
+
case 'error': return Object.assign(new Error(extra), { name: value });
|
|
88
|
+
case 'exception': return new DOMException(extra, value);
|
|
89
|
+
case 'buffer': {
|
|
90
|
+
if (!(buffers[value] instanceof ArrayBuffer)) throw dataCloneError();
|
|
91
|
+
return buffers[value];
|
|
92
|
+
}
|
|
93
|
+
case 'stream': {
|
|
94
|
+
if (!(streams[value] instanceof ReadableStream)) throw dataCloneError();
|
|
95
|
+
return streams[value];
|
|
96
|
+
}
|
|
97
|
+
case 'view': {
|
|
98
|
+
const Type = views.get(value);
|
|
99
|
+
if (!Type) throw dataCloneError();
|
|
100
|
+
return new Type(read(extra), offset, length / (Type.BYTES_PER_ELEMENT ?? 1));
|
|
101
|
+
}
|
|
102
|
+
default: throw dataCloneError();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function populate(value, node, read) {
|
|
107
|
+
switch (node[0]) {
|
|
108
|
+
case 'array': setProperties(value, node[2], read); break;
|
|
109
|
+
case 'record': setProperties(value, node[1], read); break;
|
|
110
|
+
case 'error': case 'exception': setProperties(value, node[3], read); break;
|
|
111
|
+
case 'map': for (const [key, entry] of node[1]) value.set(read(key), read(entry)); break;
|
|
112
|
+
case 'set': for (const entry of node[1]) value.add(read(entry)); break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Reconstruct a graph using buffers and streams owned by this receiver. */
|
|
117
|
+
export function decode({ data, buffers = [], streams = [] }) {
|
|
118
|
+
const { root, nodes } = JSON.parse(data), values = new Map(), pending = new Set();
|
|
119
|
+
function read(value) {
|
|
120
|
+
if (!Array.isArray(value)) return value;
|
|
121
|
+
const [tag, entry] = value;
|
|
122
|
+
if (tag === 'undefined') return undefined;
|
|
123
|
+
if (tag === 'bigint') return BigInt(entry);
|
|
124
|
+
if (tag === 'number') return Number(entry);
|
|
125
|
+
if (tag !== 'ref' || !Number.isInteger(entry) || entry < 0 || entry >= nodes.length) throw dataCloneError();
|
|
126
|
+
if (values.has(entry)) return values.get(entry);
|
|
127
|
+
if (pending.has(entry)) throw dataCloneError();
|
|
128
|
+
pending.add(entry);
|
|
129
|
+
const result = allocate(nodes[entry], read, buffers, streams);
|
|
130
|
+
values.set(entry, result);
|
|
131
|
+
populate(result, nodes[entry], read);
|
|
132
|
+
pending.delete(entry);
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
return read(root);
|
|
136
|
+
}
|
package/src/streams.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { encodeJson } from "./codec.js";
|
|
2
|
+
import type { Overflow } from "./context.js";
|
|
3
|
+
|
|
4
|
+
type Source<T> = (next: (value: T) => void, error: (error: unknown) => void, close: () => void) => () => void;
|
|
5
|
+
|
|
6
|
+
/** A byte stream of newline-delimited JSON values of type `T`, which `decodeValues` reads. */
|
|
7
|
+
export type JsonStream<T> = ReadableStream<Uint8Array> & {
|
|
8
|
+
/** Type only: the values the stream carries. */
|
|
9
|
+
readonly "~values"?: T;
|
|
10
|
+
};
|
|
11
|
+
const maxBufferedBytes = 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Adapts a push source with explicit cancellation, buffering up to 1 MiB for a slow reader. Past
|
|
15
|
+
* that, `overflow` ends the stream with `QuotaExceededError` or drops the oldest buffered values.
|
|
16
|
+
* An error ends the stream at once; closing it delivers what is buffered first.
|
|
17
|
+
*/
|
|
18
|
+
export function byteStream(subscribe: Source<Uint8Array>, overflow: Overflow = "error"): ReadableStream<Uint8Array> {
|
|
19
|
+
const buffered: Uint8Array[] = [];
|
|
20
|
+
let bytes = 0;
|
|
21
|
+
let end: { error?: unknown } | undefined;
|
|
22
|
+
let stop = () => {};
|
|
23
|
+
let wake = () => {};
|
|
24
|
+
const finish = (error?: unknown) => {
|
|
25
|
+
if (end) return;
|
|
26
|
+
end = { error };
|
|
27
|
+
if (error !== undefined) buffered.length = 0;
|
|
28
|
+
stop();
|
|
29
|
+
wake();
|
|
30
|
+
};
|
|
31
|
+
const push = (value: Uint8Array) => {
|
|
32
|
+
if (end) return;
|
|
33
|
+
buffered.push(value);
|
|
34
|
+
bytes += value.byteLength;
|
|
35
|
+
while (bytes > maxBufferedBytes) {
|
|
36
|
+
const oldest = overflow === "dropOldest" && buffered.length > 1 ? buffered.shift() : undefined;
|
|
37
|
+
if (!oldest) {
|
|
38
|
+
finish(new DOMException("The stream consumer is too slow", "QuotaExceededError"));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
bytes -= oldest.byteLength;
|
|
42
|
+
}
|
|
43
|
+
wake();
|
|
44
|
+
};
|
|
45
|
+
return new ReadableStream<Uint8Array>({
|
|
46
|
+
start() {
|
|
47
|
+
try {
|
|
48
|
+
stop = subscribe(push, finish, () => { finish(); });
|
|
49
|
+
if (end) stop();
|
|
50
|
+
} catch (error) { finish(error); }
|
|
51
|
+
},
|
|
52
|
+
async pull(controller) {
|
|
53
|
+
while (!buffered.length && !end) await new Promise<void>((resolve) => { wake = resolve; });
|
|
54
|
+
const value = buffered.shift();
|
|
55
|
+
if (value) {
|
|
56
|
+
bytes -= value.byteLength;
|
|
57
|
+
controller.enqueue(value);
|
|
58
|
+
} else if (end?.error === undefined) controller.close();
|
|
59
|
+
else controller.error(end.error);
|
|
60
|
+
},
|
|
61
|
+
cancel() { finish(); },
|
|
62
|
+
}, { highWaterMark: 0 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Encodes a typed push source as newline-delimited JSON. Errors terminate the stream. */
|
|
66
|
+
export function valueStream<T>(subscribe: Source<T>, overflow: Overflow = "error"): JsonStream<T> {
|
|
67
|
+
return byteStream((next, error, close) => subscribe((value) => {
|
|
68
|
+
try {
|
|
69
|
+
const payload = encodeJson(value);
|
|
70
|
+
const line = new Uint8Array(payload.byteLength + 1);
|
|
71
|
+
line.set(payload);
|
|
72
|
+
line[payload.byteLength] = 10;
|
|
73
|
+
next(line);
|
|
74
|
+
} catch (failure) { error(failure); }
|
|
75
|
+
}, error, close), overflow);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Decodes JSON values; ending iteration cancels the source. */
|
|
79
|
+
export async function* decodeValues<T>(source: JsonStream<T> | Promise<JsonStream<T>>, signal: AbortSignal = new AbortController().signal): AsyncGenerator<T, void, unknown> {
|
|
80
|
+
const reader = (await source).getReader();
|
|
81
|
+
const cancel = () => { void reader.cancel().catch(() => undefined); };
|
|
82
|
+
if (signal.aborted) cancel();
|
|
83
|
+
else signal.addEventListener("abort", cancel, { once: true });
|
|
84
|
+
try {
|
|
85
|
+
yield* readValues<T>(reader, signal);
|
|
86
|
+
} finally {
|
|
87
|
+
// An errored source rejects cancel with its original failure.
|
|
88
|
+
await reader.cancel().catch(() => undefined);
|
|
89
|
+
signal.removeEventListener("abort", cancel);
|
|
90
|
+
reader.releaseLock();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function* readValues<T>(reader: ReadableStreamDefaultReader<Uint8Array>, signal: AbortSignal): AsyncGenerator<T, void, unknown> {
|
|
95
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false });
|
|
96
|
+
let pending = "";
|
|
97
|
+
let complete = false;
|
|
98
|
+
while (!complete) {
|
|
99
|
+
const { value, done } = await reader.read();
|
|
100
|
+
if (signal.aborted) return;
|
|
101
|
+
complete = done;
|
|
102
|
+
pending += decoder.decode(value, { stream: !done });
|
|
103
|
+
let newline: number;
|
|
104
|
+
while ((newline = pending.indexOf("\n")) !== -1) {
|
|
105
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- yielding lets the caller abort.
|
|
106
|
+
if (signal.aborted) return;
|
|
107
|
+
const line = pending.slice(0, newline);
|
|
108
|
+
pending = pending.slice(newline + 1);
|
|
109
|
+
yield parseValue<T>(line);
|
|
110
|
+
}
|
|
111
|
+
if (pending.length > maxBufferedBytes) throw new RangeError("The stream value exceeds 1 MiB");
|
|
112
|
+
}
|
|
113
|
+
if (pending) yield parseValue<T>(pending);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- the consumer supplies the decoded type.
|
|
117
|
+
function parseValue<T>(line: string): T {
|
|
118
|
+
if (line.length > maxBufferedBytes) throw new RangeError("The stream value exceeds 1 MiB");
|
|
119
|
+
return JSON.parse(line) as T;
|
|
120
|
+
}
|