@statewalker/webrun-http-browser 0.6.5 → 0.7.0
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 +302 -52
- package/dist/core/data-calls.d.ts +17 -0
- package/dist/core/data-calls.d.ts.map +1 -1
- package/dist/core/service-worker-control.d.ts +3 -1
- package/dist/core/service-worker-control.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3906 -265
- package/dist/link/errors.d.ts +2 -0
- package/dist/link/errors.d.ts.map +1 -0
- package/dist/link/http-link.d.ts +58 -0
- package/dist/link/http-link.d.ts.map +1 -0
- package/dist/link/index.d.ts +4 -0
- package/dist/link/index.d.ts.map +1 -0
- package/dist/link/locks.d.ts +39 -0
- package/dist/link/locks.d.ts.map +1 -0
- package/dist/link/rendezvous.d.ts +21 -0
- package/dist/link/rendezvous.d.ts.map +1 -0
- package/dist/relay/index-sw.d.ts +12 -2
- package/dist/relay/index-sw.d.ts.map +1 -1
- package/dist/relay/index.d.ts +66 -8
- package/dist/relay/index.d.ts.map +1 -1
- package/dist/relay-sw.js +1160 -55
- package/dist/relay-worker.js +1161 -55
- package/dist/sw/handler-endpoint.d.ts +81 -0
- package/dist/sw/handler-endpoint.d.ts.map +1 -0
- package/dist/sw/handler-table.d.ts +53 -0
- package/dist/sw/handler-table.d.ts.map +1 -0
- package/dist/sw/http-sw-dispatcher.d.ts +162 -21
- package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
- package/dist/sw/index.d.ts +5 -0
- package/dist/sw/index.d.ts.map +1 -1
- package/dist/sw/protocol.d.ts +115 -0
- package/dist/sw/protocol.d.ts.map +1 -0
- package/dist/sw/sw-dispatcher.d.ts.map +1 -1
- package/dist/sw/sw-http-adapter.d.ts +199 -0
- package/dist/sw/sw-http-adapter.d.ts.map +1 -0
- package/dist/sw/sw-websocket.d.ts +16 -0
- package/dist/sw/sw-websocket.d.ts.map +1 -0
- package/dist/sw/worker-service.d.ts +45 -0
- package/dist/sw/worker-service.d.ts.map +1 -0
- package/dist/sw/worker-url.d.ts +9 -0
- package/dist/sw/worker-url.d.ts.map +1 -0
- package/dist/sw-worker.js +2075 -210
- package/dist/sw.d.ts +2 -0
- package/dist/sw.d.ts.map +1 -1
- package/dist/sw.js +3901 -531
- package/dist/worker.d.ts +10 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +3507 -0
- package/dist/ws/client.d.ts +73 -0
- package/dist/ws/client.d.ts.map +1 -0
- package/dist/ws/frames.d.ts +68 -0
- package/dist/ws/frames.d.ts.map +1 -0
- package/dist/ws/index.d.ts +6 -0
- package/dist/ws/index.d.ts.map +1 -0
- package/dist/ws/server.d.ts +53 -0
- package/dist/ws/server.d.ts.map +1 -0
- package/dist/ws/socket-core.d.ts +91 -0
- package/dist/ws/socket-core.d.ts.map +1 -0
- package/dist/ws/upgrade.d.ts +93 -0
- package/dist/ws/upgrade.d.ts.map +1 -0
- package/package.json +10 -5
- package/src/core/data-calls.ts +52 -2
- package/src/core/service-worker-control.ts +7 -1
- package/src/index.ts +3 -0
- package/src/link/errors.ts +3 -0
- package/src/link/http-link.ts +222 -0
- package/src/link/index.ts +3 -0
- package/src/link/locks.ts +97 -0
- package/src/link/rendezvous.ts +60 -0
- package/src/relay/index-sw.ts +84 -12
- package/src/relay/index.ts +203 -27
- package/src/sw/handler-endpoint.ts +278 -0
- package/src/sw/handler-table.ts +130 -0
- package/src/sw/http-sw-dispatcher.ts +649 -90
- package/src/sw/index.ts +5 -0
- package/src/sw/protocol.ts +103 -0
- package/src/sw/sw-dispatcher.ts +1 -20
- package/src/sw/sw-http-adapter.ts +613 -0
- package/src/sw/sw-websocket.ts +43 -0
- package/src/sw/worker-service.ts +133 -0
- package/src/sw/worker-url.ts +19 -0
- package/src/sw-worker.ts +8 -3
- package/src/sw.ts +2 -0
- package/src/worker.ts +9 -0
- package/src/ws/client.ts +346 -0
- package/src/ws/frames.ts +119 -0
- package/src/ws/index.ts +11 -0
- package/src/ws/server.ts +132 -0
- package/src/ws/socket-core.ts +251 -0
- package/src/ws/upgrade.ts +200 -0
package/dist/worker.js
ADDED
|
@@ -0,0 +1,3507 @@
|
|
|
1
|
+
//#region ../webrun-streams/dist/index.js
|
|
2
|
+
function serializeError(error) {
|
|
3
|
+
if (error instanceof Error) {
|
|
4
|
+
const out = {
|
|
5
|
+
message: error.message,
|
|
6
|
+
stack: error.stack
|
|
7
|
+
};
|
|
8
|
+
const bag = error;
|
|
9
|
+
for (const key of Object.keys(bag)) out[key] = bag[key];
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
if (typeof error === "object" && error !== null) {
|
|
13
|
+
const bag = error;
|
|
14
|
+
return {
|
|
15
|
+
message: String(bag.message ?? error),
|
|
16
|
+
...bag
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return { message: String(error) };
|
|
20
|
+
}
|
|
21
|
+
function deserializeError(error) {
|
|
22
|
+
const payload = typeof error === "string" ? { message: error } : error;
|
|
23
|
+
return Object.assign(new Error(payload.message), payload);
|
|
24
|
+
}
|
|
25
|
+
new TextEncoder();
|
|
26
|
+
new TextDecoder();
|
|
27
|
+
/**
|
|
28
|
+
* The newAsyncGenerator function creates async generators from callback-based initialization
|
|
29
|
+
* functions, providing a bridge between imperative event handling and declarative async
|
|
30
|
+
* iteration patterns.
|
|
31
|
+
*
|
|
32
|
+
* The initialization function receives two callback functions that control the generator's
|
|
33
|
+
* behavior. The `next` function yields values to consumers, while the `done` function
|
|
34
|
+
* signals completion or error conditions. The initialization function can return a cleanup
|
|
35
|
+
* function that will be called when the generator is closed or an error occurs.
|
|
36
|
+
*
|
|
37
|
+
* The generator manages an internal queue of values and completion signals, ensuring that
|
|
38
|
+
* producers can yield values without overwhelming consumers. Proper backpressure is implemented
|
|
39
|
+
* by having the `next` and `done` functions return promises that resolve only after the
|
|
40
|
+
* consumer has processed the values. This prevents memory leaks and ensures that producers
|
|
41
|
+
* are aware of whether their values were successfully handled.
|
|
42
|
+
* The generator also handles cleanup by draining any remaining items in the queue and notifying
|
|
43
|
+
* producers that their values were not processed if the generator is closed early. This ensures
|
|
44
|
+
* that resources are properly managed and that no memory leaks occur.
|
|
45
|
+
*
|
|
46
|
+
* Example usage:
|
|
47
|
+
* ```typescript
|
|
48
|
+
* const asyncGen = newAsyncGenerator<number>((next, done) => {
|
|
49
|
+
* let count = 0;
|
|
50
|
+
* const interval = setInterval(() => {
|
|
51
|
+
* if (count < 5) {
|
|
52
|
+
* next(count++);
|
|
53
|
+
* } else {
|
|
54
|
+
* done();
|
|
55
|
+
* clearInterval(interval);
|
|
56
|
+
* }
|
|
57
|
+
* }, 1000);
|
|
58
|
+
* return () => clearInterval(interval); // Cleanup function
|
|
59
|
+
* });
|
|
60
|
+
* (async () => {
|
|
61
|
+
* for await (const num of asyncGen) {
|
|
62
|
+
* console.log(num); // Logs numbers 0 to 4 at 1 second intervals
|
|
63
|
+
* }
|
|
64
|
+
* console.log("Completed");
|
|
65
|
+
* })();
|
|
66
|
+
* ```
|
|
67
|
+
* @template T The type of values yielded by the generator
|
|
68
|
+
* @template E The type of errors that can be thrown; defaults to Error
|
|
69
|
+
* @param init Initialization function that sets up the generator behavior with next/done callbacks;
|
|
70
|
+
* The first parameter - `next` function - is used to yield values to consumers;
|
|
71
|
+
* It returns a Promise<boolean> indicating whether the value was successfully handled;
|
|
72
|
+
* The second parameter - `done` function - is used to signal completion or error;
|
|
73
|
+
* It returns a Promise<boolean> indicating whether the completion was successfully handled;
|
|
74
|
+
* The initialization function can optionally return a cleanup function that will be called
|
|
75
|
+
* when the generator is closed or an error occurs;
|
|
76
|
+
* @param skipValues If true, only the most recent value is kept in the queue,
|
|
77
|
+
* skipping intermediate values (not consumed values are considered skipped);
|
|
78
|
+
* This is useful for scenarios where only the latest value matters, such as UI updates.
|
|
79
|
+
* Defaults to false, meaning all values are queued and processed in order.
|
|
80
|
+
* @returns AsyncGenerator that properly manages backpressure and resource cleanup
|
|
81
|
+
*/
|
|
82
|
+
async function* newAsyncGenerator(init, skipValues = false) {
|
|
83
|
+
let head;
|
|
84
|
+
let tail;
|
|
85
|
+
/** Flag to prevent new values from being queued after generator closes */
|
|
86
|
+
let closed = false;
|
|
87
|
+
/** Wake-up function to notify the generator loop of new items */
|
|
88
|
+
let wakeUp;
|
|
89
|
+
/**
|
|
90
|
+
* Drains the internal queue, notifying all pending producers that their values
|
|
91
|
+
* were not processed due to generator closure. This prevents memory leaks and
|
|
92
|
+
* ensures proper backpressure signaling.
|
|
93
|
+
*/
|
|
94
|
+
const drainQueue = () => {
|
|
95
|
+
for (; head; head = head.next) {
|
|
96
|
+
closed = closed || head.done;
|
|
97
|
+
head.resolve(false);
|
|
98
|
+
}
|
|
99
|
+
tail = void 0;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Enqueues a value or completion signal with a promise that resolves when the item
|
|
103
|
+
* is processed. This enables backpressure by allowing producers to know when their
|
|
104
|
+
* values have been consumed.
|
|
105
|
+
*/
|
|
106
|
+
const enqueue = (params) => {
|
|
107
|
+
if (skipValues) drainQueue();
|
|
108
|
+
return !closed ? new Promise((resolve) => {
|
|
109
|
+
const next = {
|
|
110
|
+
...params,
|
|
111
|
+
next: void 0,
|
|
112
|
+
resolve
|
|
113
|
+
};
|
|
114
|
+
if (tail) tail.next = next;
|
|
115
|
+
tail = next;
|
|
116
|
+
if (!head) head = tail;
|
|
117
|
+
wakeUp?.();
|
|
118
|
+
}) : Promise.resolve(false);
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Producer function to yield a value to consumers. Returns a promise that resolves
|
|
122
|
+
* to true if the value was successfully processed, false if the generator is closed
|
|
123
|
+
* or the value was skipped due to backpressure.
|
|
124
|
+
*/
|
|
125
|
+
const next = (value) => enqueue({
|
|
126
|
+
done: false,
|
|
127
|
+
value
|
|
128
|
+
});
|
|
129
|
+
/**
|
|
130
|
+
* Producer function to signal completion or error. Optional error parameter will
|
|
131
|
+
* cause the generator to throw that error to consumers.
|
|
132
|
+
*/
|
|
133
|
+
const done = (error) => enqueue({
|
|
134
|
+
done: true,
|
|
135
|
+
error
|
|
136
|
+
});
|
|
137
|
+
const unsubscribe = init(next, done);
|
|
138
|
+
try {
|
|
139
|
+
while (!closed) {
|
|
140
|
+
if (!head) {
|
|
141
|
+
await new Promise((resolve) => {
|
|
142
|
+
wakeUp = resolve;
|
|
143
|
+
}).then(() => {
|
|
144
|
+
wakeUp = void 0;
|
|
145
|
+
});
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const slot = head;
|
|
149
|
+
head = head.next;
|
|
150
|
+
if (tail === slot) tail = head;
|
|
151
|
+
try {
|
|
152
|
+
if (slot.done) {
|
|
153
|
+
closed = true;
|
|
154
|
+
if (slot.error !== void 0) throw slot.error;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
yield slot.value;
|
|
158
|
+
} finally {
|
|
159
|
+
slot.resolve(true);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
/**
|
|
164
|
+
* Cleanup phase - ensures proper resource management and notification of any
|
|
165
|
+
* remaining producers. This runs whether the generator completes normally,
|
|
166
|
+
* encounters an error, or is closed early by the consumer.
|
|
167
|
+
*/
|
|
168
|
+
closed = true;
|
|
169
|
+
wakeUp?.();
|
|
170
|
+
if (typeof unsubscribe === "function") await unsubscribe();
|
|
171
|
+
/**
|
|
172
|
+
* Drain any remaining items in the queue and notify their producers that
|
|
173
|
+
* the values were not processed. This prevents memory leaks and ensures
|
|
174
|
+
* proper backpressure signaling.
|
|
175
|
+
*/
|
|
176
|
+
drainQueue();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
new TextEncoder();
|
|
180
|
+
/**
|
|
181
|
+
* Inverse of {@link sendIterator}: turns a sequence of `{done, value, error}`
|
|
182
|
+
* chunks (delivered to the supplied callback by `installer`) into an async
|
|
183
|
+
* generator.
|
|
184
|
+
*
|
|
185
|
+
* The `installer` is given a `deliver` function. It should call `deliver`
|
|
186
|
+
* once for each incoming chunk and may return a cleanup callback that
|
|
187
|
+
* runs when the consumer stops iterating.
|
|
188
|
+
*/
|
|
189
|
+
function recieveIterator(installer) {
|
|
190
|
+
return newAsyncGenerator((next, done) => {
|
|
191
|
+
const cleanup = installer(async (chunk = { done: true }) => {
|
|
192
|
+
const { done: isDone = true, value, error } = chunk;
|
|
193
|
+
if (error) return await done(error);
|
|
194
|
+
if (isDone) return await done();
|
|
195
|
+
return await next(value);
|
|
196
|
+
});
|
|
197
|
+
if (cleanup) return async () => {
|
|
198
|
+
await cleanup();
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Drain an async iterator into a sink that consumes one chunk at a time.
|
|
204
|
+
*
|
|
205
|
+
* Each yielded value becomes `{ done: false, value }`. Completion emits
|
|
206
|
+
* `{ done: true }`. If the iterator throws, the error is caught and the
|
|
207
|
+
* final `done` chunk carries it so the peer can rethrow on its side.
|
|
208
|
+
*/
|
|
209
|
+
async function sendIterator(send, it) {
|
|
210
|
+
let error;
|
|
211
|
+
try {
|
|
212
|
+
for await (const value of it) await send({
|
|
213
|
+
done: false,
|
|
214
|
+
value
|
|
215
|
+
});
|
|
216
|
+
} catch (err) {
|
|
217
|
+
error = err;
|
|
218
|
+
} finally {
|
|
219
|
+
await send({
|
|
220
|
+
done: true,
|
|
221
|
+
error
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const DEFAULT_CHUNK_SIZE = 16384;
|
|
226
|
+
/**
|
|
227
|
+
* Curried `Duplex`-shaped transformer that splits incoming `Uint8Array`s into
|
|
228
|
+
* chunks no larger than `size` bytes. Empty input chunks are skipped; small
|
|
229
|
+
* input chunks pass through unchanged (zero-copy `subarray` views are used
|
|
230
|
+
* when splitting, so no allocation per chunk).
|
|
231
|
+
*
|
|
232
|
+
* Use to respect transport MTUs before reaching `channel.send`. Reassembly on
|
|
233
|
+
* the receive side is not needed — consumers iterate a continuous byte stream.
|
|
234
|
+
*/
|
|
235
|
+
function toChunks(size = DEFAULT_CHUNK_SIZE) {
|
|
236
|
+
if (!(Number.isInteger(size) && size > 0)) throw new RangeError(`toChunks: size must be a positive integer, got ${size}`);
|
|
237
|
+
return async function* (input) {
|
|
238
|
+
for await (const block of input) {
|
|
239
|
+
if (block.byteLength === 0) continue;
|
|
240
|
+
if (block.byteLength <= size) {
|
|
241
|
+
yield block;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
let offset = 0;
|
|
245
|
+
while (offset < block.byteLength) {
|
|
246
|
+
const end = Math.min(offset + size, block.byteLength);
|
|
247
|
+
yield block.subarray(offset, end);
|
|
248
|
+
offset = end;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region ../webrun-http-streams/dist/index.js
|
|
255
|
+
const CR = 13;
|
|
256
|
+
const LF = 10;
|
|
257
|
+
const EMPTY = /* @__PURE__ */ new Uint8Array(0);
|
|
258
|
+
var ByteStreamError = class extends Error {
|
|
259
|
+
name = "ByteStreamError";
|
|
260
|
+
};
|
|
261
|
+
function toAsyncIterator(input) {
|
|
262
|
+
const asyncIter = input[Symbol.asyncIterator];
|
|
263
|
+
if (asyncIter) return asyncIter.call(input);
|
|
264
|
+
const syncIter = input[Symbol.iterator]();
|
|
265
|
+
return { next() {
|
|
266
|
+
return Promise.resolve(syncIter.next());
|
|
267
|
+
} };
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Discard an iterable we are contractually forbidden from consuming — a body
|
|
271
|
+
* skipped for HEAD/204, or one abandoned because the peer reported an error.
|
|
272
|
+
*
|
|
273
|
+
* The `.next()` is not optional: `.return()` on a generator still in suspended
|
|
274
|
+
* start is a no-op, so the body never runs and its `try/finally` never unwinds.
|
|
275
|
+
* Without it a wrapped ReadableStream or socket is never cancelled.
|
|
276
|
+
*/
|
|
277
|
+
async function discard(source) {
|
|
278
|
+
if (source === void 0) return;
|
|
279
|
+
const it = toAsyncIterator(source);
|
|
280
|
+
try {
|
|
281
|
+
await it.next();
|
|
282
|
+
await it.return?.();
|
|
283
|
+
} catch {}
|
|
284
|
+
}
|
|
285
|
+
function concatChunks(parts, totalLen) {
|
|
286
|
+
if (parts.length === 1) {
|
|
287
|
+
const only = parts[0];
|
|
288
|
+
if (only !== void 0) return only;
|
|
289
|
+
}
|
|
290
|
+
const out = new Uint8Array(totalLen);
|
|
291
|
+
let off = 0;
|
|
292
|
+
for (const p of parts) {
|
|
293
|
+
out.set(p, off);
|
|
294
|
+
off += p.byteLength;
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Pull-based reader over a byte source. Holds at most one pending buffer, and
|
|
300
|
+
* hands out `subarray` views rather than copies — a body never passes through
|
|
301
|
+
* an allocation here.
|
|
302
|
+
*/
|
|
303
|
+
var ByteReader = class {
|
|
304
|
+
#iter;
|
|
305
|
+
#buf = EMPTY;
|
|
306
|
+
#done = false;
|
|
307
|
+
constructor(input) {
|
|
308
|
+
this.#iter = toAsyncIterator(input);
|
|
309
|
+
}
|
|
310
|
+
/** Bytes already pulled from the source but not yet consumed. */
|
|
311
|
+
bufferedLength() {
|
|
312
|
+
return this.#buf.byteLength;
|
|
313
|
+
}
|
|
314
|
+
/** Pull one more non-empty chunk. Returns false at end of stream. */
|
|
315
|
+
async #pull() {
|
|
316
|
+
if (this.#done) return false;
|
|
317
|
+
while (true) {
|
|
318
|
+
const next = await this.#iter.next();
|
|
319
|
+
if (next.done) {
|
|
320
|
+
this.#done = true;
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
const chunk = next.value;
|
|
324
|
+
if (chunk.byteLength === 0) continue;
|
|
325
|
+
this.#buf = this.#buf.byteLength === 0 ? chunk : concatChunks([this.#buf, chunk], this.#buf.byteLength + chunk.byteLength);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async peekByte() {
|
|
330
|
+
while (this.#buf.byteLength === 0) if (!await this.#pull()) return void 0;
|
|
331
|
+
return this.#buf[0];
|
|
332
|
+
}
|
|
333
|
+
/** Up to `max` bytes. `undefined` means end of stream. */
|
|
334
|
+
async readSome(max) {
|
|
335
|
+
while (this.#buf.byteLength === 0) if (!await this.#pull()) return void 0;
|
|
336
|
+
const take = Math.min(max, this.#buf.byteLength);
|
|
337
|
+
const out = this.#buf.subarray(0, take);
|
|
338
|
+
this.#buf = this.#buf.subarray(take);
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* One CRLF-terminated line, without the CRLF. A bare LF is rejected: real
|
|
343
|
+
* peers always send CRLF, and tolerating a bare LF is precisely the lenience
|
|
344
|
+
* that lets request smuggling through a proxy pair.
|
|
345
|
+
*
|
|
346
|
+
* The `maxBytes` bound is best-effort: it only rejects a line if the check
|
|
347
|
+
* happens to run before the line is fully buffered. A line already sitting in
|
|
348
|
+
* the buffer bypasses the check. Callers needing a hard per-line limit or
|
|
349
|
+
* aggregate bounds must keep their own running total.
|
|
350
|
+
*/
|
|
351
|
+
async readLine(maxBytes) {
|
|
352
|
+
let searched = 0;
|
|
353
|
+
while (true) {
|
|
354
|
+
const idx = this.#buf.indexOf(LF, searched);
|
|
355
|
+
if (idx !== -1) {
|
|
356
|
+
if (idx === 0 || this.#buf[idx - 1] !== CR) throw new ByteStreamError("bare LF line terminator; CRLF required");
|
|
357
|
+
const line = this.#buf.subarray(0, idx - 1);
|
|
358
|
+
this.#buf = this.#buf.subarray(idx + 1);
|
|
359
|
+
return line;
|
|
360
|
+
}
|
|
361
|
+
searched = this.#buf.byteLength;
|
|
362
|
+
if (searched > maxBytes) throw new ByteStreamError(`line exceeds ${maxBytes} bytes without CRLF`);
|
|
363
|
+
if (!await this.#pull()) throw new ByteStreamError(`stream ended after ${searched} bytes without CRLF`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/** Everything not yet consumed, lazily. */
|
|
367
|
+
async *rest() {
|
|
368
|
+
while (true) {
|
|
369
|
+
if (this.#buf.byteLength > 0) {
|
|
370
|
+
const out = this.#buf;
|
|
371
|
+
this.#buf = EMPTY;
|
|
372
|
+
yield out;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (!await this.#pull()) return;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
/**
|
|
380
|
+
* Raised for any byte sequence this codec refuses to interpret. Every case is
|
|
381
|
+
* a refusal to guess: HTTP/1.1 parsers that guess are how request smuggling
|
|
382
|
+
* works.
|
|
383
|
+
*/
|
|
384
|
+
var HttpParseError = class extends Error {
|
|
385
|
+
name = "HttpParseError";
|
|
386
|
+
};
|
|
387
|
+
const NEWLINE = 10;
|
|
388
|
+
/** Mirrors the HTTP/1.1 codec's default `maxHeaderBytes`. */
|
|
389
|
+
const MAX_ENVELOPE_BYTES = 65536;
|
|
390
|
+
const encoder$1 = new TextEncoder();
|
|
391
|
+
const decoder = new TextDecoder();
|
|
392
|
+
/**
|
|
393
|
+
* Encode an HTTP envelope plus optional body as one continuous byte stream:
|
|
394
|
+
*
|
|
395
|
+
* <JSON.stringify(envelope)>\n<body bytes...>
|
|
396
|
+
*
|
|
397
|
+
* `JSON.stringify` with default whitespace never emits a literal `\n`, so the
|
|
398
|
+
* first `0x0a` byte unambiguously terminates the envelope.
|
|
399
|
+
*
|
|
400
|
+
* This is the same wire shape used by the legacy `webrun-http-port` package.
|
|
401
|
+
*/
|
|
402
|
+
async function* encodeMessage(envelope, body) {
|
|
403
|
+
yield encoder$1.encode(`${JSON.stringify(envelope)}\n`);
|
|
404
|
+
if (!body) return;
|
|
405
|
+
for await (const chunk of body) if (chunk.byteLength > 0) yield chunk;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Consume an envelope-then-body byte stream. Returns the parsed envelope and
|
|
409
|
+
* an async iterable over the remaining body bytes.
|
|
410
|
+
*/
|
|
411
|
+
async function decodeMessage(input) {
|
|
412
|
+
const iter = toAsyncIterator(input);
|
|
413
|
+
const accum = [];
|
|
414
|
+
let accumLen = 0;
|
|
415
|
+
let before;
|
|
416
|
+
let tail;
|
|
417
|
+
while (true) {
|
|
418
|
+
const next = await iter.next();
|
|
419
|
+
if (next.done) throw new HttpParseError(`decodeMessage: stream ended after ${accumLen} bytes without delimiter (\\n)`);
|
|
420
|
+
const chunk = next.value;
|
|
421
|
+
if (chunk.byteLength === 0) continue;
|
|
422
|
+
const nl = chunk.indexOf(NEWLINE);
|
|
423
|
+
if (nl === -1) {
|
|
424
|
+
accum.push(chunk);
|
|
425
|
+
accumLen += chunk.byteLength;
|
|
426
|
+
if (accumLen > MAX_ENVELOPE_BYTES) throw new HttpParseError(`decodeMessage: envelope exceeds ${MAX_ENVELOPE_BYTES} bytes without delimiter (\\n)`);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
accum.push(chunk.subarray(0, nl));
|
|
430
|
+
accumLen += nl;
|
|
431
|
+
before = concatChunks(accum, accumLen);
|
|
432
|
+
tail = chunk.subarray(nl + 1);
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
let envelope;
|
|
436
|
+
try {
|
|
437
|
+
envelope = JSON.parse(decoder.decode(before));
|
|
438
|
+
} catch (err) {
|
|
439
|
+
throw new HttpParseError(`decodeMessage: malformed envelope JSON at bytes 0..${before.byteLength}: ${err.message}`);
|
|
440
|
+
}
|
|
441
|
+
async function* body() {
|
|
442
|
+
if (tail.byteLength > 0) yield tail;
|
|
443
|
+
while (true) {
|
|
444
|
+
const next = await iter.next();
|
|
445
|
+
if (next.done) return;
|
|
446
|
+
if (next.value.byteLength > 0) yield next.value;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
envelope,
|
|
451
|
+
body: body()
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
const OPEN_BRACE = 123;
|
|
455
|
+
/**
|
|
456
|
+
* The original wire format — `<JSON.stringify(envelope)>\n<body bytes…>` —
|
|
457
|
+
* expressed as a `MessageCodec`. Direction-agnostic: requests and responses
|
|
458
|
+
* serialise identically.
|
|
459
|
+
*
|
|
460
|
+
* Retained so a peer pair can be upgraded in either order; see ADR-0006.
|
|
461
|
+
*/
|
|
462
|
+
const jsonEnvelopeCodec = {
|
|
463
|
+
name: "json-envelope",
|
|
464
|
+
sniff: (byte) => byte === OPEN_BRACE,
|
|
465
|
+
encodeRequest: (env, body) => encodeMessage(env, body),
|
|
466
|
+
encodeResponse: (env, body) => encodeMessage(env, body),
|
|
467
|
+
decodeRequest: (input) => decodeMessage(input),
|
|
468
|
+
decodeResponse: (input) => decodeMessage(input)
|
|
469
|
+
};
|
|
470
|
+
const TCHAR = /* @__PURE__ */ new Set("!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
|
|
471
|
+
const SP = 32;
|
|
472
|
+
const HTAB = 9;
|
|
473
|
+
function isTokenChar(byte) {
|
|
474
|
+
return byte > SP && byte < 127 && TCHAR.has(String.fromCharCode(byte));
|
|
475
|
+
}
|
|
476
|
+
function isToken(value) {
|
|
477
|
+
if (value.length === 0) return false;
|
|
478
|
+
for (const ch of value) if (!TCHAR.has(ch)) return false;
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Header bytes are latin-1: byte-preserving, matching Node, and lossless
|
|
483
|
+
* across a decode/encode round-trip. Control characters are rejected
|
|
484
|
+
* separately by `assertValidHeaderValue`.
|
|
485
|
+
*/
|
|
486
|
+
function decodeLatin1(bytes) {
|
|
487
|
+
let out = "";
|
|
488
|
+
for (const byte of bytes) out += String.fromCharCode(byte);
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
function encodeLatin1(text) {
|
|
492
|
+
const out = new Uint8Array(text.length);
|
|
493
|
+
for (let i = 0; i < text.length; i++) {
|
|
494
|
+
const code = text.charCodeAt(i);
|
|
495
|
+
if (code > 255) throw new HttpParseError(`not latin-1 encodable: ${JSON.stringify(text)}`);
|
|
496
|
+
out[i] = code;
|
|
497
|
+
}
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
const HOST_IP_LITERAL = /^\[[0-9A-Fa-f:.]+\](:\d{1,5})?$/;
|
|
501
|
+
const HOST_REG_NAME = /^[A-Za-z0-9._~-]+(:\d{1,5})?$/;
|
|
502
|
+
/**
|
|
503
|
+
* Validates a value that is about to become (or was read as) a `Host`
|
|
504
|
+
* header: untrusted input either way, so an unrecognised shape is a refusal,
|
|
505
|
+
* not a guess. Rejects a present-but-empty value, one carrying userinfo
|
|
506
|
+
* (`evil.com@good.com`), and — the reason this also runs on encode — one
|
|
507
|
+
* smuggling a CRLF-terminated line into the authority of a caller-supplied
|
|
508
|
+
* url. Never applied to trusted configuration (a codec's `opts.host`).
|
|
509
|
+
*/
|
|
510
|
+
function assertValidHost(host) {
|
|
511
|
+
if (!HOST_IP_LITERAL.test(host) && !HOST_REG_NAME.test(host)) throw new HttpParseError(`invalid Host header: ${JSON.stringify(host)}`);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* A request-target must be entirely visible ASCII (RFC 9110 VCHAR) — nothing
|
|
515
|
+
* `<= 0x20` (space and every control character, CR/LF included) and nothing
|
|
516
|
+
* `>= 0x7F` (DEL and beyond). Applied on encode (`splitTarget`, so a
|
|
517
|
+
* caller-supplied url can never inject a second request line) and on decode
|
|
518
|
+
* (so a relay that decodes then re-encodes can never put an embedded control
|
|
519
|
+
* character back on the wire).
|
|
520
|
+
*/
|
|
521
|
+
function assertValidTarget(target) {
|
|
522
|
+
for (let i = 0; i < target.length; i++) {
|
|
523
|
+
const code = target.charCodeAt(i);
|
|
524
|
+
if (code <= SP || code >= 127) throw new HttpParseError(`invalid request-target: ${JSON.stringify(target)}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Shared control-character check behind both `assertValidHeaderValue` and
|
|
529
|
+
* `assertValidStatusText` — the two were previously checked by different,
|
|
530
|
+
* looser rules (statusText only rejected CR/LF), which is exactly the
|
|
531
|
+
* asymmetry class C1 and M5 are both instances of. `label` is prepended
|
|
532
|
+
* verbatim to each message, so callers keep their own wording.
|
|
533
|
+
*/
|
|
534
|
+
function assertNoControlChars(label, value) {
|
|
535
|
+
for (let i = 0; i < value.length; i++) {
|
|
536
|
+
const code = value.charCodeAt(i);
|
|
537
|
+
if (code === 13 || code === 10) throw new HttpParseError(`${label} contains CR or LF`);
|
|
538
|
+
if (code < SP && code !== HTAB || code === 127) throw new HttpParseError(`${label} contains a control character`);
|
|
539
|
+
if (code > 255) throw new HttpParseError(`${label} is not latin-1 encodable`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
function assertValidHeaderValue(name, value) {
|
|
543
|
+
assertNoControlChars(`header "${name}" value`, value);
|
|
544
|
+
}
|
|
545
|
+
/** Same character class as a header value (M5) — CR/LF, every C0 control, and DEL. */
|
|
546
|
+
function assertValidStatusText(value) {
|
|
547
|
+
assertNoControlChars("statusText", value);
|
|
548
|
+
}
|
|
549
|
+
function encodeHeaderLines(headers) {
|
|
550
|
+
let out = "";
|
|
551
|
+
for (const [name, value] of headers) {
|
|
552
|
+
if (!isToken(name)) throw new HttpParseError(`invalid header name: ${JSON.stringify(name)}`);
|
|
553
|
+
assertValidHeaderValue(name, value);
|
|
554
|
+
out += `${name}: ${value}\r\n`;
|
|
555
|
+
}
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Read to the blank line. `alreadyUsed` is the byte count of the start line,
|
|
560
|
+
* so the bound covers the whole head section rather than the headers alone.
|
|
561
|
+
*/
|
|
562
|
+
async function readHeaderSection(reader, maxHeaderBytes, alreadyUsed) {
|
|
563
|
+
const headers = [];
|
|
564
|
+
let used = alreadyUsed;
|
|
565
|
+
while (true) {
|
|
566
|
+
const remaining = maxHeaderBytes - used;
|
|
567
|
+
if (remaining <= 0) throw new HttpParseError(`head section exceeds ${maxHeaderBytes} bytes`);
|
|
568
|
+
const lineBytes = await reader.readLine(remaining);
|
|
569
|
+
used += lineBytes.byteLength + 2;
|
|
570
|
+
if (used > maxHeaderBytes) throw new HttpParseError(`head section exceeds ${maxHeaderBytes} bytes`);
|
|
571
|
+
if (lineBytes.byteLength === 0) return headers;
|
|
572
|
+
if (lineBytes[0] === SP || lineBytes[0] === HTAB) throw new HttpParseError("obs-fold header continuation is not accepted");
|
|
573
|
+
const line = decodeLatin1(lineBytes);
|
|
574
|
+
const colon = line.indexOf(":");
|
|
575
|
+
if (colon <= 0) throw new HttpParseError(`malformed header line: ${JSON.stringify(line)}`);
|
|
576
|
+
const name = line.slice(0, colon);
|
|
577
|
+
if (!isToken(name)) throw new HttpParseError(`invalid header name: ${JSON.stringify(name)}`);
|
|
578
|
+
const value = line.slice(colon + 1).replace(/^[ \t]+/, "").replace(/[ \t]+$/, "");
|
|
579
|
+
assertValidHeaderValue(name, value);
|
|
580
|
+
headers.push([name, value]);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function getAll(headers, name) {
|
|
584
|
+
const lower = name.toLowerCase();
|
|
585
|
+
return headers.filter(([k]) => k.toLowerCase() === lower).map(([, v]) => v);
|
|
586
|
+
}
|
|
587
|
+
function withoutHeaders(headers, names) {
|
|
588
|
+
const drop = new Set(names.map((n) => n.toLowerCase()));
|
|
589
|
+
return headers.filter(([k]) => !drop.has(k.toLowerCase()));
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Parse a `Content-Length` field into a single validated value.
|
|
593
|
+
*
|
|
594
|
+
* RFC 9110 §8.6 permits the value to be a comma-separated list of identical
|
|
595
|
+
* numbers (a relay may have appended one), so the list is folded; differing
|
|
596
|
+
* values are a refusal, not a choice. Shared by `resolveFraming` on decode and
|
|
597
|
+
* by the encoder's declared-length check, which previously carried its own
|
|
598
|
+
* copy that did NOT split on commas — so decode accepted `5, 5` while encode
|
|
599
|
+
* rejected it, and a relay that decoded then re-encoded threw.
|
|
600
|
+
*
|
|
601
|
+
* Returns undefined when the field is absent.
|
|
602
|
+
*/
|
|
603
|
+
function parseContentLength(values) {
|
|
604
|
+
if (values.length === 0) return void 0;
|
|
605
|
+
const unique = new Set(values.flatMap((v) => v.split(",").map((s) => s.trim())));
|
|
606
|
+
if (unique.size !== 1) throw new HttpParseError(`conflicting Content-Length values: ${[...unique].join(", ")}`);
|
|
607
|
+
const raw = [...unique][0];
|
|
608
|
+
if (!/^\d{1,15}$/.test(raw)) throw new HttpParseError(`invalid Content-Length: ${JSON.stringify(raw)}`);
|
|
609
|
+
return Number(raw);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Statuses that carry no body whatever the headers say (RFC 9110 §8.6): 1xx,
|
|
613
|
+
* 204 and 304. Defined once because the encoder must not frame a body for
|
|
614
|
+
* them and the decoder must not try to read one — two lists that agreed today
|
|
615
|
+
* and were free to drift tomorrow.
|
|
616
|
+
*
|
|
617
|
+
* A response to HEAD is also bodyless, but that depends on the request rather
|
|
618
|
+
* than the status, so callers test it separately.
|
|
619
|
+
*/
|
|
620
|
+
function isBodylessStatus(status) {
|
|
621
|
+
return status < 200 || status === 204 || status === 304;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* RFC 9112 §6.3, with every ambiguity turned into a refusal. In particular a
|
|
625
|
+
* message declaring both Content-Length and Transfer-Encoding is rejected
|
|
626
|
+
* rather than resolved — disagreeing on which one wins is request smuggling.
|
|
627
|
+
*/
|
|
628
|
+
function resolveFraming(headers, version) {
|
|
629
|
+
const te = getAll(headers, "transfer-encoding");
|
|
630
|
+
const cl = getAll(headers, "content-length");
|
|
631
|
+
if (te.length > 0 && cl.length > 0) throw new HttpParseError("message declares both Content-Length and Transfer-Encoding; refusing (request smuggling)");
|
|
632
|
+
if (te.length > 0) {
|
|
633
|
+
const encodings = te.join(",").split(",").map((s) => s.trim().toLowerCase()).filter((s) => s !== "");
|
|
634
|
+
if (encodings.length !== 1 || encodings[0] !== "chunked") throw new HttpParseError(`unsupported Transfer-Encoding: ${JSON.stringify(te.join(", "))}`);
|
|
635
|
+
if (version === "HTTP/1.0") throw new HttpParseError("Transfer-Encoding is not valid in HTTP/1.0");
|
|
636
|
+
return { kind: "chunked" };
|
|
637
|
+
}
|
|
638
|
+
const length = parseContentLength(cl);
|
|
639
|
+
if (length !== void 0) return {
|
|
640
|
+
kind: "length",
|
|
641
|
+
length
|
|
642
|
+
};
|
|
643
|
+
return { kind: "none" };
|
|
644
|
+
}
|
|
645
|
+
const CRLF = new Uint8Array([13, 10]);
|
|
646
|
+
const LAST_CHUNK = new Uint8Array([
|
|
647
|
+
48,
|
|
648
|
+
13,
|
|
649
|
+
10,
|
|
650
|
+
13,
|
|
651
|
+
10
|
|
652
|
+
]);
|
|
653
|
+
const encoder = new TextEncoder();
|
|
654
|
+
/**
|
|
655
|
+
* Chunk sizes are written in HEXADECIMAL — a 21-byte chunk is `15`. Writing
|
|
656
|
+
* them in decimal is the defect note 15 found in @libp2p/http, and it is the
|
|
657
|
+
* one that cannot be caught downstream: decimal digits are also valid hex, so
|
|
658
|
+
* a conforming parser silently reads the wrong length.
|
|
659
|
+
*
|
|
660
|
+
* Zero-length source chunks are skipped; a zero-sized chunk on the wire is the
|
|
661
|
+
* body terminator.
|
|
662
|
+
*/
|
|
663
|
+
async function* encodeChunked(body) {
|
|
664
|
+
for await (const chunk of body) {
|
|
665
|
+
if (chunk.byteLength === 0) continue;
|
|
666
|
+
yield encoder.encode(`${chunk.byteLength.toString(16)}\r\n`);
|
|
667
|
+
yield chunk;
|
|
668
|
+
yield CRLF;
|
|
669
|
+
}
|
|
670
|
+
yield LAST_CHUNK;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Decode a chunked body, yielding data chunks as they arrive. Nothing is
|
|
674
|
+
* accumulated: a chunk larger than the transport's frame is yielded in pieces.
|
|
675
|
+
*/
|
|
676
|
+
async function* decodeChunked(reader, maxLineBytes) {
|
|
677
|
+
while (true) {
|
|
678
|
+
const sizeLine = decodeLatin1(await reader.readLine(maxLineBytes));
|
|
679
|
+
const semicolon = sizeLine.indexOf(";");
|
|
680
|
+
const sizeText = semicolon === -1 ? sizeLine : sizeLine.slice(0, semicolon);
|
|
681
|
+
if (!/^[0-9a-fA-F]{1,32}$/.test(sizeText)) throw new HttpParseError(`invalid chunk size: ${JSON.stringify(sizeLine)}`);
|
|
682
|
+
const size = Number.parseInt(sizeText, 16);
|
|
683
|
+
if (!Number.isSafeInteger(size)) throw new HttpParseError(`chunk size is too large: ${JSON.stringify(sizeLine)}`);
|
|
684
|
+
if (size === 0) {
|
|
685
|
+
let used = 0;
|
|
686
|
+
while (true) {
|
|
687
|
+
const remaining = maxLineBytes - used;
|
|
688
|
+
if (remaining <= 0) throw new HttpParseError(`trailer section exceeds ${maxLineBytes} bytes`);
|
|
689
|
+
const lineBytes = await reader.readLine(remaining);
|
|
690
|
+
used += lineBytes.byteLength + 2;
|
|
691
|
+
if (used > maxLineBytes) throw new HttpParseError(`trailer section exceeds ${maxLineBytes} bytes`);
|
|
692
|
+
if (lineBytes.byteLength === 0) break;
|
|
693
|
+
}
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
let remaining = size;
|
|
697
|
+
while (remaining > 0) {
|
|
698
|
+
const part = await reader.readSome(remaining);
|
|
699
|
+
if (part === void 0) throw new HttpParseError(`chunk truncated: ${remaining} of ${size} bytes missing`);
|
|
700
|
+
remaining -= part.byteLength;
|
|
701
|
+
yield part;
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
if ((await reader.readLine(2)).byteLength !== 0) throw new HttpParseError("chunk data not terminated by CRLF");
|
|
705
|
+
} catch (err) {
|
|
706
|
+
if (err instanceof ByteStreamError) throw new HttpParseError("chunk data not terminated by CRLF");
|
|
707
|
+
throw err;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const VERSIONS = /* @__PURE__ */ new Set(["HTTP/1.1", "HTTP/1.0"]);
|
|
712
|
+
const ABSOLUTE_FORM = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
|
|
713
|
+
/**
|
|
714
|
+
* One message per Duplex call (ADR-0006), so bytes after a complete message
|
|
715
|
+
* are an error. Checked against what is ALREADY BUFFERED rather than by
|
|
716
|
+
* awaiting end-of-stream: a live socket from a keep-alive peer never reaches
|
|
717
|
+
* EOF, so awaiting one would hang instead of failing.
|
|
718
|
+
*/
|
|
719
|
+
function assertNoBufferedBytes(reader) {
|
|
720
|
+
const extra = reader.bufferedLength();
|
|
721
|
+
if (extra > 0) throw new HttpParseError(`${extra} trailing bytes after a complete message`);
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* The public error contract (I2): everything leaving `decodeRequest` /
|
|
725
|
+
* `decodeResponse` is an `HttpParseError`, whether the refusal happened
|
|
726
|
+
* synchronously (a malformed start line or header) or lazily while the body
|
|
727
|
+
* is later drained. `ByteStreamError` — the `ByteReader`'s own class, never
|
|
728
|
+
* exported — is the one thing converted here, message and all preserved via
|
|
729
|
+
* `cause`. Anything else is a genuine failure of the underlying source (a
|
|
730
|
+
* dropped socket, say) and must reach the caller unchanged: blanket-catching
|
|
731
|
+
* would hide that distinction.
|
|
732
|
+
*/
|
|
733
|
+
function toHttpParseError(err) {
|
|
734
|
+
if (err instanceof ByteStreamError) throw new HttpParseError(err.message, { cause: err });
|
|
735
|
+
throw err;
|
|
736
|
+
}
|
|
737
|
+
/** Applies `toHttpParseError` across the whole lifetime of a body generator. */
|
|
738
|
+
async function* convertBodyErrors(source) {
|
|
739
|
+
try {
|
|
740
|
+
yield* source;
|
|
741
|
+
} catch (err) {
|
|
742
|
+
toHttpParseError(err);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
async function* readBody(reader, framing, opts, noneMeansEof) {
|
|
746
|
+
if (framing.kind === "chunked") {
|
|
747
|
+
yield* decodeChunked(reader, opts.maxHeaderBytes);
|
|
748
|
+
assertNoBufferedBytes(reader);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (framing.kind === "length") {
|
|
752
|
+
let remaining = framing.length;
|
|
753
|
+
while (remaining > 0) {
|
|
754
|
+
const part = await reader.readSome(remaining);
|
|
755
|
+
if (part === void 0) throw new HttpParseError(`body truncated: ${remaining} of ${framing.length} bytes missing`);
|
|
756
|
+
remaining -= part.byteLength;
|
|
757
|
+
yield part;
|
|
758
|
+
}
|
|
759
|
+
assertNoBufferedBytes(reader);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (noneMeansEof) {
|
|
763
|
+
yield* reader.rest();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
assertNoBufferedBytes(reader);
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* `readLine`'s bound is best-effort — it is skipped when the line is already
|
|
770
|
+
* buffered — so a very long start line can reach these messages intact. Since
|
|
771
|
+
* a refusal is now echoed back to the sender in a 400 body, quote only enough
|
|
772
|
+
* to diagnose rather than reflecting the whole thing.
|
|
773
|
+
*/
|
|
774
|
+
function quoteLine(line) {
|
|
775
|
+
const MAX = 120;
|
|
776
|
+
return line.length <= MAX ? JSON.stringify(line) : `${JSON.stringify(line.slice(0, MAX))} (truncated from ${line.length} chars)`;
|
|
777
|
+
}
|
|
778
|
+
async function decodeRequest(input, opts) {
|
|
779
|
+
const reader = new ByteReader(input);
|
|
780
|
+
try {
|
|
781
|
+
const startBytes = await reader.readLine(opts.maxHeaderBytes);
|
|
782
|
+
const startLine = decodeLatin1(startBytes);
|
|
783
|
+
const parts = startLine.split(" ");
|
|
784
|
+
if (parts.length !== 3) throw new HttpParseError(`malformed request line: ${quoteLine(startLine)}`);
|
|
785
|
+
const [method, target, version] = parts;
|
|
786
|
+
if (!isToken(method)) throw new HttpParseError(`invalid method: ${JSON.stringify(method)}`);
|
|
787
|
+
if (!VERSIONS.has(version)) throw new HttpParseError(`unsupported HTTP version: ${JSON.stringify(version)}`);
|
|
788
|
+
if (target === "*") throw new HttpParseError("asterisk-form request target is not supported");
|
|
789
|
+
assertValidTarget(target);
|
|
790
|
+
const headers = await readHeaderSection(reader, opts.maxHeaderBytes, startBytes.byteLength + 2);
|
|
791
|
+
const hosts = getAll(headers, "host");
|
|
792
|
+
if (hosts.length > 1) throw new HttpParseError("multiple Host headers");
|
|
793
|
+
const host = hosts[0];
|
|
794
|
+
let url;
|
|
795
|
+
if (target.startsWith("/")) {
|
|
796
|
+
if (version === "HTTP/1.1" && host === void 0) throw new HttpParseError("HTTP/1.1 request has no Host header");
|
|
797
|
+
if (host !== void 0) assertValidHost(host);
|
|
798
|
+
url = `${opts.scheme}://${host ?? opts.host}${target}`;
|
|
799
|
+
} else if (ABSOLUTE_FORM.test(target)) url = target;
|
|
800
|
+
else throw new HttpParseError(`unsupported request target: ${JSON.stringify(target)}`);
|
|
801
|
+
try {
|
|
802
|
+
new URL(url);
|
|
803
|
+
} catch {
|
|
804
|
+
throw new HttpParseError(`decoded url is not a valid URL: ${JSON.stringify(url)}`);
|
|
805
|
+
}
|
|
806
|
+
return {
|
|
807
|
+
envelope: {
|
|
808
|
+
url,
|
|
809
|
+
method,
|
|
810
|
+
headers
|
|
811
|
+
},
|
|
812
|
+
body: convertBodyErrors(readBody(reader, resolveFraming(headers, version), opts, false))
|
|
813
|
+
};
|
|
814
|
+
} catch (err) {
|
|
815
|
+
toHttpParseError(err);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
async function decodeResponse(input, opts, method) {
|
|
819
|
+
const reader = new ByteReader(input);
|
|
820
|
+
try {
|
|
821
|
+
const startBytes = await reader.readLine(opts.maxHeaderBytes);
|
|
822
|
+
const startLine = decodeLatin1(startBytes);
|
|
823
|
+
const firstSp = startLine.indexOf(" ");
|
|
824
|
+
if (firstSp === -1) throw new HttpParseError(`malformed status line: ${quoteLine(startLine)}`);
|
|
825
|
+
const version = startLine.slice(0, firstSp);
|
|
826
|
+
if (!VERSIONS.has(version)) throw new HttpParseError(`unsupported HTTP version: ${JSON.stringify(version)}`);
|
|
827
|
+
const afterVersion = startLine.slice(firstSp + 1);
|
|
828
|
+
const secondSp = afterVersion.indexOf(" ");
|
|
829
|
+
const codeText = secondSp === -1 ? afterVersion : afterVersion.slice(0, secondSp);
|
|
830
|
+
if (!/^\d{3}$/.test(codeText)) throw new HttpParseError(`invalid status code: ${JSON.stringify(codeText)}`);
|
|
831
|
+
const status = Number(codeText);
|
|
832
|
+
const statusText = secondSp === -1 ? "" : afterVersion.slice(secondSp + 1);
|
|
833
|
+
const headers = await readHeaderSection(reader, opts.maxHeaderBytes, startBytes.byteLength + 2);
|
|
834
|
+
const bodyless = isBodylessStatus(status) || method.toUpperCase() === "HEAD";
|
|
835
|
+
const framing = bodyless ? { kind: "none" } : resolveFraming(headers, version);
|
|
836
|
+
return {
|
|
837
|
+
envelope: {
|
|
838
|
+
status,
|
|
839
|
+
statusText,
|
|
840
|
+
headers
|
|
841
|
+
},
|
|
842
|
+
body: convertBodyErrors(readBody(reader, framing, opts, !bodyless))
|
|
843
|
+
};
|
|
844
|
+
} catch (err) {
|
|
845
|
+
toHttpParseError(err);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/** Headers the codec owns: a caller-supplied copy is dropped and re-derived. */
|
|
849
|
+
const REQUEST_OWNED = [
|
|
850
|
+
"host",
|
|
851
|
+
"connection",
|
|
852
|
+
"transfer-encoding"
|
|
853
|
+
];
|
|
854
|
+
const RESPONSE_OWNED = ["connection", "transfer-encoding"];
|
|
855
|
+
/**
|
|
856
|
+
* Split a URL into an origin-form request target and an authority *without*
|
|
857
|
+
* going through `new URL()`. `URL` normalises percent-encoding and would
|
|
858
|
+
* re-serialise the target — which is the exact class of defect note 15 found
|
|
859
|
+
* in @libp2p/http, where rebuilding the request line silently dropped
|
|
860
|
+
* `url.search`. Here the target is a verbatim slice of the caller's string.
|
|
861
|
+
*/
|
|
862
|
+
function splitTarget(url, opts) {
|
|
863
|
+
if (url.startsWith("/")) {
|
|
864
|
+
assertValidTarget(url);
|
|
865
|
+
return {
|
|
866
|
+
target: url,
|
|
867
|
+
authority: opts.host
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
const match = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/([^/?#]*)([^#]*)/.exec(url);
|
|
871
|
+
if (!match) throw new HttpParseError(`cannot derive a request target from url: ${JSON.stringify(url)}`);
|
|
872
|
+
const rawAuthority = match[1];
|
|
873
|
+
if (rawAuthority === "") throw new HttpParseError(`url has no authority: ${JSON.stringify(url)}`);
|
|
874
|
+
const at = rawAuthority.lastIndexOf("@");
|
|
875
|
+
const authority = at === -1 ? rawAuthority : rawAuthority.slice(at + 1);
|
|
876
|
+
assertValidHost(authority);
|
|
877
|
+
const rawTarget = match[2];
|
|
878
|
+
const target = rawTarget === "" ? "/" : rawTarget.startsWith("/") ? rawTarget : `/${rawTarget}`;
|
|
879
|
+
assertValidTarget(target);
|
|
880
|
+
return {
|
|
881
|
+
target,
|
|
882
|
+
authority
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
async function* emitBody(body, declared) {
|
|
886
|
+
if (declared === void 0) {
|
|
887
|
+
yield* encodeChunked(body);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
let sent = 0;
|
|
891
|
+
for await (const chunk of body) {
|
|
892
|
+
if (chunk.byteLength === 0) continue;
|
|
893
|
+
sent += chunk.byteLength;
|
|
894
|
+
if (sent > declared) throw new HttpParseError(`body exceeds declared Content-Length ${declared} (${sent} bytes so far)`);
|
|
895
|
+
yield chunk;
|
|
896
|
+
}
|
|
897
|
+
if (sent !== declared) throw new HttpParseError(`body is ${sent} bytes but Content-Length declares ${declared}`);
|
|
898
|
+
}
|
|
899
|
+
async function* encodeRequest(env, body, opts) {
|
|
900
|
+
if (!isToken(env.method)) throw new HttpParseError(`invalid method: ${JSON.stringify(env.method)}`);
|
|
901
|
+
const { target, authority } = splitTarget(env.url, opts);
|
|
902
|
+
if (authority === "") throw new HttpParseError("no Host available: url has no authority and no host is configured");
|
|
903
|
+
const carried = withoutHeaders(env.headers, REQUEST_OWNED);
|
|
904
|
+
const declared = parseContentLength(getAll(carried, "content-length"));
|
|
905
|
+
let head = `${env.method} ${target} HTTP/1.1\r\n`;
|
|
906
|
+
head += `Host: ${authority}\r\n`;
|
|
907
|
+
head += encodeHeaderLines(carried);
|
|
908
|
+
head += "Connection: close\r\n";
|
|
909
|
+
if (body !== void 0 && declared === void 0) head += "Transfer-Encoding: chunked\r\n";
|
|
910
|
+
head += "\r\n";
|
|
911
|
+
yield encodeLatin1(head);
|
|
912
|
+
if (body === void 0) {
|
|
913
|
+
if (declared !== void 0 && declared !== 0) throw new HttpParseError(`body is 0 bytes but Content-Length declares ${declared}`);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
yield* emitBody(body, declared);
|
|
917
|
+
}
|
|
918
|
+
async function* encodeResponse(env, body, _opts, requestMethod) {
|
|
919
|
+
if (!Number.isInteger(env.status) || env.status < 100 || env.status > 599) throw new HttpParseError(`invalid status: ${env.status}`);
|
|
920
|
+
const reason = env.statusText ?? "";
|
|
921
|
+
assertValidStatusText(reason);
|
|
922
|
+
const bodylessStatus = isBodylessStatus(env.status);
|
|
923
|
+
const bodyless = bodylessStatus || requestMethod?.toUpperCase() === "HEAD";
|
|
924
|
+
const owned = bodylessStatus ? [...RESPONSE_OWNED, "content-length"] : RESPONSE_OWNED;
|
|
925
|
+
const carried = withoutHeaders(env.headers, owned);
|
|
926
|
+
const declared = parseContentLength(getAll(carried, "content-length"));
|
|
927
|
+
let head = `HTTP/1.1 ${env.status} ${reason}\r\n`;
|
|
928
|
+
head += encodeHeaderLines(carried);
|
|
929
|
+
head += "Connection: close\r\n";
|
|
930
|
+
if (!bodyless && body !== void 0 && declared === void 0) head += "Transfer-Encoding: chunked\r\n";
|
|
931
|
+
head += "\r\n";
|
|
932
|
+
yield encodeLatin1(head);
|
|
933
|
+
if (bodyless) {
|
|
934
|
+
await discard(body);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
if (body === void 0) {
|
|
938
|
+
if (declared !== void 0 && declared !== 0) throw new HttpParseError(`body is 0 bytes but Content-Length declares ${declared}`);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
yield* emitBody(body, declared);
|
|
942
|
+
}
|
|
943
|
+
function newHttpCodec(options = {}) {
|
|
944
|
+
const opts = {
|
|
945
|
+
scheme: options.scheme ?? "http",
|
|
946
|
+
host: options.host ?? "localhost",
|
|
947
|
+
maxHeaderBytes: options.maxHeaderBytes ?? 65536
|
|
948
|
+
};
|
|
949
|
+
return {
|
|
950
|
+
name: "http/1.1",
|
|
951
|
+
sniff: (byte) => isTokenChar(byte),
|
|
952
|
+
encodeRequest: (env, body) => encodeRequest(env, body, opts),
|
|
953
|
+
encodeResponse: (env, body, o) => encodeResponse(env, body, opts, o?.method),
|
|
954
|
+
decodeRequest: (input) => decodeRequest(input, opts),
|
|
955
|
+
decodeResponse: (input, o) => decodeResponse(input, opts, o.method)
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
const httpCodec = newHttpCodec();
|
|
959
|
+
/**
|
|
960
|
+
* Dispatches on byte 0. The formats are self-identifying — a JSON envelope
|
|
961
|
+
* always begins `{`, which is not a token character and so can never begin an
|
|
962
|
+
* HTTP start-line — so no negotiation handshake, magic prefix, or version byte
|
|
963
|
+
* is needed.
|
|
964
|
+
*
|
|
965
|
+
* This is what makes a mixed-version peer pair safe: readers accept either
|
|
966
|
+
* format, so the two ends can be upgraded in any order.
|
|
967
|
+
*/
|
|
968
|
+
function newSniffingCodec(options) {
|
|
969
|
+
const { write, accept } = options;
|
|
970
|
+
async function pick(input) {
|
|
971
|
+
const reader = new ByteReader(input);
|
|
972
|
+
const first = await reader.peekByte();
|
|
973
|
+
if (first === void 0) throw new HttpParseError("sniff: stream ended before any bytes arrived");
|
|
974
|
+
const codec = accept.find((c) => c.sniff(first));
|
|
975
|
+
if (!codec) throw new HttpParseError(`sniff: no accepted codec recognises a message starting with byte 0x${first.toString(16).padStart(2, "0")}`);
|
|
976
|
+
return {
|
|
977
|
+
codec,
|
|
978
|
+
input: reader.rest()
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
return {
|
|
982
|
+
name: `sniff(write=${write.name}; accept=${accept.map((c) => c.name).join(",")})`,
|
|
983
|
+
sniff: (byte) => accept.some((c) => c.sniff(byte)),
|
|
984
|
+
encodeRequest: (env, body) => write.encodeRequest(env, body),
|
|
985
|
+
encodeResponse: (env, body, o) => write.encodeResponse(env, body, o),
|
|
986
|
+
decodeRequest: async (input) => {
|
|
987
|
+
const picked = await pick(input);
|
|
988
|
+
try {
|
|
989
|
+
return {
|
|
990
|
+
...await picked.codec.decodeRequest(picked.input),
|
|
991
|
+
codec: picked.codec
|
|
992
|
+
};
|
|
993
|
+
} catch (error) {
|
|
994
|
+
if (error !== null && typeof error === "object") error.codec = picked.codec;
|
|
995
|
+
throw error;
|
|
996
|
+
}
|
|
997
|
+
},
|
|
998
|
+
decodeResponse: async (input, o) => {
|
|
999
|
+
const picked = await pick(input);
|
|
1000
|
+
return picked.codec.decodeResponse(picked.input, o);
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Writes HTTP/1.1; accepts HTTP/1.1 or the legacy JSON envelope. Used whenever
|
|
1006
|
+
* no `codec` option is supplied.
|
|
1007
|
+
*/
|
|
1008
|
+
const defaultCodec = newSniffingCodec({
|
|
1009
|
+
write: httpCodec,
|
|
1010
|
+
accept: [httpCodec, jsonEnvelopeCodec]
|
|
1011
|
+
});
|
|
1012
|
+
/** Carries the serialized JS error between two webrun peers. */
|
|
1013
|
+
const PEER_ERROR_HEADER = "x-webrun-error";
|
|
1014
|
+
/** Hard cap on the peer-error header (M3): see `encodeErrorResponse`. */
|
|
1015
|
+
const MAX_DETAIL_CHARS = 4096;
|
|
1016
|
+
/**
|
|
1017
|
+
* JSON with every non-printable-ASCII character escaped, so the result is a
|
|
1018
|
+
* legal latin-1 header value whatever the error message contained.
|
|
1019
|
+
*/
|
|
1020
|
+
function asciiJson(value) {
|
|
1021
|
+
return JSON.stringify(value).replace(/[^\x20-\x7E]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Decision 13: a real HTTP peer cannot receive a JavaScript exception, only a
|
|
1025
|
+
* response. So an uncaught handler error becomes a conforming 500 whose body
|
|
1026
|
+
* carries the message, with the serialized error in a namespaced header that
|
|
1027
|
+
* another webrun peer re-throws from.
|
|
1028
|
+
*/
|
|
1029
|
+
function encodeErrorResponse(codec, error, method, status = 500, statusText = "Internal Server Error") {
|
|
1030
|
+
const serialized = serializeError(error);
|
|
1031
|
+
let detail = asciiJson(serialized);
|
|
1032
|
+
if (detail.length > MAX_DETAIL_CHARS) detail = asciiJson({ message: serialized.message });
|
|
1033
|
+
if (detail.length > MAX_DETAIL_CHARS) detail = detail.slice(0, MAX_DETAIL_CHARS);
|
|
1034
|
+
const message = serialized.message ?? statusText;
|
|
1035
|
+
return codec.encodeResponse({
|
|
1036
|
+
status,
|
|
1037
|
+
statusText,
|
|
1038
|
+
headers: [["Content-Type", "text/plain; charset=utf-8"], [PEER_ERROR_HEADER, detail]]
|
|
1039
|
+
}, [new TextEncoder().encode(message)], { method });
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* `output` is the `Duplex` call's own generator — one logical call, per the
|
|
1043
|
+
* `Duplex` contract in `@statewalker/webrun-streams`: "Consumer `.return()`
|
|
1044
|
+
* on the output → producer's `finally` runs." On a mux transport
|
|
1045
|
+
* (`emulateMux`), that `finally` is what frees the stream-table slot; skip it
|
|
1046
|
+
* and every peer-error response leaks one slot, unboundedly, until the mux
|
|
1047
|
+
* itself is exhausted (`maxStreams` reached, every further call rejected).
|
|
1048
|
+
*
|
|
1049
|
+
* Cancelling `output` here — not inside the codec's own decode logic — is
|
|
1050
|
+
* deliberate. `codec.decodeResponse` already pulled from `output` to read the
|
|
1051
|
+
* head before this function runs, so unlike the body case above there's no
|
|
1052
|
+
* suspended-start no-op to worry about. And it's safe specifically *because*
|
|
1053
|
+
* this is the client's own response-only generator: the request was already
|
|
1054
|
+
* fully sent before we got here, so there is nothing left to write on it.
|
|
1055
|
+
* The equivalent is NOT safe inside `src/http1/decode.ts`'s `ByteReader` —
|
|
1056
|
+
* that code also runs when a caller wires `codec.decodeRequest`/
|
|
1057
|
+
* `decodeResponse` directly onto a raw bidirectional socket (see
|
|
1058
|
+
* `tests/http1-node-interop.test.ts`), where the *same* object is read from
|
|
1059
|
+
* and then written back to (a server reads the request, then replies on the
|
|
1060
|
+
* same socket); cancelling the read side there tears down the whole
|
|
1061
|
+
* connection out from under the pending write. That was tried and reverted —
|
|
1062
|
+
* see the Task 11 report for the "socket hang up" failure it caused.
|
|
1063
|
+
*/
|
|
1064
|
+
async function throwIfPeerError(result, output) {
|
|
1065
|
+
const found = result.envelope.headers.find(([k]) => k.toLowerCase() === PEER_ERROR_HEADER);
|
|
1066
|
+
if (!found) return;
|
|
1067
|
+
await discard(result.body);
|
|
1068
|
+
try {
|
|
1069
|
+
await output.return?.(void 0);
|
|
1070
|
+
} catch {}
|
|
1071
|
+
let payload;
|
|
1072
|
+
try {
|
|
1073
|
+
payload = JSON.parse(found[1]);
|
|
1074
|
+
} catch {
|
|
1075
|
+
payload = { message: found[1] };
|
|
1076
|
+
}
|
|
1077
|
+
throw deserializeError(payload);
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Initiate an HTTP call over a `Duplex`. The caller's `call: Duplex` is
|
|
1081
|
+
* obtained from any `webrun-streams-*` adapter's `connect`. Returns the
|
|
1082
|
+
* response envelope and an async iterable over the response body bytes.
|
|
1083
|
+
*
|
|
1084
|
+
* The call is one logical Duplex invocation; multiplexing of concurrent
|
|
1085
|
+
* calls is the adapter's concern (native or `emulateMux`).
|
|
1086
|
+
*/
|
|
1087
|
+
async function httpFetch(call, env, body, options = {}) {
|
|
1088
|
+
const codec = options.codec ?? defaultCodec;
|
|
1089
|
+
const output = call(codec.encodeRequest(env, body));
|
|
1090
|
+
const decoded = await codec.decodeResponse(output, { method: env.method });
|
|
1091
|
+
const release = async () => {
|
|
1092
|
+
try {
|
|
1093
|
+
await output.return?.(void 0);
|
|
1094
|
+
} catch {}
|
|
1095
|
+
};
|
|
1096
|
+
const result = {
|
|
1097
|
+
envelope: decoded.envelope,
|
|
1098
|
+
body: releasingOnExit(decoded.body, release),
|
|
1099
|
+
release
|
|
1100
|
+
};
|
|
1101
|
+
await throwIfPeerError(result, output);
|
|
1102
|
+
return result;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* `body`, but leaving it before its end releases the call.
|
|
1106
|
+
*
|
|
1107
|
+
* The body the codec hands back is a generator of its own over a `ByteReader`,
|
|
1108
|
+
* and `.return()` on it never reaches the call's `output` (see
|
|
1109
|
+
* `throwIfPeerError` for why the codec must not do that itself). So a caller
|
|
1110
|
+
* that stopped reading — an aborted `fetch`, a cancelled response stream —
|
|
1111
|
+
* left the peer's producer parked forever, holding its handler open. Safe here
|
|
1112
|
+
* for the same reason it is safe in `throwIfPeerError`: `output` is this
|
|
1113
|
+
* call's own response-only generator, and the request was fully sent.
|
|
1114
|
+
*/
|
|
1115
|
+
async function* releasingOnExit(body, release) {
|
|
1116
|
+
let completed = false;
|
|
1117
|
+
try {
|
|
1118
|
+
yield* body;
|
|
1119
|
+
completed = true;
|
|
1120
|
+
} finally {
|
|
1121
|
+
if (!completed) await release();
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Wrap an HTTP handler as a `Duplex` so it can be registered with any
|
|
1126
|
+
* `webrun-streams-*` adapter's `serve`.
|
|
1127
|
+
*/
|
|
1128
|
+
function httpServe(handler, options = {}) {
|
|
1129
|
+
const codec = options.codec ?? defaultCodec;
|
|
1130
|
+
return async function* httpHandlerDuplex(input) {
|
|
1131
|
+
let decoded;
|
|
1132
|
+
try {
|
|
1133
|
+
decoded = await codec.decodeRequest(input);
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
if (!(error instanceof HttpParseError)) throw error;
|
|
1136
|
+
yield* encodeErrorResponse(error.codec ?? codec, error, "GET", 400, "Bad Request");
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
const replyCodec = decoded.codec ?? codec;
|
|
1140
|
+
let result;
|
|
1141
|
+
try {
|
|
1142
|
+
result = await handler(decoded.envelope, decoded.body);
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
yield* encodeErrorResponse(replyCodec, error, decoded.envelope.method);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
let completed = false;
|
|
1148
|
+
try {
|
|
1149
|
+
yield* replyCodec.encodeResponse(result.envelope, result.body, { method: decoded.envelope.method });
|
|
1150
|
+
completed = true;
|
|
1151
|
+
} finally {
|
|
1152
|
+
if (!completed) try {
|
|
1153
|
+
await result.release?.();
|
|
1154
|
+
} catch {}
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* The one place that knows a runtime may not implement request body streams,
|
|
1160
|
+
* and the buffering both fallbacks need. `fetch.ts` and `http-stubs.ts` each
|
|
1161
|
+
* carry a client and a server direction that must agree on the answer, so the
|
|
1162
|
+
* predicate lives here rather than being written out four times.
|
|
1163
|
+
*/
|
|
1164
|
+
/**
|
|
1165
|
+
* Whether `Request.prototype` exposes a `body` accessor. Two call sites read
|
|
1166
|
+
* that property, and two more depend on the constructor accepting a
|
|
1167
|
+
* `ReadableStream` as `init.body`; this predicate answers for all four.
|
|
1168
|
+
*
|
|
1169
|
+
* What was verified, and all that is claimed here: Firefox (146 at the time of
|
|
1170
|
+
* writing) has *neither*, and Chromium and Node have *both*. On Firefox it is
|
|
1171
|
+
* not that `body` is `undefined` on the instance —
|
|
1172
|
+
* `Object.getOwnPropertyDescriptor(Request.prototype, "body")` is `null`, the
|
|
1173
|
+
* accessor is genuinely absent — and because a `ReadableStream` is then not a
|
|
1174
|
+
* recognised `BodyInit`, the constructor falls through to the string branch and
|
|
1175
|
+
* stores the literal text `[object ReadableStream]`.
|
|
1176
|
+
*
|
|
1177
|
+
* The two halves are NOT guaranteed to ship together, so do not read this as a
|
|
1178
|
+
* test for "request streams" in general. Safari is the counterexample: it has
|
|
1179
|
+
* had `Request.body` since 11.1 but only accepts a stream as `init.body` from
|
|
1180
|
+
* Technology Preview 250, so a shipping Safari has the reader half without the
|
|
1181
|
+
* upload half and this returns `true` there. That looks benign — WebKit appears
|
|
1182
|
+
* to store the stream on the `Request` rather than stringify it, and its error
|
|
1183
|
+
* comes from `fetch()`, which this package never calls on the objects it builds
|
|
1184
|
+
* — but it is untested, and it is the case to look at first if a Safari report
|
|
1185
|
+
* arrives. The sharper probe, if one is ever needed, is whether
|
|
1186
|
+
* `new Request(url, {method:"POST", body:new ReadableStream(), duplex:"half"})`
|
|
1187
|
+
* has a `content-type` of `text/plain;charset=UTF-8` (stringified) or `null`
|
|
1188
|
+
* (stored); it is not used here because it costs a `Request` and a
|
|
1189
|
+
* `ReadableStream` per call and agrees with the descriptor check on every
|
|
1190
|
+
* runtime measured.
|
|
1191
|
+
*
|
|
1192
|
+
* A capability check, never a user-agent test: the question is what this
|
|
1193
|
+
* runtime does, and the answer flips on its own the day Firefox ships request
|
|
1194
|
+
* streams. Evaluated per call rather than cached at module load so that a test
|
|
1195
|
+
* can install a `Request` without the capability and exercise the real branch
|
|
1196
|
+
* under Node.
|
|
1197
|
+
*/
|
|
1198
|
+
function supportsRequestStreams() {
|
|
1199
|
+
return typeof Request === "function" && Object.getOwnPropertyDescriptor(Request.prototype, "body") != null;
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Drain a body iterable into one contiguous buffer. Only the fallbacks need it.
|
|
1203
|
+
* Allocates its own buffer rather than reusing `bytes.ts`'s `concatChunks`,
|
|
1204
|
+
* which passes a single chunk straight through: that chunk is a view onto the
|
|
1205
|
+
* decoder's own read buffer, and the result here is handed to `new Request` and
|
|
1206
|
+
* outlives the decode. The allocation is also what makes it a
|
|
1207
|
+
* `Uint8Array<ArrayBuffer>`, which `BodyInit` accepts and the looser
|
|
1208
|
+
* `Uint8Array<ArrayBufferLike>` does not.
|
|
1209
|
+
*/
|
|
1210
|
+
async function collectBytes(source) {
|
|
1211
|
+
const parts = [];
|
|
1212
|
+
let total = 0;
|
|
1213
|
+
for await (const chunk of source) {
|
|
1214
|
+
if (chunk.byteLength === 0) continue;
|
|
1215
|
+
parts.push(chunk);
|
|
1216
|
+
total += chunk.byteLength;
|
|
1217
|
+
}
|
|
1218
|
+
const out = new Uint8Array(total);
|
|
1219
|
+
let offset = 0;
|
|
1220
|
+
for (const part of parts) {
|
|
1221
|
+
out.set(part, offset);
|
|
1222
|
+
offset += part.byteLength;
|
|
1223
|
+
}
|
|
1224
|
+
return out;
|
|
1225
|
+
}
|
|
1226
|
+
const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
|
|
1227
|
+
101,
|
|
1228
|
+
103,
|
|
1229
|
+
204,
|
|
1230
|
+
205,
|
|
1231
|
+
304
|
|
1232
|
+
]);
|
|
1233
|
+
/**
|
|
1234
|
+
* Connection-scoped headers. The codec surfaces them verbatim (decision 12),
|
|
1235
|
+
* but they are meaningless to a `Request`/`Response`, and re-emitting them
|
|
1236
|
+
* from a relay would corrupt its framing.
|
|
1237
|
+
*/
|
|
1238
|
+
const HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
1239
|
+
"connection",
|
|
1240
|
+
"host",
|
|
1241
|
+
"keep-alive",
|
|
1242
|
+
"proxy-authenticate",
|
|
1243
|
+
"proxy-authorization",
|
|
1244
|
+
"te",
|
|
1245
|
+
"trailer",
|
|
1246
|
+
"transfer-encoding",
|
|
1247
|
+
"upgrade"
|
|
1248
|
+
]);
|
|
1249
|
+
function forwardableHeaders(headers) {
|
|
1250
|
+
return headers.filter(([name]) => !HOP_BY_HOP.has(name.toLowerCase()));
|
|
1251
|
+
}
|
|
1252
|
+
function headersToArray(headers) {
|
|
1253
|
+
const out = [];
|
|
1254
|
+
headers.forEach((value, key) => {
|
|
1255
|
+
out.push([key, value]);
|
|
1256
|
+
});
|
|
1257
|
+
return out;
|
|
1258
|
+
}
|
|
1259
|
+
async function* readableToAsyncIterable(stream) {
|
|
1260
|
+
if (!stream) return;
|
|
1261
|
+
const reader = stream.getReader();
|
|
1262
|
+
let consumed = false;
|
|
1263
|
+
try {
|
|
1264
|
+
while (true) {
|
|
1265
|
+
const { done, value } = await reader.read();
|
|
1266
|
+
if (done) {
|
|
1267
|
+
consumed = true;
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
if (value) yield value;
|
|
1271
|
+
}
|
|
1272
|
+
} finally {
|
|
1273
|
+
if (!consumed) try {
|
|
1274
|
+
await reader.cancel();
|
|
1275
|
+
} catch {}
|
|
1276
|
+
try {
|
|
1277
|
+
reader.releaseLock();
|
|
1278
|
+
} catch {}
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
function asyncIterableToReadable(iter) {
|
|
1282
|
+
const it = iter[Symbol.asyncIterator]();
|
|
1283
|
+
return new ReadableStream({
|
|
1284
|
+
async pull(controller) {
|
|
1285
|
+
try {
|
|
1286
|
+
const { done, value } = await it.next();
|
|
1287
|
+
if (done) {
|
|
1288
|
+
controller.close();
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
controller.enqueue(value);
|
|
1292
|
+
} catch (err) {
|
|
1293
|
+
controller.error(err);
|
|
1294
|
+
}
|
|
1295
|
+
},
|
|
1296
|
+
async cancel() {
|
|
1297
|
+
await it.return?.();
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Run a `Request` through a `Duplex` call and reconstruct the `Response` on
|
|
1303
|
+
* the other side. The request's `signal` is plumbed into the body iteration —
|
|
1304
|
+
* abort terminates the underlying call.
|
|
1305
|
+
*/
|
|
1306
|
+
async function fetchOverDuplex(call, request, options = {}) {
|
|
1307
|
+
if (request.signal?.aborted) throw abortReason$1(request.signal);
|
|
1308
|
+
const env = {
|
|
1309
|
+
url: request.url,
|
|
1310
|
+
method: request.method,
|
|
1311
|
+
headers: headersToArray(request.headers)
|
|
1312
|
+
};
|
|
1313
|
+
let body;
|
|
1314
|
+
if (request.body != null) body = readableToAsyncIterable(request.body);
|
|
1315
|
+
else if (!supportsRequestStreams()) {
|
|
1316
|
+
const buffered = new Uint8Array(await request.arrayBuffer());
|
|
1317
|
+
if (buffered.byteLength > 0) body = [buffered];
|
|
1318
|
+
}
|
|
1319
|
+
const { envelope, body: respBody, release } = await httpFetch(call, env, body, options);
|
|
1320
|
+
const responseInit = {
|
|
1321
|
+
status: envelope.status,
|
|
1322
|
+
statusText: envelope.statusText,
|
|
1323
|
+
headers: forwardableHeaders(envelope.headers)
|
|
1324
|
+
};
|
|
1325
|
+
if (request.method === "HEAD" || request.method === "OPTIONS" || NULL_BODY_STATUSES.has(envelope.status)) {
|
|
1326
|
+
await release?.();
|
|
1327
|
+
return new Response(null, responseInit);
|
|
1328
|
+
}
|
|
1329
|
+
return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
|
|
1333
|
+
* registered with any `webrun-streams-*` adapter's `serve`.
|
|
1334
|
+
*/
|
|
1335
|
+
function serveFetchOverDuplex(handler, options = {}) {
|
|
1336
|
+
return httpServe(async (env, body) => {
|
|
1337
|
+
const reqInit = {
|
|
1338
|
+
method: env.method,
|
|
1339
|
+
headers: forwardableHeaders(env.headers)
|
|
1340
|
+
};
|
|
1341
|
+
if (env.method !== "GET" && env.method !== "HEAD") {
|
|
1342
|
+
if (supportsRequestStreams()) {
|
|
1343
|
+
reqInit.body = asyncIterableToReadable(body);
|
|
1344
|
+
reqInit.duplex = "half";
|
|
1345
|
+
} else reqInit.body = await collectBytes(body);
|
|
1346
|
+
}
|
|
1347
|
+
const response = await handler(new Request(env.url, reqInit));
|
|
1348
|
+
const respEnv = {
|
|
1349
|
+
status: response.status,
|
|
1350
|
+
statusText: response.statusText,
|
|
1351
|
+
headers: headersToArray(response.headers)
|
|
1352
|
+
};
|
|
1353
|
+
if (env.method === "HEAD" || env.method === "OPTIONS" || NULL_BODY_STATUSES.has(response.status)) {
|
|
1354
|
+
await response.body?.cancel();
|
|
1355
|
+
return {
|
|
1356
|
+
envelope: respEnv,
|
|
1357
|
+
body: void 0
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
return {
|
|
1361
|
+
envelope: respEnv,
|
|
1362
|
+
body: response.body ? readableToAsyncIterable(response.body) : void 0,
|
|
1363
|
+
release: () => response.body?.cancel()
|
|
1364
|
+
};
|
|
1365
|
+
}, options);
|
|
1366
|
+
}
|
|
1367
|
+
async function* withAbort(iter, signal) {
|
|
1368
|
+
if (!signal) {
|
|
1369
|
+
yield* iter;
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
for await (const chunk of iter) {
|
|
1373
|
+
if (signal.aborted) throw abortReason$1(signal);
|
|
1374
|
+
yield chunk;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
function abortReason$1(signal) {
|
|
1378
|
+
const reason = signal.reason;
|
|
1379
|
+
if (reason instanceof Error) return reason;
|
|
1380
|
+
const err = new Error(reason === void 0 ? "Aborted" : String(reason));
|
|
1381
|
+
err.name = "AbortError";
|
|
1382
|
+
return err;
|
|
1383
|
+
}
|
|
1384
|
+
//#endregion
|
|
1385
|
+
//#region ../webrun-rpc/dist/index.js
|
|
1386
|
+
const closeSignals = /* @__PURE__ */ new WeakMap();
|
|
1387
|
+
/**
|
|
1388
|
+
* Internal: register a port's close signal. Called once by `bindBytesToPort`.
|
|
1389
|
+
*
|
|
1390
|
+
* @internal
|
|
1391
|
+
*/
|
|
1392
|
+
function setPortCloseSignal(port, signal) {
|
|
1393
|
+
closeSignals.set(port, signal);
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Return the AbortSignal that fires when `port`'s transport closes, or
|
|
1397
|
+
* `undefined` if `port` is not transport-backed (e.g., a raw
|
|
1398
|
+
* `MessageChannel().port1`).
|
|
1399
|
+
*/
|
|
1400
|
+
function getPortCloseSignal(port) {
|
|
1401
|
+
return closeSignals.get(port);
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* Pass as `timeout` to install no deadline at all. Used by the stream tier,
|
|
1405
|
+
* where the deadline belongs to the stream rather than to one chunk (spec D8):
|
|
1406
|
+
* a slow consumer is throttled, never failed.
|
|
1407
|
+
*/
|
|
1408
|
+
const NO_TIMEOUT = Number.POSITIVE_INFINITY;
|
|
1409
|
+
/**
|
|
1410
|
+
* Asynchronous request/response over any `MessageTarget`.
|
|
1411
|
+
*
|
|
1412
|
+
* Sends `params` to the peer listening with `listenPort`, waits up to
|
|
1413
|
+
* `timeout` ms for a matching reply, and either resolves with the result or
|
|
1414
|
+
* rejects with the deserialised error.
|
|
1415
|
+
*/
|
|
1416
|
+
function callPort(port, params, { timeout = 1e3, channelName = "", log = () => {}, newCallId = () => `call-${Date.now()}-${String(Math.random()).substring(2)}`, signal } = {}) {
|
|
1417
|
+
const callId = newCallId();
|
|
1418
|
+
log("[callPort]", {
|
|
1419
|
+
channelName,
|
|
1420
|
+
callId,
|
|
1421
|
+
params
|
|
1422
|
+
});
|
|
1423
|
+
const combinedSignal = combineSignals(signal, getPortCloseSignal(port));
|
|
1424
|
+
let timerId;
|
|
1425
|
+
let onMessage;
|
|
1426
|
+
let onAbort;
|
|
1427
|
+
const promise = new Promise((resolve, reject) => {
|
|
1428
|
+
if (combinedSignal?.aborted) {
|
|
1429
|
+
reject(abortReason(combinedSignal));
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
if (Number.isFinite(timeout) && timeout > 0) timerId = setTimeout(() => reject(/* @__PURE__ */ new Error(`Call timeout. CallId: "${callId}".`)), timeout);
|
|
1433
|
+
onMessage = (event) => {
|
|
1434
|
+
const data = event.data;
|
|
1435
|
+
if (!data) return;
|
|
1436
|
+
if (data.channelName !== channelName) return;
|
|
1437
|
+
if (data.callId !== callId) return;
|
|
1438
|
+
if (data.type === "response:error") reject(deserializeError(data.error));
|
|
1439
|
+
else if (data.type === "response:result") resolve(data.result);
|
|
1440
|
+
};
|
|
1441
|
+
port.addEventListener("message", onMessage);
|
|
1442
|
+
if (combinedSignal) {
|
|
1443
|
+
onAbort = () => reject(abortReason(combinedSignal));
|
|
1444
|
+
combinedSignal.addEventListener("abort", onAbort, { once: true });
|
|
1445
|
+
}
|
|
1446
|
+
});
|
|
1447
|
+
promise.catch(() => {}).finally(() => {
|
|
1448
|
+
if (timerId !== void 0) clearTimeout(timerId);
|
|
1449
|
+
if (onMessage) port.removeEventListener("message", onMessage);
|
|
1450
|
+
if (onAbort && combinedSignal) combinedSignal.removeEventListener("abort", onAbort);
|
|
1451
|
+
});
|
|
1452
|
+
port.postMessage({
|
|
1453
|
+
type: "request",
|
|
1454
|
+
channelName,
|
|
1455
|
+
callId,
|
|
1456
|
+
params
|
|
1457
|
+
});
|
|
1458
|
+
return promise;
|
|
1459
|
+
}
|
|
1460
|
+
function abortReason(signal) {
|
|
1461
|
+
const reason = signal.reason;
|
|
1462
|
+
if (reason instanceof Error) return reason;
|
|
1463
|
+
const err = new Error(reason === void 0 ? "Aborted" : String(reason));
|
|
1464
|
+
err.name = "AbortError";
|
|
1465
|
+
return err;
|
|
1466
|
+
}
|
|
1467
|
+
function combineSignals(...signals) {
|
|
1468
|
+
const live = signals.filter((s) => s !== void 0);
|
|
1469
|
+
if (live.length === 0) return void 0;
|
|
1470
|
+
if (live.length === 1) return live[0];
|
|
1471
|
+
return AbortSignal.any(live);
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Installs `handler` as the server side of a `callPort` / `listenPort`
|
|
1475
|
+
* request/response pair on `port`.
|
|
1476
|
+
*
|
|
1477
|
+
* Returns a cleanup function that removes the listener.
|
|
1478
|
+
*/
|
|
1479
|
+
function listenPort(port, handler, { channelName = "", log = () => {} } = {}) {
|
|
1480
|
+
const onMessage = async (event) => {
|
|
1481
|
+
const data = event.data;
|
|
1482
|
+
if (!data || data.channelName !== channelName || data.type !== "request") return;
|
|
1483
|
+
const { callId, params } = data;
|
|
1484
|
+
log("[listenPort]", {
|
|
1485
|
+
channelName,
|
|
1486
|
+
callId,
|
|
1487
|
+
params
|
|
1488
|
+
});
|
|
1489
|
+
let result;
|
|
1490
|
+
let error;
|
|
1491
|
+
let type;
|
|
1492
|
+
try {
|
|
1493
|
+
result = await handler(params);
|
|
1494
|
+
type = "response:result";
|
|
1495
|
+
} catch (e) {
|
|
1496
|
+
error = serializeError(e);
|
|
1497
|
+
type = "response:error";
|
|
1498
|
+
}
|
|
1499
|
+
port.postMessage({
|
|
1500
|
+
callId,
|
|
1501
|
+
channelName,
|
|
1502
|
+
type,
|
|
1503
|
+
result,
|
|
1504
|
+
error
|
|
1505
|
+
});
|
|
1506
|
+
};
|
|
1507
|
+
port.addEventListener("message", onMessage);
|
|
1508
|
+
return () => port.removeEventListener("message", onMessage);
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Wraps an async iterable so that an `AbortSignal` firing causes the wrapper
|
|
1512
|
+
* to return cleanly, forwarding `return()` to the underlying iterator so the
|
|
1513
|
+
* producer (e.g., a user-supplied generator) sees its own `finally` blocks
|
|
1514
|
+
* run immediately rather than waiting for the next yield.
|
|
1515
|
+
*/
|
|
1516
|
+
async function* throughAbort(input, signal) {
|
|
1517
|
+
const iter = input[Symbol.asyncIterator] ? input[Symbol.asyncIterator]() : input[Symbol.iterator]();
|
|
1518
|
+
const onAbort = () => {
|
|
1519
|
+
iter.return?.(void 0);
|
|
1520
|
+
};
|
|
1521
|
+
if (signal.aborted) {
|
|
1522
|
+
iter.return?.(void 0);
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1526
|
+
try {
|
|
1527
|
+
while (true) {
|
|
1528
|
+
const r = await iter.next();
|
|
1529
|
+
if (r.done) return;
|
|
1530
|
+
if (signal.aborted) return;
|
|
1531
|
+
yield r.value;
|
|
1532
|
+
}
|
|
1533
|
+
} finally {
|
|
1534
|
+
signal.removeEventListener("abort", onAbort);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* The `type` of the out-of-band notice a side posts when it abandons a stream.
|
|
1539
|
+
*
|
|
1540
|
+
* Layer 1's `close` is not observable to layer 2 — a closed virtual port drops
|
|
1541
|
+
* its listeners silently and is indistinguishable from a working port nobody
|
|
1542
|
+
* is answering — so the peer would otherwise wait forever. Exported because
|
|
1543
|
+
* tests and adapters assert on it.
|
|
1544
|
+
*/
|
|
1545
|
+
const STREAM_ABORT = "webrun-rpc:stream-abort";
|
|
1546
|
+
/** Caller's input travels on this channel; the handler listens on it. */
|
|
1547
|
+
const CHANNEL_IN = "in";
|
|
1548
|
+
/** The handler's output travels on this channel; the caller listens on it. */
|
|
1549
|
+
const CHANNEL_OUT = "out";
|
|
1550
|
+
/**
|
|
1551
|
+
* One port in, one `Duplex` out (spec D9).
|
|
1552
|
+
*
|
|
1553
|
+
* The returned `Duplex` runs a single stream on `port`: the caller's `input`
|
|
1554
|
+
* is sent chunk by chunk with `callPort`, and the handler's output arrives the
|
|
1555
|
+
* same way on the other channel. Within each direction the next chunk is never
|
|
1556
|
+
* sent until the previous one has been delivered *and* pulled past by the
|
|
1557
|
+
* consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
|
|
1558
|
+
* `listenPort` withholds it until then.
|
|
1559
|
+
*
|
|
1560
|
+
* A stream port carries exactly one invocation. To make several calls, open
|
|
1561
|
+
* several ports: `mux.openPort({ kind: "stream" })` per call.
|
|
1562
|
+
*/
|
|
1563
|
+
function duplexOverPort(port, options = {}) {
|
|
1564
|
+
return (input) => runCallerSide(port, input, options);
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Installs `handler` as the serving side of one stream on `port`. Returns an
|
|
1568
|
+
* idempotent teardown that abandons the stream and notifies the peer.
|
|
1569
|
+
*/
|
|
1570
|
+
function serveDuplexOverPort(port, handler, options = {}) {
|
|
1571
|
+
const controller = new AbortController();
|
|
1572
|
+
const notice = installAbortNotice(port, controller);
|
|
1573
|
+
abortOnPortClose(port, controller);
|
|
1574
|
+
const clock = installStreamTimeout(controller, options.timeout);
|
|
1575
|
+
const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch);
|
|
1576
|
+
let output;
|
|
1577
|
+
try {
|
|
1578
|
+
output = handler(inbound.stream);
|
|
1579
|
+
} catch (err) {
|
|
1580
|
+
const pump = sendChunks(port, CHANNEL_OUT, failing(err), options, controller.signal, clock.touch);
|
|
1581
|
+
pump.catch(() => {});
|
|
1582
|
+
disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
|
|
1583
|
+
return teardownOnce(controller, notice, clock, inbound, void 0);
|
|
1584
|
+
}
|
|
1585
|
+
const pump = sendChunks(port, CHANNEL_OUT, output, options, controller.signal, clock.touch);
|
|
1586
|
+
pump.catch(() => {});
|
|
1587
|
+
disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
|
|
1588
|
+
return teardownOnce(controller, notice, clock, inbound, output);
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* A stream that completes normally has nobody left to call the returned
|
|
1592
|
+
* teardown — the caller only knows the stream ended, not that it must also
|
|
1593
|
+
* dispose the serve-side handle. Without this, the last `touch()`'s timer
|
|
1594
|
+
* stays armed for up to `timeout` ms after real completion, doing nothing
|
|
1595
|
+
* but holding the event loop open.
|
|
1596
|
+
*
|
|
1597
|
+
* Only disarms once BOTH `pump` (our output) and `inboundEnded` (the peer's
|
|
1598
|
+
* declared end of input, or an abort) have settled. Disarming on `pump`
|
|
1599
|
+
* alone is not safe: under half-close the handler can finish producing
|
|
1600
|
+
* output while the caller is still sending input, and a clock stopped then
|
|
1601
|
+
* would leave that still-open half with no deadline at all — worse than the
|
|
1602
|
+
* leak this fixes.
|
|
1603
|
+
*/
|
|
1604
|
+
function disarmClockWhenBothSidesSettle(clock, pump, inboundEnded) {
|
|
1605
|
+
Promise.allSettled([pump, inboundEnded]).then(() => {
|
|
1606
|
+
clock.stop();
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
async function* failing(err) {
|
|
1610
|
+
throw err;
|
|
1611
|
+
}
|
|
1612
|
+
function teardownOnce(controller, notice, clock, inbound, output) {
|
|
1613
|
+
let torn = false;
|
|
1614
|
+
return () => {
|
|
1615
|
+
if (torn) return;
|
|
1616
|
+
torn = true;
|
|
1617
|
+
if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: stream torn down"));
|
|
1618
|
+
notice.post();
|
|
1619
|
+
notice.stop();
|
|
1620
|
+
clock.stop();
|
|
1621
|
+
inbound.stop();
|
|
1622
|
+
output?.return(void 0).catch(() => {});
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
function runCallerSide(port, input, options) {
|
|
1626
|
+
const controller = new AbortController();
|
|
1627
|
+
const notice = installAbortNotice(port, controller);
|
|
1628
|
+
abortOnPortClose(port, controller);
|
|
1629
|
+
const clock = installStreamTimeout(controller, options.timeout);
|
|
1630
|
+
const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
|
|
1631
|
+
const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
|
|
1632
|
+
pump.catch(() => {});
|
|
1633
|
+
return (async function* () {
|
|
1634
|
+
try {
|
|
1635
|
+
yield* inbound.stream;
|
|
1636
|
+
} finally {
|
|
1637
|
+
if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
|
|
1638
|
+
notice.post();
|
|
1639
|
+
notice.stop();
|
|
1640
|
+
clock.stop();
|
|
1641
|
+
inbound.stop();
|
|
1642
|
+
await pump.catch(() => {});
|
|
1643
|
+
try {
|
|
1644
|
+
await port.close?.();
|
|
1645
|
+
} catch {}
|
|
1646
|
+
}
|
|
1647
|
+
})();
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Listens for the peer's abort notice, and can post our own. The notice is a
|
|
1651
|
+
* plain message with no `channelName` and a `type` that is not `"request"`,
|
|
1652
|
+
* so neither `callPort` nor `listenPort` reacts to it, and it carries no
|
|
1653
|
+
* numeric `id`, so `structuredCodec` never mistakes it for a layer 1 envelope.
|
|
1654
|
+
*/
|
|
1655
|
+
function installAbortNotice(port, controller) {
|
|
1656
|
+
const onMessage = (event) => {
|
|
1657
|
+
const data = event.data;
|
|
1658
|
+
if (!data || data.type !== "webrun-rpc:stream-abort") return;
|
|
1659
|
+
if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the peer abandoned the stream"));
|
|
1660
|
+
};
|
|
1661
|
+
port.addEventListener("message", onMessage);
|
|
1662
|
+
let posted = false;
|
|
1663
|
+
return {
|
|
1664
|
+
post() {
|
|
1665
|
+
if (posted) return;
|
|
1666
|
+
posted = true;
|
|
1667
|
+
try {
|
|
1668
|
+
port.postMessage({ type: STREAM_ABORT });
|
|
1669
|
+
} catch {}
|
|
1670
|
+
},
|
|
1671
|
+
stop() {
|
|
1672
|
+
port.removeEventListener("message", onMessage);
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Aborts the stream when its port closes underneath it — the multiplexer was
|
|
1678
|
+
* closed, or the peer closed the port — for ports that report it (see
|
|
1679
|
+
* `getPortCloseSignal`). Without this a stream waiting for the peer's next
|
|
1680
|
+
* chunk would wait forever on a port that can no longer deliver one: the
|
|
1681
|
+
* outbound half's `callPort` already fails on the close signal, but nothing
|
|
1682
|
+
* failed the inbound half.
|
|
1683
|
+
*/
|
|
1684
|
+
function abortOnPortClose(port, controller) {
|
|
1685
|
+
const closed = getPortCloseSignal(port);
|
|
1686
|
+
if (!closed) return;
|
|
1687
|
+
const onClose = () => {
|
|
1688
|
+
if (!controller.signal.aborted) controller.abort(closed.reason);
|
|
1689
|
+
};
|
|
1690
|
+
if (closed.aborted) {
|
|
1691
|
+
onClose();
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
closed.addEventListener("abort", onClose, { once: true });
|
|
1695
|
+
controller.signal.addEventListener("abort", () => closed.removeEventListener("abort", onClose), { once: true });
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* The per-stream inactivity timeout (spec D8). Reset by any chunk in either
|
|
1699
|
+
* direction; elapsing aborts the stream. Unset, zero or non-finite installs no
|
|
1700
|
+
* timer at all, which is the default: a slow consumer is throttled, not failed.
|
|
1701
|
+
*/
|
|
1702
|
+
function installStreamTimeout(controller, timeout) {
|
|
1703
|
+
if (timeout === void 0 || !Number.isFinite(timeout) || timeout <= 0) return {
|
|
1704
|
+
touch() {},
|
|
1705
|
+
stop() {}
|
|
1706
|
+
};
|
|
1707
|
+
let timer;
|
|
1708
|
+
let stopped = false;
|
|
1709
|
+
const arm = () => {
|
|
1710
|
+
timer = setTimeout(() => {
|
|
1711
|
+
if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error(`webrun-rpc: stream idle for ${timeout} ms`));
|
|
1712
|
+
}, timeout);
|
|
1713
|
+
};
|
|
1714
|
+
arm();
|
|
1715
|
+
return {
|
|
1716
|
+
touch() {
|
|
1717
|
+
if (stopped) return;
|
|
1718
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1719
|
+
arm();
|
|
1720
|
+
},
|
|
1721
|
+
stop() {
|
|
1722
|
+
stopped = true;
|
|
1723
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1724
|
+
timer = void 0;
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* The receiving half of one direction.
|
|
1730
|
+
*
|
|
1731
|
+
* The `listenPort` listener is installed **eagerly**, not lazily inside
|
|
1732
|
+
* `recieveIterator`'s installer, because a handler that never drains its input
|
|
1733
|
+
* would otherwise leave the peer's chunk calls with nobody to answer them. If
|
|
1734
|
+
* the local consumer has not started iterating, an inbound chunk waits for it —
|
|
1735
|
+
* which is the correct backpressure, and different from having no listener.
|
|
1736
|
+
*/
|
|
1737
|
+
function receiveChunks(port, channelName, controller, touch) {
|
|
1738
|
+
let deliver;
|
|
1739
|
+
const waiting = [];
|
|
1740
|
+
let finished = false;
|
|
1741
|
+
let outstanding = false;
|
|
1742
|
+
let poison;
|
|
1743
|
+
let resolveEnded = () => {};
|
|
1744
|
+
const ended = new Promise((resolve) => {
|
|
1745
|
+
resolveEnded = resolve;
|
|
1746
|
+
});
|
|
1747
|
+
let aborted = false;
|
|
1748
|
+
let abortReason;
|
|
1749
|
+
const ready = () => deliver || finished ? Promise.resolve() : new Promise((r) => waiting.push(r));
|
|
1750
|
+
const wake = () => {
|
|
1751
|
+
for (const r of waiting.splice(0)) r();
|
|
1752
|
+
};
|
|
1753
|
+
const off = listenPort(port, async ({ done, value, error }) => {
|
|
1754
|
+
touch();
|
|
1755
|
+
if (poison) throw poison;
|
|
1756
|
+
if (outstanding) {
|
|
1757
|
+
poison = /* @__PURE__ */ new Error("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
|
|
1758
|
+
controller.abort(poison);
|
|
1759
|
+
setTimeout(() => {
|
|
1760
|
+
try {
|
|
1761
|
+
port.close?.();
|
|
1762
|
+
} catch {}
|
|
1763
|
+
}, 0);
|
|
1764
|
+
throw poison;
|
|
1765
|
+
}
|
|
1766
|
+
outstanding = true;
|
|
1767
|
+
try {
|
|
1768
|
+
await ready();
|
|
1769
|
+
if (finished) throw poison ?? /* @__PURE__ */ new Error("webrun-rpc: the stream is closed");
|
|
1770
|
+
await deliver?.({
|
|
1771
|
+
done,
|
|
1772
|
+
value,
|
|
1773
|
+
error: error ? deserializeError(error) : void 0
|
|
1774
|
+
});
|
|
1775
|
+
} finally {
|
|
1776
|
+
outstanding = false;
|
|
1777
|
+
}
|
|
1778
|
+
if (done) resolveEnded();
|
|
1779
|
+
}, { channelName });
|
|
1780
|
+
const detach = () => {
|
|
1781
|
+
finished = true;
|
|
1782
|
+
wake();
|
|
1783
|
+
off();
|
|
1784
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
1785
|
+
resolveEnded();
|
|
1786
|
+
};
|
|
1787
|
+
const onAbort = () => {
|
|
1788
|
+
aborted = true;
|
|
1789
|
+
abortReason = controller.signal.reason;
|
|
1790
|
+
finished = true;
|
|
1791
|
+
wake();
|
|
1792
|
+
deliver?.({
|
|
1793
|
+
done: true,
|
|
1794
|
+
error: abortReason
|
|
1795
|
+
});
|
|
1796
|
+
resolveEnded();
|
|
1797
|
+
};
|
|
1798
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
1799
|
+
return {
|
|
1800
|
+
stream: recieveIterator((d) => {
|
|
1801
|
+
if (aborted) {
|
|
1802
|
+
d({
|
|
1803
|
+
done: true,
|
|
1804
|
+
error: abortReason
|
|
1805
|
+
});
|
|
1806
|
+
return detach;
|
|
1807
|
+
}
|
|
1808
|
+
deliver = d;
|
|
1809
|
+
wake();
|
|
1810
|
+
return detach;
|
|
1811
|
+
}),
|
|
1812
|
+
stop: detach,
|
|
1813
|
+
ended
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* The sending half of one direction: one `callPort` per chunk, one call
|
|
1818
|
+
* outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
|
|
1819
|
+
*/
|
|
1820
|
+
async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
|
|
1821
|
+
const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
|
|
1822
|
+
try {
|
|
1823
|
+
await sendIterator(async ({ done, value, error }) => {
|
|
1824
|
+
if (signal.aborted) return;
|
|
1825
|
+
const chunk = {
|
|
1826
|
+
done,
|
|
1827
|
+
value,
|
|
1828
|
+
error: error === void 0 ? void 0 : serializeError(error)
|
|
1829
|
+
};
|
|
1830
|
+
log?.("[duplexOverPort] send", {
|
|
1831
|
+
channelName,
|
|
1832
|
+
done,
|
|
1833
|
+
size: value?.byteLength
|
|
1834
|
+
});
|
|
1835
|
+
await callPort(port, chunk, {
|
|
1836
|
+
channelName,
|
|
1837
|
+
timeout: NO_TIMEOUT,
|
|
1838
|
+
signal
|
|
1839
|
+
});
|
|
1840
|
+
touch();
|
|
1841
|
+
}, stream);
|
|
1842
|
+
} catch (err) {
|
|
1843
|
+
if (signal.aborted) return;
|
|
1844
|
+
throw err;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* One virtual port.
|
|
1849
|
+
*
|
|
1850
|
+
* `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
|
|
1851
|
+
* only a `MessageTarget`, so it cannot forge inbound traffic or close the port
|
|
1852
|
+
* out from under the multiplexer's bookkeeping.
|
|
1853
|
+
*/
|
|
1854
|
+
function newVirtualPort(send, requestClose) {
|
|
1855
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1856
|
+
let closed = false;
|
|
1857
|
+
const closing = new AbortController();
|
|
1858
|
+
const abort = (reason) => {
|
|
1859
|
+
if (closing.signal.aborted) return;
|
|
1860
|
+
closing.abort(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the port was closed"));
|
|
1861
|
+
};
|
|
1862
|
+
const port = {
|
|
1863
|
+
addEventListener(_type, listener) {
|
|
1864
|
+
listeners.add(listener);
|
|
1865
|
+
},
|
|
1866
|
+
removeEventListener(_type, listener) {
|
|
1867
|
+
listeners.delete(listener);
|
|
1868
|
+
},
|
|
1869
|
+
postMessage(message, transfer) {
|
|
1870
|
+
if (closed) return;
|
|
1871
|
+
send(message, transfer);
|
|
1872
|
+
},
|
|
1873
|
+
close() {
|
|
1874
|
+
if (closed) return;
|
|
1875
|
+
closed = true;
|
|
1876
|
+
const notify = requestClose;
|
|
1877
|
+
listeners.clear();
|
|
1878
|
+
notify();
|
|
1879
|
+
abort();
|
|
1880
|
+
}
|
|
1881
|
+
};
|
|
1882
|
+
setPortCloseSignal(port, closing.signal);
|
|
1883
|
+
return {
|
|
1884
|
+
port,
|
|
1885
|
+
deliver(payload) {
|
|
1886
|
+
if (closed) return;
|
|
1887
|
+
const event = new MessageEvent("message", { data: payload });
|
|
1888
|
+
for (const listener of [...listeners]) try {
|
|
1889
|
+
listener(event);
|
|
1890
|
+
} catch {}
|
|
1891
|
+
},
|
|
1892
|
+
markClosed(reason) {
|
|
1893
|
+
closed = true;
|
|
1894
|
+
listeners.clear();
|
|
1895
|
+
abort(reason);
|
|
1896
|
+
},
|
|
1897
|
+
isClosed() {
|
|
1898
|
+
return closed;
|
|
1899
|
+
}
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* The message of the error that fails the calls still in flight on a
|
|
1904
|
+
* multiplexer when it is closed (`PortMux.close()`).
|
|
1905
|
+
*/
|
|
1906
|
+
const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
|
|
1907
|
+
/** Ceiling on concurrently open virtual ports. Bounds the id table only. */
|
|
1908
|
+
const DEFAULT_MAX_PORTS = 1024;
|
|
1909
|
+
/**
|
|
1910
|
+
* The default `PortMux`: emulates multiplexing over a single port.
|
|
1911
|
+
*
|
|
1912
|
+
* A transport that already multiplexes natively supplies its own `PortMux`
|
|
1913
|
+
* instead — this implementation is for transports that offer one pipe.
|
|
1914
|
+
*/
|
|
1915
|
+
function multiplexPort(port, options) {
|
|
1916
|
+
const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize } = options;
|
|
1917
|
+
const open = /* @__PURE__ */ new Map();
|
|
1918
|
+
let nextId = side === "initiator" ? 0 : 1;
|
|
1919
|
+
let muxClosed = false;
|
|
1920
|
+
const post = (envelope, transfer) => {
|
|
1921
|
+
if (muxClosed) return;
|
|
1922
|
+
try {
|
|
1923
|
+
codec.post(port, envelope, transfer);
|
|
1924
|
+
} catch {}
|
|
1925
|
+
};
|
|
1926
|
+
const attach = (id) => {
|
|
1927
|
+
let self;
|
|
1928
|
+
self = newVirtualPort((payload, transfer) => post({
|
|
1929
|
+
type: "message",
|
|
1930
|
+
id,
|
|
1931
|
+
payload
|
|
1932
|
+
}, transfer), (reason) => {
|
|
1933
|
+
post({
|
|
1934
|
+
type: "close",
|
|
1935
|
+
id,
|
|
1936
|
+
reason
|
|
1937
|
+
});
|
|
1938
|
+
open.delete(id);
|
|
1939
|
+
self.markClosed();
|
|
1940
|
+
});
|
|
1941
|
+
open.set(id, self);
|
|
1942
|
+
return self;
|
|
1943
|
+
};
|
|
1944
|
+
const handleEnvelope = (envelope) => {
|
|
1945
|
+
if (muxClosed) return;
|
|
1946
|
+
if (envelope.type === "open") {
|
|
1947
|
+
if (open.has(envelope.id)) return;
|
|
1948
|
+
if (open.size >= maxPorts) {
|
|
1949
|
+
post({
|
|
1950
|
+
type: "close",
|
|
1951
|
+
id: envelope.id,
|
|
1952
|
+
reason: "max-ports"
|
|
1953
|
+
});
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
const handle = attach(envelope.id);
|
|
1957
|
+
let accepted = false;
|
|
1958
|
+
if (onPort) try {
|
|
1959
|
+
accepted = onPort(handle.port, envelope.meta) !== false;
|
|
1960
|
+
} catch {
|
|
1961
|
+
accepted = false;
|
|
1962
|
+
}
|
|
1963
|
+
if (!accepted) {
|
|
1964
|
+
open.delete(envelope.id);
|
|
1965
|
+
handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port was rejected"));
|
|
1966
|
+
post({
|
|
1967
|
+
type: "close",
|
|
1968
|
+
id: envelope.id,
|
|
1969
|
+
reason: "rejected"
|
|
1970
|
+
});
|
|
1971
|
+
}
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
const handle = open.get(envelope.id);
|
|
1975
|
+
if (envelope.type === "message") {
|
|
1976
|
+
if (!handle) return;
|
|
1977
|
+
handle.deliver(envelope.payload);
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
if (!handle) return;
|
|
1981
|
+
open.delete(envelope.id);
|
|
1982
|
+
handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
|
|
1983
|
+
};
|
|
1984
|
+
const listener = (event) => {
|
|
1985
|
+
const envelope = codec.read(event);
|
|
1986
|
+
if (envelope) handleEnvelope(envelope);
|
|
1987
|
+
};
|
|
1988
|
+
port.addEventListener("message", listener);
|
|
1989
|
+
port.start?.();
|
|
1990
|
+
return {
|
|
1991
|
+
maxMessageSize,
|
|
1992
|
+
async openPort(meta) {
|
|
1993
|
+
if (muxClosed) throw new Error("webrun-rpc: the multiplexer is closed");
|
|
1994
|
+
if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached`);
|
|
1995
|
+
while (open.has(nextId)) nextId += 2;
|
|
1996
|
+
const id = nextId;
|
|
1997
|
+
nextId += 2;
|
|
1998
|
+
const handle = attach(id);
|
|
1999
|
+
post({
|
|
2000
|
+
type: "open",
|
|
2001
|
+
id,
|
|
2002
|
+
meta
|
|
2003
|
+
});
|
|
2004
|
+
return handle.port;
|
|
2005
|
+
},
|
|
2006
|
+
async close() {
|
|
2007
|
+
if (muxClosed) return;
|
|
2008
|
+
for (const [id, handle] of [...open]) {
|
|
2009
|
+
post({
|
|
2010
|
+
type: "close",
|
|
2011
|
+
id
|
|
2012
|
+
});
|
|
2013
|
+
open.delete(id);
|
|
2014
|
+
handle.markClosed(/* @__PURE__ */ new Error(MUX_CLOSED_MESSAGE));
|
|
2015
|
+
}
|
|
2016
|
+
muxClosed = true;
|
|
2017
|
+
port.removeEventListener("message", listener);
|
|
2018
|
+
await port.close?.();
|
|
2019
|
+
}
|
|
2020
|
+
};
|
|
2021
|
+
}
|
|
2022
|
+
/**
|
|
2023
|
+
* Announced with every port this pair opens. Layer 1 never reads `meta` — it is
|
|
2024
|
+
* here so a peer running something else over the same multiplexer can tell a
|
|
2025
|
+
* stream port from whatever else it hands out, and so a packet capture says
|
|
2026
|
+
* what the port is for.
|
|
2027
|
+
*/
|
|
2028
|
+
const STREAM_META = { kind: "stream" };
|
|
2029
|
+
/**
|
|
2030
|
+
* One port source in, one caller `Duplex` out.
|
|
2031
|
+
*
|
|
2032
|
+
* A call is a port: the mux allocates one, `duplexOverPort` runs the single
|
|
2033
|
+
* invocation on it, and closing the stream closes the port. No stream ids, no
|
|
2034
|
+
* framing and no credit accounting live here — the mux owns the first, and
|
|
2035
|
+
* `duplexOverPort` owns the one-chunk window that makes memory bounded.
|
|
2036
|
+
*/
|
|
2037
|
+
const connect = async ({ mux: factory, timeout }) => {
|
|
2038
|
+
const mux = await factory();
|
|
2039
|
+
const streamOptions = {
|
|
2040
|
+
maxMessageSize: mux.maxMessageSize,
|
|
2041
|
+
timeout
|
|
2042
|
+
};
|
|
2043
|
+
const call = (input) => (async function* () {
|
|
2044
|
+
yield* duplexOverPort(await mux.openPort(STREAM_META), streamOptions)(input);
|
|
2045
|
+
})();
|
|
2046
|
+
return {
|
|
2047
|
+
call,
|
|
2048
|
+
async close() {
|
|
2049
|
+
await mux.close();
|
|
2050
|
+
}
|
|
2051
|
+
};
|
|
2052
|
+
};
|
|
2053
|
+
/**
|
|
2054
|
+
* One port source in, `handler` serving every call that arrives on it.
|
|
2055
|
+
*
|
|
2056
|
+
* Each inbound port is one invocation, so the factory's `onPort` is the accept
|
|
2057
|
+
* loop: it installs `handler` on the new port and nothing else. The returned
|
|
2058
|
+
* teardown abandons the streams still running *before* dropping the mux, so
|
|
2059
|
+
* the peer's callers reject with "the peer abandoned the stream" instead of
|
|
2060
|
+
* parking forever on a port that has silently gone inert — layer 1's close is
|
|
2061
|
+
* not observable to layer 2, which is why the notice has to be posted
|
|
2062
|
+
* deliberately.
|
|
2063
|
+
*/
|
|
2064
|
+
const serve = async ({ mux: factory, timeout }, handler) => {
|
|
2065
|
+
const live = /* @__PURE__ */ new Set();
|
|
2066
|
+
let streamOptions = { timeout };
|
|
2067
|
+
const mux = await factory((port) => {
|
|
2068
|
+
let off;
|
|
2069
|
+
let finished = false;
|
|
2070
|
+
const tracked = (input) => (async function* () {
|
|
2071
|
+
try {
|
|
2072
|
+
yield* handler(input);
|
|
2073
|
+
} finally {
|
|
2074
|
+
finished = true;
|
|
2075
|
+
if (off) live.delete(off);
|
|
2076
|
+
}
|
|
2077
|
+
})();
|
|
2078
|
+
off = serveDuplexOverPort(port, tracked, streamOptions);
|
|
2079
|
+
if (!finished) live.add(off);
|
|
2080
|
+
});
|
|
2081
|
+
streamOptions = {
|
|
2082
|
+
maxMessageSize: mux.maxMessageSize,
|
|
2083
|
+
timeout
|
|
2084
|
+
};
|
|
2085
|
+
let torn = false;
|
|
2086
|
+
return async () => {
|
|
2087
|
+
if (torn) return;
|
|
2088
|
+
torn = true;
|
|
2089
|
+
for (const off of [...live]) {
|
|
2090
|
+
live.delete(off);
|
|
2091
|
+
try {
|
|
2092
|
+
off();
|
|
2093
|
+
} catch {}
|
|
2094
|
+
}
|
|
2095
|
+
await mux.close();
|
|
2096
|
+
};
|
|
2097
|
+
};
|
|
2098
|
+
/**
|
|
2099
|
+
* Ports over ONE PIPE OF BYTES — a `MessagePort`, a worker, a WebSocket.
|
|
2100
|
+
*
|
|
2101
|
+
* There is no second port to be had, so `multiplexPort`'s id table invents
|
|
2102
|
+
* them. Use this when the transport gives you exactly one channel.
|
|
2103
|
+
*/
|
|
2104
|
+
function overPipe(pipe, options) {
|
|
2105
|
+
return (onPort) => multiplexPort(pipe, {
|
|
2106
|
+
...options,
|
|
2107
|
+
onPort
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
function isEnvelope(value) {
|
|
2111
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2112
|
+
const candidate = value;
|
|
2113
|
+
if (typeof candidate.id !== "number") return false;
|
|
2114
|
+
if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
|
|
2115
|
+
return candidate.type === "open" || candidate.type === "message" || candidate.type === "close";
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* For ports whose messages are structured values — a real `MessagePort`, a
|
|
2119
|
+
* worker, an iframe.
|
|
2120
|
+
*
|
|
2121
|
+
* Envelopes are posted as-is, so nothing is encoded, `ArrayBuffer`s move
|
|
2122
|
+
* zero-copy through the transfer list, and structured clone does the work the
|
|
2123
|
+
* platform already does well. This is a performance choice only: layer 2 may
|
|
2124
|
+
* not send anything a byte codec could not also carry.
|
|
2125
|
+
*/
|
|
2126
|
+
const structuredCodec = {
|
|
2127
|
+
post(port, envelope, transfer) {
|
|
2128
|
+
if (transfer && transfer.length > 0) port.postMessage(envelope, transfer);
|
|
2129
|
+
else port.postMessage(envelope);
|
|
2130
|
+
},
|
|
2131
|
+
read(event) {
|
|
2132
|
+
return isEnvelope(event.data) ? event.data : void 0;
|
|
2133
|
+
}
|
|
2134
|
+
};
|
|
2135
|
+
//#endregion
|
|
2136
|
+
//#region src/link/http-link.ts
|
|
2137
|
+
/**
|
|
2138
|
+
* The version of the HTTP-over-`MessagePort` protocol this package speaks,
|
|
2139
|
+
* sent in every hand-shake that sets up a link. Version 1 was this package's
|
|
2140
|
+
* own `handleStreams` / `sendStream` (no backpressure); version 2 is
|
|
2141
|
+
* webrun-rpc's `connect` / `serve` over `overPipe` with webrun-http-streams'
|
|
2142
|
+
* `fetchOverDuplex` / `serveFetchOverDuplex`: one port per link, calls
|
|
2143
|
+
* multiplexed on it, each call a stream with a one-chunk window in both
|
|
2144
|
+
* directions, and cancellation that reaches the handler.
|
|
2145
|
+
*/
|
|
2146
|
+
const HTTP_LINK_PROTOCOL = "webrun-http-link/2";
|
|
2147
|
+
/**
|
|
2148
|
+
* Why a request failed when its link went away under it: the far side (the
|
|
2149
|
+
* handler, or the ServiceWorker) was lost. The request may or may not have
|
|
2150
|
+
* been applied.
|
|
2151
|
+
*/
|
|
2152
|
+
var LinkLostError = class extends Error {
|
|
2153
|
+
constructor(message = "The link was lost while this request was in flight") {
|
|
2154
|
+
super(`${message}; it may or may not have been applied.`);
|
|
2155
|
+
this.name = "LinkLostError";
|
|
2156
|
+
}
|
|
2157
|
+
};
|
|
2158
|
+
/**
|
|
2159
|
+
* Posted on a link's port by the calling end when it closes the link, so the
|
|
2160
|
+
* answering end can release it: a `MessagePort` never reports that its other
|
|
2161
|
+
* end is gone.
|
|
2162
|
+
*/
|
|
2163
|
+
const LINK_CLOSE = "webrun-http:link-close";
|
|
2164
|
+
/** Opens the calling end of a link on `port` (one port per link). */
|
|
2165
|
+
async function connectHttpLink(port, { streamTimeout } = {}) {
|
|
2166
|
+
const { call, close } = await connect({
|
|
2167
|
+
mux: overPipe(port, {
|
|
2168
|
+
codec: structuredCodec,
|
|
2169
|
+
side: "initiator"
|
|
2170
|
+
}),
|
|
2171
|
+
timeout: streamTimeout
|
|
2172
|
+
});
|
|
2173
|
+
return trackedLink((request) => fetchOverDuplex(call, request), async () => {
|
|
2174
|
+
try {
|
|
2175
|
+
port.postMessage({ type: LINK_CLOSE });
|
|
2176
|
+
} catch {}
|
|
2177
|
+
await close();
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* Serves `handler` on the answering end of a link. Resolves with a teardown
|
|
2182
|
+
* that abandons the requests still running (their handlers see the
|
|
2183
|
+
* cancellation) and releases the port.
|
|
2184
|
+
*/
|
|
2185
|
+
async function serveHttpLink(port, handler, { streamTimeout } = {}) {
|
|
2186
|
+
const teardown = await serve({
|
|
2187
|
+
mux: overPipe(port, {
|
|
2188
|
+
codec: structuredCodec,
|
|
2189
|
+
side: "responder"
|
|
2190
|
+
}),
|
|
2191
|
+
timeout: streamTimeout
|
|
2192
|
+
}, serveFetchOverDuplex(async (request) => await handler(request)));
|
|
2193
|
+
const onClose = (event) => {
|
|
2194
|
+
if (event.data?.type === LINK_CLOSE) close();
|
|
2195
|
+
};
|
|
2196
|
+
const close = async () => {
|
|
2197
|
+
port.removeEventListener("message", onClose);
|
|
2198
|
+
await teardown();
|
|
2199
|
+
};
|
|
2200
|
+
port.addEventListener("message", onClose);
|
|
2201
|
+
return close;
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Wraps a `send` so the link knows its requests in flight (for draining and
|
|
2205
|
+
* load-based routing) and can fail them all at once when it is closed.
|
|
2206
|
+
*/
|
|
2207
|
+
function trackedLink(send, release) {
|
|
2208
|
+
let inflight = 0;
|
|
2209
|
+
let closed = false;
|
|
2210
|
+
let lostReason;
|
|
2211
|
+
const failers = /* @__PURE__ */ new Set();
|
|
2212
|
+
const idleWaiters = [];
|
|
2213
|
+
const done = () => {
|
|
2214
|
+
inflight--;
|
|
2215
|
+
if (inflight === 0) for (const resolve of idleWaiters.splice(0)) resolve();
|
|
2216
|
+
};
|
|
2217
|
+
return {
|
|
2218
|
+
get inflight() {
|
|
2219
|
+
return inflight;
|
|
2220
|
+
},
|
|
2221
|
+
get closed() {
|
|
2222
|
+
return closed;
|
|
2223
|
+
},
|
|
2224
|
+
whenIdle() {
|
|
2225
|
+
return inflight === 0 ? Promise.resolve() : new Promise((r) => idleWaiters.push(r));
|
|
2226
|
+
},
|
|
2227
|
+
async send(request) {
|
|
2228
|
+
if (closed) throw lostReason ?? new LinkLostError("The link is closed");
|
|
2229
|
+
inflight++;
|
|
2230
|
+
let finished = false;
|
|
2231
|
+
const finish = () => {
|
|
2232
|
+
if (finished) return;
|
|
2233
|
+
finished = true;
|
|
2234
|
+
failers.delete(fail);
|
|
2235
|
+
done();
|
|
2236
|
+
};
|
|
2237
|
+
let fail = () => {};
|
|
2238
|
+
const lost = new Promise((_, reject) => {
|
|
2239
|
+
fail = reject;
|
|
2240
|
+
});
|
|
2241
|
+
failers.add(fail);
|
|
2242
|
+
lost.catch(() => {});
|
|
2243
|
+
let response;
|
|
2244
|
+
try {
|
|
2245
|
+
response = await Promise.race([send(request), lost]);
|
|
2246
|
+
} catch (error) {
|
|
2247
|
+
finish();
|
|
2248
|
+
throw lostReason ?? error;
|
|
2249
|
+
}
|
|
2250
|
+
if (!response.body) {
|
|
2251
|
+
finish();
|
|
2252
|
+
return response;
|
|
2253
|
+
}
|
|
2254
|
+
return new Response(guardBody(response.body, lost, () => lostReason, finish), {
|
|
2255
|
+
status: response.status,
|
|
2256
|
+
statusText: response.statusText,
|
|
2257
|
+
headers: response.headers
|
|
2258
|
+
});
|
|
2259
|
+
},
|
|
2260
|
+
async close(reason) {
|
|
2261
|
+
if (closed) return;
|
|
2262
|
+
closed = true;
|
|
2263
|
+
lostReason = reason ?? new LinkLostError("The link was closed");
|
|
2264
|
+
for (const fail of [...failers]) fail(lostReason);
|
|
2265
|
+
try {
|
|
2266
|
+
await release();
|
|
2267
|
+
} catch {}
|
|
2268
|
+
}
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* A response body that reports its end (done, cancelled or failed) and fails
|
|
2273
|
+
* with the link's reason when the link is lost mid-stream.
|
|
2274
|
+
*/
|
|
2275
|
+
function guardBody(body, lost, reason, finish) {
|
|
2276
|
+
const reader = body.getReader();
|
|
2277
|
+
return new ReadableStream({
|
|
2278
|
+
async pull(controller) {
|
|
2279
|
+
try {
|
|
2280
|
+
const { done, value } = await Promise.race([reader.read(), lost]);
|
|
2281
|
+
if (done) {
|
|
2282
|
+
finish();
|
|
2283
|
+
controller.close();
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
controller.enqueue(value);
|
|
2287
|
+
} catch (error) {
|
|
2288
|
+
finish();
|
|
2289
|
+
reader.cancel().catch(() => {});
|
|
2290
|
+
controller.error(reason() ?? error);
|
|
2291
|
+
}
|
|
2292
|
+
},
|
|
2293
|
+
async cancel(why) {
|
|
2294
|
+
finish();
|
|
2295
|
+
await reader.cancel(why).catch(() => {});
|
|
2296
|
+
}
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
//#endregion
|
|
2300
|
+
//#region src/link/locks.ts
|
|
2301
|
+
function lockManager() {
|
|
2302
|
+
return globalThis.navigator?.locks;
|
|
2303
|
+
}
|
|
2304
|
+
function locksAvailable() {
|
|
2305
|
+
return lockManager() !== void 0;
|
|
2306
|
+
}
|
|
2307
|
+
/** A fresh lock name: `<prefix>:<uuid>`. */
|
|
2308
|
+
function newLockName(prefix) {
|
|
2309
|
+
return `${prefix}:${crypto.randomUUID()}`;
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Takes the lock `name` and holds it until the returned release is called or
|
|
2313
|
+
* this context dies. Resolves once the lock is held; with no Web Locks,
|
|
2314
|
+
* resolves at once.
|
|
2315
|
+
*/
|
|
2316
|
+
async function holdLock(name) {
|
|
2317
|
+
const locks = lockManager();
|
|
2318
|
+
if (!locks) return () => {};
|
|
2319
|
+
let release = () => {};
|
|
2320
|
+
const released = new Promise((resolve) => {
|
|
2321
|
+
release = resolve;
|
|
2322
|
+
});
|
|
2323
|
+
await new Promise((held) => {
|
|
2324
|
+
locks.request(name, {}, () => {
|
|
2325
|
+
held();
|
|
2326
|
+
return released;
|
|
2327
|
+
}).catch(() => held());
|
|
2328
|
+
});
|
|
2329
|
+
return release;
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Calls `onLost` once the lock `name` is granted to this context — that is,
|
|
2333
|
+
* once whoever holds it is gone — and releases it at once. Returns a function
|
|
2334
|
+
* that stops watching. With no Web Locks, never calls `onLost`.
|
|
2335
|
+
*/
|
|
2336
|
+
function watchLock(name, onLost) {
|
|
2337
|
+
const locks = lockManager();
|
|
2338
|
+
if (!locks) return () => {};
|
|
2339
|
+
const controller = new AbortController();
|
|
2340
|
+
locks.request(name, { signal: controller.signal }, () => {
|
|
2341
|
+
if (!controller.signal.aborted) onLost();
|
|
2342
|
+
}).catch(() => {});
|
|
2343
|
+
return () => controller.abort();
|
|
2344
|
+
}
|
|
2345
|
+
//#endregion
|
|
2346
|
+
//#region src/ws/frames.ts
|
|
2347
|
+
/**
|
|
2348
|
+
* The wire protocol of a WebSocket carried over a `MessagePort`.
|
|
2349
|
+
*
|
|
2350
|
+
* A `MessagePort` is a natural carrier for a WebSocket: messages arrive in
|
|
2351
|
+
* order, and strings and transferable `ArrayBuffer`s cross it as they are. The
|
|
2352
|
+
* protocol on top is these frames, the same in both directions:
|
|
2353
|
+
*
|
|
2354
|
+
* - `m` — one message. An `ArrayBuffer` is transferred, never copied.
|
|
2355
|
+
* - `c` — close. The side that closes first sends it; the other answers with
|
|
2356
|
+
* its own `c` (the echo), and both are then closed cleanly.
|
|
2357
|
+
* - `p` — ping, client to server, carrying the client's heartbeat period.
|
|
2358
|
+
* - `q` — pong, server to client.
|
|
2359
|
+
* - `a` — ack: the receiver consumed `n` more bytes of messages. This is the
|
|
2360
|
+
* flow control: what a side has sent and not yet seen acknowledged is its
|
|
2361
|
+
* `bufferedAmount`, and the server end can wait for it to fall
|
|
2362
|
+
* (`whenDrained`). Added in version 2; version 1 had no backpressure.
|
|
2363
|
+
*
|
|
2364
|
+
* HEARTBEAT. A `MessagePort` never reports that the other side died, so the
|
|
2365
|
+
* client pings every `hb` ms and closes with 1006 when pongs stop for `2·hb`;
|
|
2366
|
+
* the server closes with 1006 when pings stop for `3·hb`. That is how a socket
|
|
2367
|
+
* notices a closed tab or a terminated worker.
|
|
2368
|
+
*
|
|
2369
|
+
* Unknown frames are ignored, so a later version can add frames a peer of
|
|
2370
|
+
* this version skips.
|
|
2371
|
+
*/
|
|
2372
|
+
/** The version of this frame protocol. Sent in the upgrade hand-shake. */
|
|
2373
|
+
const WS_PORT_PROTOCOL_VERSION = 2;
|
|
2374
|
+
/** Default heartbeat period of the client, in ms. */
|
|
2375
|
+
const DEFAULT_HEARTBEAT_MS = 1e4;
|
|
2376
|
+
/** How long a side waits for the echo of its close frame, in ms. */
|
|
2377
|
+
const DEFAULT_CLOSE_TIMEOUT_MS = 5e3;
|
|
2378
|
+
const defaultTimers = {
|
|
2379
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
2380
|
+
clearTimeout: (id) => clearTimeout(id),
|
|
2381
|
+
setInterval: (fn, ms) => setInterval(fn, ms),
|
|
2382
|
+
clearInterval: (id) => clearInterval(id)
|
|
2383
|
+
};
|
|
2384
|
+
/** Recovers a frame from a message's data, or `undefined` for anything else. */
|
|
2385
|
+
function readFrame(data) {
|
|
2386
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
2387
|
+
const frame = data;
|
|
2388
|
+
switch (frame.t) {
|
|
2389
|
+
case "m": return typeof frame.d === "string" || frame.d instanceof ArrayBuffer ? frame : void 0;
|
|
2390
|
+
case "c": return {
|
|
2391
|
+
t: "c",
|
|
2392
|
+
code: typeof frame.code === "number" ? frame.code : 1005,
|
|
2393
|
+
reason: typeof frame.reason === "string" ? frame.reason : ""
|
|
2394
|
+
};
|
|
2395
|
+
case "p": {
|
|
2396
|
+
const hb = frame.hb;
|
|
2397
|
+
return {
|
|
2398
|
+
t: "p",
|
|
2399
|
+
hb: typeof hb === "number" && hb > 0 ? hb : DEFAULT_HEARTBEAT_MS
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
case "q": return { t: "q" };
|
|
2403
|
+
case "a": {
|
|
2404
|
+
const n = frame.n;
|
|
2405
|
+
return typeof n === "number" && n >= 0 ? {
|
|
2406
|
+
t: "a",
|
|
2407
|
+
n
|
|
2408
|
+
} : void 0;
|
|
2409
|
+
}
|
|
2410
|
+
default: return;
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
/** Posts `frame`, transferring a message's `ArrayBuffer`. */
|
|
2414
|
+
function postFrame(port, frame) {
|
|
2415
|
+
if (frame.t === "m" && frame.d instanceof ArrayBuffer) port.postMessage(frame, [frame.d]);
|
|
2416
|
+
else port.postMessage(frame);
|
|
2417
|
+
}
|
|
2418
|
+
/** The size a message counts for in `bufferedAmount`: UTF-8 bytes, or the buffer's length. */
|
|
2419
|
+
function messageSize(data) {
|
|
2420
|
+
return typeof data === "string" ? utf8Length(data) : data.byteLength;
|
|
2421
|
+
}
|
|
2422
|
+
/** The UTF-8 length of `text`, without encoding it. */
|
|
2423
|
+
function utf8Length(text) {
|
|
2424
|
+
let length = 0;
|
|
2425
|
+
for (let i = 0; i < text.length; i++) {
|
|
2426
|
+
const code = text.charCodeAt(i);
|
|
2427
|
+
if (code < 128) length += 1;
|
|
2428
|
+
else if (code < 2048) length += 2;
|
|
2429
|
+
else if (code >= 55296 && code <= 56319 && i + 1 < text.length) {
|
|
2430
|
+
const next = text.charCodeAt(i + 1);
|
|
2431
|
+
if (next >= 56320 && next <= 57343) {
|
|
2432
|
+
length += 4;
|
|
2433
|
+
i++;
|
|
2434
|
+
} else length += 3;
|
|
2435
|
+
} else length += 3;
|
|
2436
|
+
}
|
|
2437
|
+
return length;
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* A `CloseEvent` for runtimes that have none: Node 22 has `Event` and
|
|
2441
|
+
* `MessageEvent` as globals, but not `CloseEvent`.
|
|
2442
|
+
*/
|
|
2443
|
+
var WsCloseEvent = class extends Event {
|
|
2444
|
+
code;
|
|
2445
|
+
reason;
|
|
2446
|
+
wasClean;
|
|
2447
|
+
constructor(type, init = {}) {
|
|
2448
|
+
super(type, init);
|
|
2449
|
+
this.code = init.code ?? 0;
|
|
2450
|
+
this.reason = init.reason ?? "";
|
|
2451
|
+
this.wasClean = init.wasClean ?? false;
|
|
2452
|
+
}
|
|
2453
|
+
};
|
|
2454
|
+
/** A `close` event: the platform's `CloseEvent` where there is one. */
|
|
2455
|
+
function newCloseEvent(init) {
|
|
2456
|
+
const Native = globalThis.CloseEvent;
|
|
2457
|
+
return Native ? new Native("close", init) : new WsCloseEvent("close", init);
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* What both ends of a socket over a `MessagePort` share: the frames, the close
|
|
2461
|
+
* handshake, and the byte accounting behind `bufferedAmount`.
|
|
2462
|
+
*
|
|
2463
|
+
* Not exported from the package: the two ends are `createWebSocketClass`'s
|
|
2464
|
+
* class and `ServerWebSocket`.
|
|
2465
|
+
*/
|
|
2466
|
+
var PortSocketCore = class extends EventTarget {
|
|
2467
|
+
_port;
|
|
2468
|
+
_state = 0;
|
|
2469
|
+
_timers;
|
|
2470
|
+
_closeTimeoutMs;
|
|
2471
|
+
/** Bytes sent and not yet acknowledged by the peer, plus those still being prepared. */
|
|
2472
|
+
_inFlight = 0;
|
|
2473
|
+
/** Bytes of messages "sent" after the socket started closing: counted, never sent. */
|
|
2474
|
+
_discarded = 0;
|
|
2475
|
+
_toAck = 0;
|
|
2476
|
+
_ackScheduled = false;
|
|
2477
|
+
_closeTimer;
|
|
2478
|
+
_drainWaiters = [];
|
|
2479
|
+
constructor(options = {}) {
|
|
2480
|
+
super();
|
|
2481
|
+
this._timers = options.timers ?? defaultTimers;
|
|
2482
|
+
this._closeTimeoutMs = options.closeTimeoutMs ?? 5e3;
|
|
2483
|
+
}
|
|
2484
|
+
get readyState() {
|
|
2485
|
+
return this._state;
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* Bytes queued by `send()` that the peer has not consumed yet. A peer
|
|
2489
|
+
* acknowledges what it has dispatched, so this falls as the other side reads.
|
|
2490
|
+
*/
|
|
2491
|
+
get bufferedAmount() {
|
|
2492
|
+
return this._inFlight + this._discarded;
|
|
2493
|
+
}
|
|
2494
|
+
/**
|
|
2495
|
+
* Resolves once `bufferedAmount` is at most `limit` (default 0), or the
|
|
2496
|
+
* socket is closed. The backpressure a producer applies:
|
|
2497
|
+
* `if (socket.bufferedAmount > HIGH) await socket.whenDrained(LOW)`.
|
|
2498
|
+
*/
|
|
2499
|
+
whenDrained(limit = 0) {
|
|
2500
|
+
if (this._state === 3 || this._inFlight <= limit) return Promise.resolve();
|
|
2501
|
+
return new Promise((resolve) => this._drainWaiters.push({
|
|
2502
|
+
limit,
|
|
2503
|
+
resolve
|
|
2504
|
+
}));
|
|
2505
|
+
}
|
|
2506
|
+
_attach(port) {
|
|
2507
|
+
this._port = port;
|
|
2508
|
+
port.onmessage = (event) => {
|
|
2509
|
+
const frame = readFrame(event.data);
|
|
2510
|
+
if (frame) this._onFrame(frame);
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
_onFrame(frame) {
|
|
2514
|
+
switch (frame.t) {
|
|
2515
|
+
case "m":
|
|
2516
|
+
case "c":
|
|
2517
|
+
this._deliver(frame);
|
|
2518
|
+
return;
|
|
2519
|
+
case "a":
|
|
2520
|
+
this._inFlight = Math.max(0, this._inFlight - frame.n);
|
|
2521
|
+
this._wakeDrainWaiters();
|
|
2522
|
+
return;
|
|
2523
|
+
case "p":
|
|
2524
|
+
this._onPing(frame.hb);
|
|
2525
|
+
return;
|
|
2526
|
+
case "q":
|
|
2527
|
+
this._onPong();
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
/** A message or close frame the socket is ready to act on. */
|
|
2532
|
+
_deliver(frame) {
|
|
2533
|
+
if (frame.t === "c") {
|
|
2534
|
+
this._onCloseFrame(frame.code, frame.reason);
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2537
|
+
if (this._state !== 1) return;
|
|
2538
|
+
this._dispatchMessage(frame.d);
|
|
2539
|
+
this._consumed(messageSize(frame.d));
|
|
2540
|
+
}
|
|
2541
|
+
_onPing(_hb) {}
|
|
2542
|
+
_onPong() {}
|
|
2543
|
+
_onClosed() {}
|
|
2544
|
+
/** Acknowledge consumed bytes, coalescing the acks of one turn into one frame. */
|
|
2545
|
+
_consumed(bytes) {
|
|
2546
|
+
this._toAck += bytes;
|
|
2547
|
+
if (this._ackScheduled) return;
|
|
2548
|
+
this._ackScheduled = true;
|
|
2549
|
+
queueMicrotask(() => {
|
|
2550
|
+
this._ackScheduled = false;
|
|
2551
|
+
const n = this._toAck;
|
|
2552
|
+
this._toAck = 0;
|
|
2553
|
+
if (n > 0 && this._state !== 3) this._post({
|
|
2554
|
+
t: "a",
|
|
2555
|
+
n
|
|
2556
|
+
});
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
/** Reserves `bytes` in `bufferedAmount` for a message still being prepared (a Blob). */
|
|
2560
|
+
_reserve(bytes) {
|
|
2561
|
+
this._inFlight += bytes;
|
|
2562
|
+
}
|
|
2563
|
+
/** Sends one message whose bytes were reserved with `_reserve` (or `reserved = false`). */
|
|
2564
|
+
_sendData(data, reserved = false) {
|
|
2565
|
+
const size = messageSize(data);
|
|
2566
|
+
if (this._state !== 1) {
|
|
2567
|
+
if (reserved) this._inFlight = Math.max(0, this._inFlight - size);
|
|
2568
|
+
this._discarded += size;
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
if (!reserved) this._inFlight += size;
|
|
2572
|
+
this._post({
|
|
2573
|
+
t: "m",
|
|
2574
|
+
d: data
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
_post(frame) {
|
|
2578
|
+
try {
|
|
2579
|
+
if (this._port) postFrame(this._port, frame);
|
|
2580
|
+
} catch {}
|
|
2581
|
+
}
|
|
2582
|
+
/** Starts the close handshake. */
|
|
2583
|
+
_startClose(code, reason) {
|
|
2584
|
+
if (this._state === 2 || this._state === 3) return;
|
|
2585
|
+
this._state = 2;
|
|
2586
|
+
this._post({
|
|
2587
|
+
t: "c",
|
|
2588
|
+
code,
|
|
2589
|
+
reason
|
|
2590
|
+
});
|
|
2591
|
+
this._closeTimer = this._timers.setTimeout(() => this._finish(1006, "", false), this._closeTimeoutMs);
|
|
2592
|
+
}
|
|
2593
|
+
_onCloseFrame(code, reason) {
|
|
2594
|
+
if (this._state === 3) return;
|
|
2595
|
+
if (this._state === 2) {
|
|
2596
|
+
this._finish(code, reason, true);
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
this._state = 2;
|
|
2600
|
+
this._post({
|
|
2601
|
+
t: "c",
|
|
2602
|
+
code,
|
|
2603
|
+
reason
|
|
2604
|
+
});
|
|
2605
|
+
this._finish(code, reason, true);
|
|
2606
|
+
}
|
|
2607
|
+
/** The socket is closed: release the port and tell the listeners. */
|
|
2608
|
+
_finish(code, reason, wasClean, error = false) {
|
|
2609
|
+
if (this._state === 3) return;
|
|
2610
|
+
this._state = 3;
|
|
2611
|
+
if (this._closeTimer !== void 0) this._timers.clearTimeout(this._closeTimer);
|
|
2612
|
+
this._closeTimer = void 0;
|
|
2613
|
+
this._onClosed();
|
|
2614
|
+
if (this._port) {
|
|
2615
|
+
this._port.onmessage = null;
|
|
2616
|
+
try {
|
|
2617
|
+
this._port.close();
|
|
2618
|
+
} catch {}
|
|
2619
|
+
}
|
|
2620
|
+
this._wakeDrainWaiters();
|
|
2621
|
+
if (error) this.dispatchEvent(new Event("error"));
|
|
2622
|
+
this.dispatchEvent(newCloseEvent({
|
|
2623
|
+
code,
|
|
2624
|
+
reason,
|
|
2625
|
+
wasClean
|
|
2626
|
+
}));
|
|
2627
|
+
}
|
|
2628
|
+
_wakeDrainWaiters() {
|
|
2629
|
+
if (this._drainWaiters.length === 0) return;
|
|
2630
|
+
const closed = this._state === 3;
|
|
2631
|
+
this._drainWaiters = this._drainWaiters.filter(({ limit, resolve }) => {
|
|
2632
|
+
if (!closed && this._inFlight > limit) return true;
|
|
2633
|
+
resolve();
|
|
2634
|
+
return false;
|
|
2635
|
+
});
|
|
2636
|
+
}
|
|
2637
|
+
};
|
|
2638
|
+
/** Copies `data` into a fresh `ArrayBuffer`: `send()` must never detach the caller's buffer. */
|
|
2639
|
+
function toArrayBuffer(data) {
|
|
2640
|
+
if (data instanceof ArrayBuffer) return data.slice(0);
|
|
2641
|
+
const copy = new Uint8Array(data.byteLength);
|
|
2642
|
+
copy.set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
2643
|
+
return copy.buffer;
|
|
2644
|
+
}
|
|
2645
|
+
function isBinary(data) {
|
|
2646
|
+
return data instanceof ArrayBuffer || ArrayBuffer.isView(data);
|
|
2647
|
+
}
|
|
2648
|
+
function isBlob(data) {
|
|
2649
|
+
return typeof Blob !== "undefined" && data instanceof Blob;
|
|
2650
|
+
}
|
|
2651
|
+
/** Valid close codes for an application to send: 1000, or 3000–4999. */
|
|
2652
|
+
function isApplicationCloseCode(code) {
|
|
2653
|
+
return code === 1e3 || code >= 3e3 && code <= 4999;
|
|
2654
|
+
}
|
|
2655
|
+
//#endregion
|
|
2656
|
+
//#region src/ws/client.ts
|
|
2657
|
+
/** RFC 7230 `token`: what a subprotocol name may be. */
|
|
2658
|
+
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
2659
|
+
/**
|
|
2660
|
+
* A WHATWG-compatible `WebSocket` class whose transport is a `MessagePort`
|
|
2661
|
+
* from `connect`. Code written against the browser's `WebSocket` runs on it
|
|
2662
|
+
* unchanged:
|
|
2663
|
+
*
|
|
2664
|
+
* ```ts
|
|
2665
|
+
* const WS = createWebSocketClass(adapter.connectWebSocket);
|
|
2666
|
+
* const ws = new WS(`${baseUrl}chat`, ["v1"]);
|
|
2667
|
+
* ws.onmessage = (event) => console.log(event.data);
|
|
2668
|
+
* ```
|
|
2669
|
+
*
|
|
2670
|
+
* It validates as the standard does (url, scheme, fragment, subprotocol
|
|
2671
|
+
* tokens and duplicates, close codes and reasons), has `binaryType`, the `on*`
|
|
2672
|
+
* handlers next to `addEventListener`, and the close handshake.
|
|
2673
|
+
* `bufferedAmount` is real: the server acknowledges what it consumed.
|
|
2674
|
+
*
|
|
2675
|
+
* The real `globalThis.WebSocket` is left alone; install the class where you
|
|
2676
|
+
* want it.
|
|
2677
|
+
*/
|
|
2678
|
+
function createWebSocketClass(connect, options = {}) {
|
|
2679
|
+
const heartbeatMs = options.heartbeatMs ?? 1e4;
|
|
2680
|
+
class WebSocketOverPort extends PortSocketCore {
|
|
2681
|
+
static CONNECTING = 0;
|
|
2682
|
+
static OPEN = 1;
|
|
2683
|
+
static CLOSING = 2;
|
|
2684
|
+
static CLOSED = 3;
|
|
2685
|
+
CONNECTING = 0;
|
|
2686
|
+
OPEN = 1;
|
|
2687
|
+
CLOSING = 2;
|
|
2688
|
+
CLOSED = 3;
|
|
2689
|
+
url;
|
|
2690
|
+
extensions = "";
|
|
2691
|
+
_protocol = "";
|
|
2692
|
+
_binaryType = "blob";
|
|
2693
|
+
_handlers = {};
|
|
2694
|
+
_heartbeat;
|
|
2695
|
+
/** When the oldest ping still waiting for its pong was sent. */
|
|
2696
|
+
_unansweredSince;
|
|
2697
|
+
_onVisibility;
|
|
2698
|
+
/** Sends waiting for a Blob ahead of them to be read, so order is kept. */
|
|
2699
|
+
_chain;
|
|
2700
|
+
constructor(url, protocols = []) {
|
|
2701
|
+
super({
|
|
2702
|
+
timers: options.timers,
|
|
2703
|
+
closeTimeoutMs: options.closeTimeoutMs
|
|
2704
|
+
});
|
|
2705
|
+
this.url = parseUrl(url);
|
|
2706
|
+
const list = typeof protocols === "string" ? [protocols] : [...protocols];
|
|
2707
|
+
validateProtocols(list);
|
|
2708
|
+
for (const type of [
|
|
2709
|
+
"open",
|
|
2710
|
+
"message",
|
|
2711
|
+
"error",
|
|
2712
|
+
"close"
|
|
2713
|
+
]) this.addEventListener(type, (event) => this._handlers[type]?.call(this, event));
|
|
2714
|
+
Promise.resolve().then(() => connect(this.url, list)).then((connection) => {
|
|
2715
|
+
try {
|
|
2716
|
+
this._opened(connection, list);
|
|
2717
|
+
} catch {
|
|
2718
|
+
this._failed();
|
|
2719
|
+
}
|
|
2720
|
+
}, () => this._failed());
|
|
2721
|
+
}
|
|
2722
|
+
get protocol() {
|
|
2723
|
+
return this._protocol;
|
|
2724
|
+
}
|
|
2725
|
+
get binaryType() {
|
|
2726
|
+
return this._binaryType;
|
|
2727
|
+
}
|
|
2728
|
+
set binaryType(value) {
|
|
2729
|
+
if (value === "blob" || value === "arraybuffer") this._binaryType = value;
|
|
2730
|
+
}
|
|
2731
|
+
get onopen() {
|
|
2732
|
+
return this._handlers.open ?? null;
|
|
2733
|
+
}
|
|
2734
|
+
set onopen(fn) {
|
|
2735
|
+
this._handlers.open = typeof fn === "function" ? fn : null;
|
|
2736
|
+
}
|
|
2737
|
+
get onmessage() {
|
|
2738
|
+
return this._handlers.message ?? null;
|
|
2739
|
+
}
|
|
2740
|
+
set onmessage(fn) {
|
|
2741
|
+
this._handlers.message = typeof fn === "function" ? fn : null;
|
|
2742
|
+
}
|
|
2743
|
+
get onerror() {
|
|
2744
|
+
return this._handlers.error ?? null;
|
|
2745
|
+
}
|
|
2746
|
+
set onerror(fn) {
|
|
2747
|
+
this._handlers.error = typeof fn === "function" ? fn : null;
|
|
2748
|
+
}
|
|
2749
|
+
get onclose() {
|
|
2750
|
+
return this._handlers.close ?? null;
|
|
2751
|
+
}
|
|
2752
|
+
set onclose(fn) {
|
|
2753
|
+
this._handlers.close = typeof fn === "function" ? fn : null;
|
|
2754
|
+
}
|
|
2755
|
+
send(data) {
|
|
2756
|
+
if (this._state === 0) throw new DOMException("Still in CONNECTING state.", "InvalidStateError");
|
|
2757
|
+
if (isBlob(data)) {
|
|
2758
|
+
const size = data.size;
|
|
2759
|
+
if (this._state !== 1) {
|
|
2760
|
+
this._sendData(new ArrayBuffer(size));
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
this._reserve(size);
|
|
2764
|
+
this._enqueue(async () => this._sendData(await data.arrayBuffer(), true));
|
|
2765
|
+
return;
|
|
2766
|
+
}
|
|
2767
|
+
const payload = isBinary(data) ? toArrayBuffer(data) : String(data);
|
|
2768
|
+
if (this._chain) this._enqueue(async () => this._sendData(payload));
|
|
2769
|
+
else this._sendData(payload);
|
|
2770
|
+
}
|
|
2771
|
+
close(code, reason) {
|
|
2772
|
+
if (code !== void 0 && !isApplicationCloseCode(code)) throw new DOMException(`The close code must be 1000 or in 3000-4999; ${code} is neither.`, "InvalidAccessError");
|
|
2773
|
+
const text = reason ?? "";
|
|
2774
|
+
if (utf8Length(text) > 123) throw new DOMException("The close reason is longer than 123 bytes.", "SyntaxError");
|
|
2775
|
+
if (this._state === 2 || this._state === 3) return;
|
|
2776
|
+
if (this._state === 0) {
|
|
2777
|
+
this._state = 2;
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
this._startClose(code ?? 1005, text);
|
|
2781
|
+
}
|
|
2782
|
+
_dispatchMessage(data) {
|
|
2783
|
+
const value = typeof data === "string" || this._binaryType === "arraybuffer" ? data : new Blob([data]);
|
|
2784
|
+
this.dispatchEvent(new MessageEvent("message", {
|
|
2785
|
+
data: value,
|
|
2786
|
+
origin: new URL(this.url).origin
|
|
2787
|
+
}));
|
|
2788
|
+
}
|
|
2789
|
+
_onPong() {
|
|
2790
|
+
this._unansweredSince = void 0;
|
|
2791
|
+
}
|
|
2792
|
+
_onClosed() {
|
|
2793
|
+
if (this._heartbeat !== void 0) this._timers.clearInterval(this._heartbeat);
|
|
2794
|
+
this._heartbeat = void 0;
|
|
2795
|
+
if (this._onVisibility) globalThis.document?.removeEventListener("visibilitychange", this._onVisibility);
|
|
2796
|
+
}
|
|
2797
|
+
_ping(hb = heartbeatMs) {
|
|
2798
|
+
this._unansweredSince ??= Date.now();
|
|
2799
|
+
this._post({
|
|
2800
|
+
t: "p",
|
|
2801
|
+
hb
|
|
2802
|
+
});
|
|
2803
|
+
}
|
|
2804
|
+
_opened(connection, requested) {
|
|
2805
|
+
const { port, protocol = "" } = connection ?? {};
|
|
2806
|
+
if (!port || typeof port.postMessage !== "function") {
|
|
2807
|
+
this._failed();
|
|
2808
|
+
return;
|
|
2809
|
+
}
|
|
2810
|
+
if (this._state !== 0) {
|
|
2811
|
+
try {
|
|
2812
|
+
port.postMessage({
|
|
2813
|
+
t: "c",
|
|
2814
|
+
code: 1006,
|
|
2815
|
+
reason: ""
|
|
2816
|
+
});
|
|
2817
|
+
port.close();
|
|
2818
|
+
} catch {}
|
|
2819
|
+
this._finish(1006, "", false, true);
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
if (protocol !== "" && !requested.includes(protocol)) {
|
|
2823
|
+
port.close();
|
|
2824
|
+
this._failed();
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
this._protocol = protocol;
|
|
2828
|
+
this._attach(port);
|
|
2829
|
+
this._state = 1;
|
|
2830
|
+
this._ping();
|
|
2831
|
+
this._heartbeat = this._timers.setInterval(() => {
|
|
2832
|
+
const since = this._unansweredSince;
|
|
2833
|
+
if (since !== void 0 && Date.now() - since > 2 * heartbeatMs) {
|
|
2834
|
+
this._finish(1006, "", false, true);
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
this._ping();
|
|
2838
|
+
}, heartbeatMs);
|
|
2839
|
+
const doc = globalThis.document;
|
|
2840
|
+
if (doc) {
|
|
2841
|
+
this._onVisibility = () => this._ping(doc.visibilityState === "hidden" ? Math.max(heartbeatMs, 7e4) : heartbeatMs);
|
|
2842
|
+
doc.addEventListener("visibilitychange", this._onVisibility);
|
|
2843
|
+
if (doc.visibilityState === "hidden") this._onVisibility();
|
|
2844
|
+
}
|
|
2845
|
+
this.dispatchEvent(new Event("open"));
|
|
2846
|
+
}
|
|
2847
|
+
_failed() {
|
|
2848
|
+
this._state = 2;
|
|
2849
|
+
this._finish(1006, "", false, true);
|
|
2850
|
+
}
|
|
2851
|
+
_enqueue(task) {
|
|
2852
|
+
const run = (this._chain ?? Promise.resolve()).then(task).catch(() => {});
|
|
2853
|
+
this._chain = run;
|
|
2854
|
+
run.then(() => {
|
|
2855
|
+
if (this._chain === run) this._chain = void 0;
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
return WebSocketOverPort;
|
|
2860
|
+
}
|
|
2861
|
+
function parseUrl(input) {
|
|
2862
|
+
let url;
|
|
2863
|
+
try {
|
|
2864
|
+
url = new URL(`${input}`, globalThis.location?.href);
|
|
2865
|
+
} catch {
|
|
2866
|
+
throw new DOMException(`The URL '${input}' is invalid.`, "SyntaxError");
|
|
2867
|
+
}
|
|
2868
|
+
if (url.protocol === "http:") url.protocol = "ws:";
|
|
2869
|
+
else if (url.protocol === "https:") url.protocol = "wss:";
|
|
2870
|
+
if (url.protocol !== "ws:" && url.protocol !== "wss:") throw new DOMException(`The URL's scheme must be 'ws', 'wss', 'http' or 'https'; '${url.protocol.slice(0, -1)}' is not allowed.`, "SyntaxError");
|
|
2871
|
+
if (url.hash !== "" || `${input}`.includes("#")) throw new DOMException(`The URL contains a fragment identifier ('${url.hash}'). Fragment identifiers are not allowed in WebSocket URLs.`, "SyntaxError");
|
|
2872
|
+
return url.href;
|
|
2873
|
+
}
|
|
2874
|
+
function validateProtocols(protocols) {
|
|
2875
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2876
|
+
for (const protocol of protocols) {
|
|
2877
|
+
if (!TOKEN.test(protocol)) throw new DOMException(`The subprotocol '${protocol}' is invalid.`, "SyntaxError");
|
|
2878
|
+
if (seen.has(protocol)) throw new DOMException(`The subprotocol '${protocol}' is duplicated.`, "SyntaxError");
|
|
2879
|
+
seen.add(protocol);
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
//#endregion
|
|
2883
|
+
//#region src/link/errors.ts
|
|
2884
|
+
function errorMessage(error) {
|
|
2885
|
+
return error instanceof Error ? error.message : String(error);
|
|
2886
|
+
}
|
|
2887
|
+
//#endregion
|
|
2888
|
+
//#region src/link/rendezvous.ts
|
|
2889
|
+
/**
|
|
2890
|
+
* Matches replies to requests by id, for the control messages that hand
|
|
2891
|
+
* WebSocket ports across: each waiting request has a deadline, and all of a
|
|
2892
|
+
* peer's waiting requests can be failed at once when the peer is lost.
|
|
2893
|
+
*/
|
|
2894
|
+
var PortRendezvous = class {
|
|
2895
|
+
_prefix;
|
|
2896
|
+
_waiting = /* @__PURE__ */ new Map();
|
|
2897
|
+
_counter = 0;
|
|
2898
|
+
constructor(_prefix = "r") {
|
|
2899
|
+
this._prefix = _prefix;
|
|
2900
|
+
}
|
|
2901
|
+
/** A fresh id for a request. */
|
|
2902
|
+
newId() {
|
|
2903
|
+
return `${this._prefix}:${++this._counter}`;
|
|
2904
|
+
}
|
|
2905
|
+
get size() {
|
|
2906
|
+
return this._waiting.size;
|
|
2907
|
+
}
|
|
2908
|
+
/** Waits for the reply to `id`, failing after `timeoutMs`. */
|
|
2909
|
+
wait(id, timeoutMs, describe = "the reply") {
|
|
2910
|
+
return new Promise((resolve, reject) => {
|
|
2911
|
+
const timer = setTimeout(() => {
|
|
2912
|
+
this._waiting.delete(id);
|
|
2913
|
+
reject(/* @__PURE__ */ new Error(`No answer to ${describe} within ${timeoutMs} ms`));
|
|
2914
|
+
}, timeoutMs);
|
|
2915
|
+
this._waiting.set(id, {
|
|
2916
|
+
resolve,
|
|
2917
|
+
reject,
|
|
2918
|
+
timer
|
|
2919
|
+
});
|
|
2920
|
+
});
|
|
2921
|
+
}
|
|
2922
|
+
/** Delivers the reply to `id`. `false` when nobody waits for it (it timed out). */
|
|
2923
|
+
settle(id, value) {
|
|
2924
|
+
const waiting = this._waiting.get(id);
|
|
2925
|
+
if (!waiting) return false;
|
|
2926
|
+
this._waiting.delete(id);
|
|
2927
|
+
clearTimeout(waiting.timer);
|
|
2928
|
+
waiting.resolve(value);
|
|
2929
|
+
return true;
|
|
2930
|
+
}
|
|
2931
|
+
fail(id, error) {
|
|
2932
|
+
const waiting = this._waiting.get(id);
|
|
2933
|
+
if (!waiting) return;
|
|
2934
|
+
this._waiting.delete(id);
|
|
2935
|
+
clearTimeout(waiting.timer);
|
|
2936
|
+
waiting.reject(error);
|
|
2937
|
+
}
|
|
2938
|
+
failAll(error) {
|
|
2939
|
+
for (const id of [...this._waiting.keys()]) this.fail(id, error);
|
|
2940
|
+
}
|
|
2941
|
+
};
|
|
2942
|
+
//#endregion
|
|
2943
|
+
//#region src/ws/server.ts
|
|
2944
|
+
/**
|
|
2945
|
+
* The server end of a socket over a `MessagePort`: what a handler gets from
|
|
2946
|
+
* `upgradeWebSocket` and talks to.
|
|
2947
|
+
*
|
|
2948
|
+
* - Events are delivered only after `accept()`; frames that arrive earlier are
|
|
2949
|
+
* queued. Pings are answered even before `accept()`, so a slow handler does
|
|
2950
|
+
* not look like a dead one.
|
|
2951
|
+
* - Messages arrive as strings or `ArrayBuffer`s.
|
|
2952
|
+
* - `bufferedAmount` counts what the client has not consumed yet, and
|
|
2953
|
+
* `whenDrained(limit)` waits for it to fall: the backpressure a producer
|
|
2954
|
+
* applies.
|
|
2955
|
+
* - When the client's pings stop for three heartbeat periods (a closed tab, a
|
|
2956
|
+
* terminated worker), the socket closes with 1006.
|
|
2957
|
+
*/
|
|
2958
|
+
var ServerWebSocket = class extends PortSocketCore {
|
|
2959
|
+
static CONNECTING = 0;
|
|
2960
|
+
static OPEN = 1;
|
|
2961
|
+
static CLOSING = 2;
|
|
2962
|
+
static CLOSED = 3;
|
|
2963
|
+
url;
|
|
2964
|
+
protocol;
|
|
2965
|
+
_accepted = false;
|
|
2966
|
+
_queue = [];
|
|
2967
|
+
_heartbeatMs = DEFAULT_HEARTBEAT_MS;
|
|
2968
|
+
_lastPing = Date.now();
|
|
2969
|
+
_watch;
|
|
2970
|
+
constructor(port, options = {}) {
|
|
2971
|
+
super({
|
|
2972
|
+
timers: options.timers,
|
|
2973
|
+
closeTimeoutMs: options.closeTimeoutMs
|
|
2974
|
+
});
|
|
2975
|
+
this.url = options.url ?? "";
|
|
2976
|
+
this.protocol = options.protocol ?? "";
|
|
2977
|
+
this._attach(port);
|
|
2978
|
+
this._state = 1;
|
|
2979
|
+
this._watchHeartbeat();
|
|
2980
|
+
}
|
|
2981
|
+
/** Whether `accept()` was called. */
|
|
2982
|
+
get accepted() {
|
|
2983
|
+
return this._accepted;
|
|
2984
|
+
}
|
|
2985
|
+
/** Starts delivering events, the queued ones first. */
|
|
2986
|
+
accept() {
|
|
2987
|
+
if (this._accepted) return;
|
|
2988
|
+
this._accepted = true;
|
|
2989
|
+
for (const frame of this._queue.splice(0)) super._deliver(frame);
|
|
2990
|
+
}
|
|
2991
|
+
send(data) {
|
|
2992
|
+
if (!this._accepted) throw new DOMException("accept() the socket before sending on it.", "InvalidStateError");
|
|
2993
|
+
this._sendData(isBinary(data) ? toArrayBuffer(data) : String(data));
|
|
2994
|
+
}
|
|
2995
|
+
close(code = 1e3, reason = "") {
|
|
2996
|
+
if (!isSendableCloseCode(code)) throw new DOMException(`${code} is not a close code an endpoint may send.`, "InvalidAccessError");
|
|
2997
|
+
if (utf8Length(reason) > 123) throw new DOMException("The close reason is longer than 123 bytes.", "SyntaxError");
|
|
2998
|
+
this._startClose(code, reason);
|
|
2999
|
+
}
|
|
3000
|
+
_deliver(frame) {
|
|
3001
|
+
if (!this._accepted) {
|
|
3002
|
+
this._queue.push(frame);
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
super._deliver(frame);
|
|
3006
|
+
}
|
|
3007
|
+
_dispatchMessage(data) {
|
|
3008
|
+
this.dispatchEvent(new MessageEvent("message", { data }));
|
|
3009
|
+
}
|
|
3010
|
+
_onPing(hb) {
|
|
3011
|
+
this._post({ t: "q" });
|
|
3012
|
+
this._lastPing = Date.now();
|
|
3013
|
+
if (hb !== this._heartbeatMs) {
|
|
3014
|
+
this._heartbeatMs = hb;
|
|
3015
|
+
this._watchHeartbeat();
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
_onClosed() {
|
|
3019
|
+
if (this._watch !== void 0) this._timers.clearInterval(this._watch);
|
|
3020
|
+
this._watch = void 0;
|
|
3021
|
+
}
|
|
3022
|
+
_watchHeartbeat() {
|
|
3023
|
+
if (this._watch !== void 0) this._timers.clearInterval(this._watch);
|
|
3024
|
+
this._watch = this._timers.setInterval(() => {
|
|
3025
|
+
if (Date.now() - this._lastPing > 3 * this._heartbeatMs) this._finish(1006, "", false, true);
|
|
3026
|
+
}, this._heartbeatMs);
|
|
3027
|
+
}
|
|
3028
|
+
};
|
|
3029
|
+
/** Codes an endpoint may put in a close frame (RFC 6455 §7.4). */
|
|
3030
|
+
function isSendableCloseCode(code) {
|
|
3031
|
+
if (!Number.isInteger(code)) return false;
|
|
3032
|
+
if (code >= 3e3 && code <= 4999) return true;
|
|
3033
|
+
return code >= 1e3 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006;
|
|
3034
|
+
}
|
|
3035
|
+
//#endregion
|
|
3036
|
+
//#region src/ws/upgrade.ts
|
|
3037
|
+
/**
|
|
3038
|
+
* The upgrade, as an HTTP exchange: a `GET` with `Upgrade: websocket` goes to
|
|
3039
|
+
* an ordinary `(Request) => Response` handler, and a `101` answer carrying a
|
|
3040
|
+
* server socket means accepted. Anything else is a refusal, with its status.
|
|
3041
|
+
*
|
|
3042
|
+
* FORBIDDEN HEADERS. A browser drops `Upgrade`, `Connection` and every
|
|
3043
|
+
* `Sec-*` header from a `new Request()`. The upgrade request therefore carries
|
|
3044
|
+
* its headers as an own `headers` property, a standalone `Headers` that keeps
|
|
3045
|
+
* them, so handler code reads `request.headers.get("upgrade")` as usual.
|
|
3046
|
+
*
|
|
3047
|
+
* NO 101 IN A `Response`. The constructor refuses statuses below 200, so the
|
|
3048
|
+
* response `upgradeWebSocket` returns reports 101 through an own `status`
|
|
3049
|
+
* property; the port that goes back to the caller is found with `takePort`.
|
|
3050
|
+
*/
|
|
3051
|
+
/** The request header carrying the client's version of the frame protocol. */
|
|
3052
|
+
const WS_VERSION_HEADER = "x-webrun-ws-version";
|
|
3053
|
+
/**
|
|
3054
|
+
* The `GET` a handler sees for `new WebSocket(url, protocols)`. A `ws:` or
|
|
3055
|
+
* `wss:` url is given as `http:` or `https:`, as a server sees it.
|
|
3056
|
+
*/
|
|
3057
|
+
function newWebSocketUpgradeRequest(url, { protocols = [], headers } = {}) {
|
|
3058
|
+
const httpUrl = new URL(`${url}`);
|
|
3059
|
+
if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
|
|
3060
|
+
else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
|
|
3061
|
+
const all = new Headers(headers);
|
|
3062
|
+
all.set("upgrade", "websocket");
|
|
3063
|
+
all.set("connection", "Upgrade");
|
|
3064
|
+
all.set("sec-websocket-version", "13");
|
|
3065
|
+
if (!all.has("sec-websocket-key")) all.set("sec-websocket-key", randomKey());
|
|
3066
|
+
if (protocols.length > 0) all.set("sec-websocket-protocol", protocols.join(", "));
|
|
3067
|
+
if (!all.has("x-webrun-ws-version")) all.set(WS_VERSION_HEADER, `2`);
|
|
3068
|
+
return withHeaders(new Request(httpUrl, {
|
|
3069
|
+
method: "GET",
|
|
3070
|
+
headers: all
|
|
3071
|
+
}), all);
|
|
3072
|
+
}
|
|
3073
|
+
/**
|
|
3074
|
+
* Re-attaches `headers` to `request` as its own `headers` property, keeping
|
|
3075
|
+
* the forbidden ones a browser dropped. Returns `request`.
|
|
3076
|
+
*/
|
|
3077
|
+
function withHeaders(request, headers) {
|
|
3078
|
+
Object.defineProperty(request, "headers", {
|
|
3079
|
+
value: headers,
|
|
3080
|
+
configurable: true
|
|
3081
|
+
});
|
|
3082
|
+
return request;
|
|
3083
|
+
}
|
|
3084
|
+
/** Whether `request` asks for a WebSocket. */
|
|
3085
|
+
function isWebSocketUpgrade(request) {
|
|
3086
|
+
return request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
3087
|
+
}
|
|
3088
|
+
/** The subprotocols `request` asks for, in order of preference. */
|
|
3089
|
+
function requestedProtocols(request) {
|
|
3090
|
+
const header = request.headers.get("sec-websocket-protocol");
|
|
3091
|
+
if (!header) return [];
|
|
3092
|
+
return header.split(",").map((protocol) => protocol.trim()).filter((protocol) => protocol !== "");
|
|
3093
|
+
}
|
|
3094
|
+
/**
|
|
3095
|
+
* Where a response from `upgradeWebSocket` keeps its port. A registered
|
|
3096
|
+
* symbol, not a module-level `WeakMap`: the handler and the host that takes
|
|
3097
|
+
* the port often run different bundles of this package (a page's adapter from
|
|
3098
|
+
* `dist/sw.js`, its handler code importing `dist/worker.js`), and each bundle
|
|
3099
|
+
* has its own copy of every module.
|
|
3100
|
+
*/
|
|
3101
|
+
const UPGRADE = Symbol.for("webrun-http-browser:websocket-upgrade");
|
|
3102
|
+
/**
|
|
3103
|
+
* Accepts a WebSocket upgrade in a handler:
|
|
3104
|
+
*
|
|
3105
|
+
* ```ts
|
|
3106
|
+
* if (isWebSocketUpgrade(request)) {
|
|
3107
|
+
* const { socket, response } = upgradeWebSocket(request, { protocol: "v1" });
|
|
3108
|
+
* socket.accept();
|
|
3109
|
+
* socket.addEventListener("message", (event) => socket.send(event.data));
|
|
3110
|
+
* return response;
|
|
3111
|
+
* }
|
|
3112
|
+
* ```
|
|
3113
|
+
*
|
|
3114
|
+
* The host takes the client's port from the response with `takePort` and
|
|
3115
|
+
* hands it to the caller.
|
|
3116
|
+
*/
|
|
3117
|
+
function upgradeWebSocket(request, options = {}) {
|
|
3118
|
+
const protocol = options.protocol ?? "";
|
|
3119
|
+
const channel = new MessageChannel();
|
|
3120
|
+
const socket = new ServerWebSocket(channel.port1, {
|
|
3121
|
+
...options,
|
|
3122
|
+
url: request.url,
|
|
3123
|
+
protocol
|
|
3124
|
+
});
|
|
3125
|
+
const response = new Response(null, {
|
|
3126
|
+
status: 200,
|
|
3127
|
+
headers: protocol ? { "sec-websocket-protocol": protocol } : {}
|
|
3128
|
+
});
|
|
3129
|
+
Object.defineProperties(response, {
|
|
3130
|
+
status: {
|
|
3131
|
+
value: 101,
|
|
3132
|
+
configurable: true
|
|
3133
|
+
},
|
|
3134
|
+
statusText: {
|
|
3135
|
+
value: "Switching Protocols",
|
|
3136
|
+
configurable: true
|
|
3137
|
+
},
|
|
3138
|
+
webSocket: {
|
|
3139
|
+
value: socket,
|
|
3140
|
+
configurable: true
|
|
3141
|
+
},
|
|
3142
|
+
[UPGRADE]: {
|
|
3143
|
+
value: {
|
|
3144
|
+
port: channel.port2,
|
|
3145
|
+
protocol
|
|
3146
|
+
},
|
|
3147
|
+
configurable: true,
|
|
3148
|
+
writable: true
|
|
3149
|
+
}
|
|
3150
|
+
});
|
|
3151
|
+
return {
|
|
3152
|
+
socket,
|
|
3153
|
+
response
|
|
3154
|
+
};
|
|
3155
|
+
}
|
|
3156
|
+
/**
|
|
3157
|
+
* The port to hand to the caller, for a response from `upgradeWebSocket`.
|
|
3158
|
+
* Taken once: a second call returns `undefined`.
|
|
3159
|
+
*/
|
|
3160
|
+
function takePort(response) {
|
|
3161
|
+
return takeUpgrade(response)?.port;
|
|
3162
|
+
}
|
|
3163
|
+
/** `takePort`, with the subprotocol the server picked. */
|
|
3164
|
+
function takeUpgrade(response) {
|
|
3165
|
+
const holder = response;
|
|
3166
|
+
const upgrade = holder[UPGRADE];
|
|
3167
|
+
if (upgrade) holder[UPGRADE] = void 0;
|
|
3168
|
+
return upgrade;
|
|
3169
|
+
}
|
|
3170
|
+
/**
|
|
3171
|
+
* Runs an upgrade through `handler`, as a host does when a caller asks for a
|
|
3172
|
+
* socket: builds the upgrade request, calls the handler, and either takes the
|
|
3173
|
+
* port of a `101` or reports the refusal. A subprotocol the caller did not ask
|
|
3174
|
+
* for is a refusal, as in the standard.
|
|
3175
|
+
*/
|
|
3176
|
+
async function serveWebSocketUpgrade(handler, { url, protocols = [], headers }) {
|
|
3177
|
+
let response;
|
|
3178
|
+
try {
|
|
3179
|
+
response = await handler(newWebSocketUpgradeRequest(url, {
|
|
3180
|
+
protocols,
|
|
3181
|
+
headers
|
|
3182
|
+
}));
|
|
3183
|
+
} catch (error) {
|
|
3184
|
+
return {
|
|
3185
|
+
ok: false,
|
|
3186
|
+
status: 500,
|
|
3187
|
+
message: errorMessage(error)
|
|
3188
|
+
};
|
|
3189
|
+
}
|
|
3190
|
+
const upgrade = takeUpgrade(response);
|
|
3191
|
+
if (!upgrade || response.status !== 101) {
|
|
3192
|
+
upgrade?.port.close();
|
|
3193
|
+
try {
|
|
3194
|
+
await response.body?.cancel();
|
|
3195
|
+
} catch {}
|
|
3196
|
+
return {
|
|
3197
|
+
ok: false,
|
|
3198
|
+
status: response.status,
|
|
3199
|
+
message: `The server refused the upgrade: ${response.status} ${response.statusText}`.trim()
|
|
3200
|
+
};
|
|
3201
|
+
}
|
|
3202
|
+
if (upgrade.protocol !== "" && !protocols.includes(upgrade.protocol)) {
|
|
3203
|
+
upgrade.port.close();
|
|
3204
|
+
return {
|
|
3205
|
+
ok: false,
|
|
3206
|
+
status: 400,
|
|
3207
|
+
message: `The server picked the subprotocol '${upgrade.protocol}', which was not requested.`
|
|
3208
|
+
};
|
|
3209
|
+
}
|
|
3210
|
+
return {
|
|
3211
|
+
ok: true,
|
|
3212
|
+
port: upgrade.port,
|
|
3213
|
+
protocol: upgrade.protocol
|
|
3214
|
+
};
|
|
3215
|
+
}
|
|
3216
|
+
function randomKey() {
|
|
3217
|
+
const bytes = /* @__PURE__ */ new Uint8Array(16);
|
|
3218
|
+
crypto.getRandomValues(bytes);
|
|
3219
|
+
return btoa(String.fromCharCode(...bytes));
|
|
3220
|
+
}
|
|
3221
|
+
//#endregion
|
|
3222
|
+
//#region src/sw/handler-endpoint.ts
|
|
3223
|
+
/** The message calls through a lost ServiceWorker fail with. */
|
|
3224
|
+
const ROUTER_LOST_MESSAGE = "The ServiceWorker was lost while this call was in flight";
|
|
3225
|
+
/**
|
|
3226
|
+
* The handler's end of its links to the ServiceWorker, the same in a page and
|
|
3227
|
+
* in a Dedicated Worker.
|
|
3228
|
+
*
|
|
3229
|
+
* - Serves `handler` on each link's `down` port, and answers WebSocket
|
|
3230
|
+
* upgrades arriving on its `ctl` port by running them through `handler`.
|
|
3231
|
+
* - Sends requests through the router on `up` (`fetch`) and opens sockets to
|
|
3232
|
+
* other handlers over `ctl` (`connectWebSocket`).
|
|
3233
|
+
* - Watches the ServiceWorker's router lock. When the ServiceWorker dies, the
|
|
3234
|
+
* links made for it are dropped: calls in flight on them fail with
|
|
3235
|
+
* `LinkLostError` ("may or may not have been applied") instead of hanging,
|
|
3236
|
+
* and new calls wait for a link, asking for one (`requestRelink`) every
|
|
3237
|
+
* `relinkRetryMs` until it comes.
|
|
3238
|
+
* - While calls are in flight, calls `keepAlive` every `keepAliveMs`: port
|
|
3239
|
+
* messages are not events, so without it the browser may stop the
|
|
3240
|
+
* ServiceWorker in the middle of a long call.
|
|
3241
|
+
*/
|
|
3242
|
+
var HandlerEndpoint = class {
|
|
3243
|
+
handler;
|
|
3244
|
+
options;
|
|
3245
|
+
_links = /* @__PURE__ */ new Set();
|
|
3246
|
+
_waiters = [];
|
|
3247
|
+
_sockets = new PortRendezvous("o");
|
|
3248
|
+
_keepAliveTimer;
|
|
3249
|
+
_closed = false;
|
|
3250
|
+
constructor(handler, options = {}) {
|
|
3251
|
+
this.handler = handler;
|
|
3252
|
+
this.options = options;
|
|
3253
|
+
}
|
|
3254
|
+
/** Whether any link is live. */
|
|
3255
|
+
get linked() {
|
|
3256
|
+
return this._links.size > 0;
|
|
3257
|
+
}
|
|
3258
|
+
/** Installs a fresh link made for the router holding `routerLock`. */
|
|
3259
|
+
async link(key, ports, routerLock) {
|
|
3260
|
+
if (this._closed) {
|
|
3261
|
+
for (const port of Object.values(ports)) port?.close();
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
const closeDown = await serveHttpLink(ports.down, this.handler, this.options);
|
|
3265
|
+
const up = ports.up ? await connectHttpLink(ports.up, this.options) : void 0;
|
|
3266
|
+
const link = {
|
|
3267
|
+
key,
|
|
3268
|
+
routerLock,
|
|
3269
|
+
ctl: ports.ctl,
|
|
3270
|
+
up,
|
|
3271
|
+
closeDown,
|
|
3272
|
+
stopWatch: () => {}
|
|
3273
|
+
};
|
|
3274
|
+
if (ports.ctl) ports.ctl.onmessage = (event) => this._onControl(link, event.data);
|
|
3275
|
+
if (routerLock) link.stopWatch = watchLock(routerLock, () => this._routerLost(routerLock));
|
|
3276
|
+
for (const old of [...this._links]) {
|
|
3277
|
+
if (old.key !== key) continue;
|
|
3278
|
+
if (old.routerLock !== routerLock) this._drop(old, new LinkLostError(ROUTER_LOST_MESSAGE));
|
|
3279
|
+
else if (!old.superseded) {
|
|
3280
|
+
old.superseded = true;
|
|
3281
|
+
setTimeout(() => void this._drop(old), this.options.supersededLinkMs ?? 3e4);
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
this._links.add(link);
|
|
3285
|
+
for (const wake of this._waiters.splice(0)) wake();
|
|
3286
|
+
}
|
|
3287
|
+
/** Drops the links attached under `key` (all of them without a key). */
|
|
3288
|
+
async unlink(key, reason) {
|
|
3289
|
+
const dropped = [...this._links].filter((link) => key === void 0 || link.key === key);
|
|
3290
|
+
await Promise.all(dropped.map((link) => this._drop(link, reason)));
|
|
3291
|
+
}
|
|
3292
|
+
/** The routers this endpoint has live links for. */
|
|
3293
|
+
routerLocks() {
|
|
3294
|
+
return new Set([...this._links].map((link) => link.routerLock));
|
|
3295
|
+
}
|
|
3296
|
+
/**
|
|
3297
|
+
* Sends `request` through the ServiceWorker's router, as if the browser had
|
|
3298
|
+
* fetched it: it reaches the handler its url routes to.
|
|
3299
|
+
*/
|
|
3300
|
+
async fetch(request) {
|
|
3301
|
+
const link = await this._linkWith("up");
|
|
3302
|
+
this._startKeepAlive();
|
|
3303
|
+
return await link.up.send(request);
|
|
3304
|
+
}
|
|
3305
|
+
/** Opens a socket to the handler `url` routes to, through the ServiceWorker. */
|
|
3306
|
+
async connectWebSocket(url, protocols = []) {
|
|
3307
|
+
const link = await this._linkWith("ctl");
|
|
3308
|
+
const id = this._sockets.newId();
|
|
3309
|
+
const answer = this._sockets.wait(id, this.options.wsConnectTimeoutMs ?? 15e3, `the socket request for ${url}`);
|
|
3310
|
+
link.ctl?.postMessage({
|
|
3311
|
+
t: "ws-open",
|
|
3312
|
+
id,
|
|
3313
|
+
url,
|
|
3314
|
+
protocols
|
|
3315
|
+
});
|
|
3316
|
+
return await answer;
|
|
3317
|
+
}
|
|
3318
|
+
async close() {
|
|
3319
|
+
this._closed = true;
|
|
3320
|
+
if (this._keepAliveTimer) clearInterval(this._keepAliveTimer);
|
|
3321
|
+
await this.unlink(void 0, new LinkLostError("The handler was closed"));
|
|
3322
|
+
for (const wake of this._waiters.splice(0)) wake();
|
|
3323
|
+
}
|
|
3324
|
+
async _drop(link, reason) {
|
|
3325
|
+
if (!this._links.delete(link)) return;
|
|
3326
|
+
link.stopWatch();
|
|
3327
|
+
await link.up?.close(reason);
|
|
3328
|
+
try {
|
|
3329
|
+
await link.closeDown();
|
|
3330
|
+
} catch {}
|
|
3331
|
+
if (link.ctl) {
|
|
3332
|
+
link.ctl.onmessage = null;
|
|
3333
|
+
link.ctl.close();
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
_routerLost(routerLock) {
|
|
3337
|
+
const reason = new LinkLostError(ROUTER_LOST_MESSAGE);
|
|
3338
|
+
for (const link of [...this._links]) if (link.routerLock === routerLock) this._drop(link, reason);
|
|
3339
|
+
this._sockets.failAll(reason);
|
|
3340
|
+
}
|
|
3341
|
+
/** A live link with `port`, waiting (and asking) for one when there is none. */
|
|
3342
|
+
async _linkWith(port) {
|
|
3343
|
+
const find = () => [...this._links].filter((link) => !link.superseded).reverse().find((link) => port === "up" ? link.up && !link.up.closed : link.ctl);
|
|
3344
|
+
const found = find();
|
|
3345
|
+
if (found) return found;
|
|
3346
|
+
const retryMs = this.options.relinkRetryMs ?? 2e3;
|
|
3347
|
+
const deadline = Date.now() + (this.options.relinkTimeoutMs ?? 3e4);
|
|
3348
|
+
while (!this._closed) {
|
|
3349
|
+
this.options.requestRelink?.();
|
|
3350
|
+
await new Promise((resolve) => {
|
|
3351
|
+
const timer = setTimeout(resolve, Math.max(0, Math.min(retryMs, deadline - Date.now())));
|
|
3352
|
+
this._waiters.push(() => {
|
|
3353
|
+
clearTimeout(timer);
|
|
3354
|
+
resolve();
|
|
3355
|
+
});
|
|
3356
|
+
});
|
|
3357
|
+
const link = find();
|
|
3358
|
+
if (link) return link;
|
|
3359
|
+
if (Date.now() >= deadline) break;
|
|
3360
|
+
}
|
|
3361
|
+
throw new LinkLostError("No link to the ServiceWorker could be made for this call");
|
|
3362
|
+
}
|
|
3363
|
+
_startKeepAlive() {
|
|
3364
|
+
if (this._keepAliveTimer || !this.options.keepAlive) return;
|
|
3365
|
+
this._keepAliveTimer = setInterval(() => {
|
|
3366
|
+
if ([...this._links].reduce((sum, link) => sum + (link.up?.inflight ?? 0), 0) === 0) {
|
|
3367
|
+
clearInterval(this._keepAliveTimer);
|
|
3368
|
+
this._keepAliveTimer = void 0;
|
|
3369
|
+
return;
|
|
3370
|
+
}
|
|
3371
|
+
this.options.keepAlive?.();
|
|
3372
|
+
}, this.options.keepAliveMs ?? 2e4);
|
|
3373
|
+
}
|
|
3374
|
+
_onControl(link, message) {
|
|
3375
|
+
if (!message || typeof message !== "object") return;
|
|
3376
|
+
switch (message.t) {
|
|
3377
|
+
case "ws-connect":
|
|
3378
|
+
this._acceptSocket(link, message);
|
|
3379
|
+
return;
|
|
3380
|
+
case "ws-opened":
|
|
3381
|
+
if (!this._sockets.settle(message.id, {
|
|
3382
|
+
port: message.port,
|
|
3383
|
+
protocol: message.protocol
|
|
3384
|
+
})) message.port.close();
|
|
3385
|
+
return;
|
|
3386
|
+
case "ws-failed":
|
|
3387
|
+
this._sockets.fail(message.id, new Error(message.message));
|
|
3388
|
+
return;
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
async _acceptSocket(link, { id, url, protocols, headers }) {
|
|
3392
|
+
const ctl = link.ctl;
|
|
3393
|
+
if (!ctl) return;
|
|
3394
|
+
try {
|
|
3395
|
+
const result = await serveWebSocketUpgrade(this.handler, {
|
|
3396
|
+
url,
|
|
3397
|
+
protocols,
|
|
3398
|
+
headers
|
|
3399
|
+
});
|
|
3400
|
+
if (result.ok) ctl.postMessage({
|
|
3401
|
+
t: "ws-accept",
|
|
3402
|
+
id,
|
|
3403
|
+
protocol: result.protocol,
|
|
3404
|
+
port: result.port
|
|
3405
|
+
}, [result.port]);
|
|
3406
|
+
else ctl.postMessage({
|
|
3407
|
+
t: "ws-refuse",
|
|
3408
|
+
id,
|
|
3409
|
+
status: result.status,
|
|
3410
|
+
message: result.message
|
|
3411
|
+
});
|
|
3412
|
+
} catch (error) {
|
|
3413
|
+
ctl.postMessage({
|
|
3414
|
+
t: "ws-refuse",
|
|
3415
|
+
id,
|
|
3416
|
+
status: 500,
|
|
3417
|
+
message: errorMessage(error)
|
|
3418
|
+
});
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
};
|
|
3422
|
+
//#endregion
|
|
3423
|
+
//#region src/sw/worker-service.ts
|
|
3424
|
+
/**
|
|
3425
|
+
* Serves `handler` from a Dedicated Worker, on the links the page attaches
|
|
3426
|
+
* with `adapter.attachWorker(worker)`:
|
|
3427
|
+
*
|
|
3428
|
+
* ```ts
|
|
3429
|
+
* // worker.ts
|
|
3430
|
+
* const service = serveHttpInWorker(async (request) => new Response("hi"));
|
|
3431
|
+
* ```
|
|
3432
|
+
*
|
|
3433
|
+
* The worker holds an owner lock while it lives, so the ServiceWorker notices
|
|
3434
|
+
* at once when it is terminated. It watches the ServiceWorker's router lock,
|
|
3435
|
+
* and asks its page to attach it again when its links died and a call waits.
|
|
3436
|
+
* While calls through the router are in flight it asks its page every 20 s to
|
|
3437
|
+
* keep the ServiceWorker awake — only a message to the ServiceWorker object
|
|
3438
|
+
* counts as an event, and a worker cannot post one.
|
|
3439
|
+
*/
|
|
3440
|
+
function serveHttpInWorker(handler, options = {}) {
|
|
3441
|
+
const scope = options.scope ?? globalThis;
|
|
3442
|
+
const ownerLock = newLockName("webrun-http-owner");
|
|
3443
|
+
const held = holdLock(ownerLock);
|
|
3444
|
+
const controls = /* @__PURE__ */ new Set();
|
|
3445
|
+
const tellPages = (message) => {
|
|
3446
|
+
for (const control of controls) control.postMessage(message);
|
|
3447
|
+
};
|
|
3448
|
+
const endpoint = new HandlerEndpoint(handler, {
|
|
3449
|
+
...options,
|
|
3450
|
+
requestRelink: () => tellPages({ type: "relink" }),
|
|
3451
|
+
keepAlive: () => tellPages({ type: "keepalive" })
|
|
3452
|
+
});
|
|
3453
|
+
const onMessage = (event) => {
|
|
3454
|
+
const { data, ports } = event;
|
|
3455
|
+
if (data?.type !== "webrun-http:worker-hello") return;
|
|
3456
|
+
const control = ports?.[0];
|
|
3457
|
+
if (!control) return;
|
|
3458
|
+
controls.add(control);
|
|
3459
|
+
control.onmessage = (message) => {
|
|
3460
|
+
const msg = message.data;
|
|
3461
|
+
if (msg?.type === "link") {
|
|
3462
|
+
const [down, ctl, up] = message.ports;
|
|
3463
|
+
if (down) endpoint.link(msg.key, {
|
|
3464
|
+
down,
|
|
3465
|
+
ctl,
|
|
3466
|
+
up
|
|
3467
|
+
}, msg.routerLock);
|
|
3468
|
+
} else if (msg?.type === "unlink") endpoint.unlink(msg.key);
|
|
3469
|
+
};
|
|
3470
|
+
held.then(() => control.postMessage({
|
|
3471
|
+
type: "ready",
|
|
3472
|
+
ownerLock: locksAvailable() ? ownerLock : void 0
|
|
3473
|
+
}));
|
|
3474
|
+
};
|
|
3475
|
+
scope.addEventListener("message", onMessage);
|
|
3476
|
+
const baseUrl = () => globalThis.location?.href;
|
|
3477
|
+
const connectWebSocket = (url, protocols = []) => {
|
|
3478
|
+
const httpUrl = new URL(`${url}`, baseUrl());
|
|
3479
|
+
if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
|
|
3480
|
+
else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
|
|
3481
|
+
return endpoint.connectWebSocket(`${httpUrl}`, protocols);
|
|
3482
|
+
};
|
|
3483
|
+
let WebSocketClass;
|
|
3484
|
+
return {
|
|
3485
|
+
fetch(input, init) {
|
|
3486
|
+
const request = input instanceof Request && init === void 0 ? input : new Request(input instanceof Request ? input : new URL(`${input}`, baseUrl()), init);
|
|
3487
|
+
return endpoint.fetch(request);
|
|
3488
|
+
},
|
|
3489
|
+
connectWebSocket,
|
|
3490
|
+
get WebSocket() {
|
|
3491
|
+
WebSocketClass ??= createWebSocketClass(connectWebSocket, options.webSocket);
|
|
3492
|
+
return WebSocketClass;
|
|
3493
|
+
},
|
|
3494
|
+
get linked() {
|
|
3495
|
+
return endpoint.linked;
|
|
3496
|
+
},
|
|
3497
|
+
async close() {
|
|
3498
|
+
scope.removeEventListener("message", onMessage);
|
|
3499
|
+
await endpoint.close();
|
|
3500
|
+
for (const control of controls) control.close();
|
|
3501
|
+
controls.clear();
|
|
3502
|
+
(await held)();
|
|
3503
|
+
}
|
|
3504
|
+
};
|
|
3505
|
+
}
|
|
3506
|
+
//#endregion
|
|
3507
|
+
export { DEFAULT_CLOSE_TIMEOUT_MS, DEFAULT_HEARTBEAT_MS, HTTP_LINK_PROTOCOL, LinkLostError, ServerWebSocket, WS_PORT_PROTOCOL_VERSION, WS_VERSION_HEADER, WsCloseEvent, connectHttpLink, createWebSocketClass, isWebSocketUpgrade, newWebSocketUpgradeRequest, requestedProtocols, serveHttpInWorker, serveHttpLink, serveWebSocketUpgrade, takePort, takeUpgrade, trackedLink, upgradeWebSocket, withHeaders };
|