@statewalker/webrun-http-browser 0.3.4 → 0.4.2
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 +87 -2
- package/dist/core/data-channels.d.ts.map +1 -1
- package/dist/core/message-target.d.ts +1 -15
- package/dist/core/message-target.d.ts.map +1 -1
- package/dist/http/http-send-recieve.d.ts +27 -14
- package/dist/http/http-send-recieve.d.ts.map +1 -1
- package/dist/index.js +250 -121
- package/dist/relay-sw.js +848 -66
- package/dist/sw/sw-dispatcher.d.ts.map +1 -1
- package/dist/sw-worker.js +848 -64
- package/dist/sw.js +879 -72
- package/package.json +4 -3
- package/src/core/data-channels.ts +42 -4
- package/src/core/message-target.ts +8 -18
- package/src/http/http-send-recieve.ts +27 -14
- package/src/sw/sw-dispatcher.ts +26 -1
- package/LICENSE +0 -21
package/dist/sw-worker.js
CHANGED
|
@@ -1,31 +1,5 @@
|
|
|
1
1
|
(function() {
|
|
2
|
-
//#region ../webrun-
|
|
3
|
-
function toAsyncIterator(input) {
|
|
4
|
-
const asyncIter = input[Symbol.asyncIterator];
|
|
5
|
-
if (asyncIter) return asyncIter.call(input);
|
|
6
|
-
const syncIter = input[Symbol.iterator]();
|
|
7
|
-
return { next() {
|
|
8
|
-
return Promise.resolve(syncIter.next());
|
|
9
|
-
} };
|
|
10
|
-
}
|
|
11
|
-
/**
|
|
12
|
-
* Discard an iterable we are contractually forbidden from consuming — a body
|
|
13
|
-
* skipped for HEAD/204, or one abandoned because the peer reported an error.
|
|
14
|
-
*
|
|
15
|
-
* The `.next()` is not optional: `.return()` on a generator still in suspended
|
|
16
|
-
* start is a no-op, so the body never runs and its `try/finally` never unwinds.
|
|
17
|
-
* Without it a wrapped ReadableStream or socket is never cancelled.
|
|
18
|
-
*/
|
|
19
|
-
async function discard(source) {
|
|
20
|
-
if (source === void 0) return;
|
|
21
|
-
const it = toAsyncIterator(source);
|
|
22
|
-
try {
|
|
23
|
-
await it.next();
|
|
24
|
-
await it.return?.();
|
|
25
|
-
} catch {}
|
|
26
|
-
}
|
|
27
|
-
//#endregion
|
|
28
|
-
//#region ../webrun-streams/src/errors.ts
|
|
2
|
+
//#region ../webrun-streams/dist/index.js
|
|
29
3
|
function serializeError(error) {
|
|
30
4
|
if (error instanceof Error) {
|
|
31
5
|
const out = {
|
|
@@ -49,8 +23,8 @@
|
|
|
49
23
|
const payload = typeof error === "string" ? { message: error } : error;
|
|
50
24
|
return Object.assign(new Error(payload.message), payload);
|
|
51
25
|
}
|
|
52
|
-
|
|
53
|
-
|
|
26
|
+
new TextEncoder();
|
|
27
|
+
new TextDecoder();
|
|
54
28
|
/**
|
|
55
29
|
* The newAsyncGenerator function creates async generators from callback-based initialization
|
|
56
30
|
* functions, providing a bridge between imperative event handling and declarative async
|
|
@@ -203,36 +177,66 @@
|
|
|
203
177
|
drainQueue();
|
|
204
178
|
}
|
|
205
179
|
}
|
|
206
|
-
|
|
207
|
-
|
|
180
|
+
new TextEncoder();
|
|
181
|
+
/**
|
|
182
|
+
* The iterator ↔ `ReadableStream` boundary.
|
|
183
|
+
*
|
|
184
|
+
* Both adapters must carry CANCELLATION, not just data: a response body leaves
|
|
185
|
+
* a handler as a `ReadableStream`, crosses a transport as an iterator, and
|
|
186
|
+
* becomes a `ReadableStream` again at the caller — so when the caller walks
|
|
187
|
+
* away, the only path back to the handler's producer runs through both of
|
|
188
|
+
* these functions. Teardown that stops at an adapter leaves a producer running
|
|
189
|
+
* for ever.
|
|
190
|
+
*/
|
|
208
191
|
function toReadableStream(it) {
|
|
209
|
-
return new ReadableStream({
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
192
|
+
return new ReadableStream({
|
|
193
|
+
/**
|
|
194
|
+
* One chunk per pull. An earlier version drained the whole iterator inside
|
|
195
|
+
* a single `pull`, which defeated the stream's own backpressure (every
|
|
196
|
+
* chunk was enqueued as fast as the producer could make them, however slow
|
|
197
|
+
* the reader was) and left no point between chunks at which a cancellation
|
|
198
|
+
* could take effect.
|
|
199
|
+
*/
|
|
200
|
+
async pull(controller) {
|
|
201
|
+
try {
|
|
213
202
|
const slot = await it.next();
|
|
214
|
-
if (!slot || slot.done)
|
|
215
|
-
|
|
216
|
-
|
|
203
|
+
if (!slot || slot.done) {
|
|
204
|
+
controller.close();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
controller.enqueue(await slot.value);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
controller.error(error);
|
|
217
210
|
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
211
|
+
},
|
|
212
|
+
/**
|
|
213
|
+
* Release the source. NOT awaited: `.return()` on an async generator that
|
|
214
|
+
* is parked awaiting its own source is queued behind that pending
|
|
215
|
+
* `next()`, so awaiting it here would hang `reader.cancel()` on exactly
|
|
216
|
+
* the producers that most need cancelling.
|
|
217
|
+
*/
|
|
218
|
+
cancel(reason) {
|
|
219
|
+
Promise.resolve(it.return?.(reason)).catch(() => {});
|
|
223
220
|
}
|
|
224
|
-
}
|
|
221
|
+
});
|
|
225
222
|
}
|
|
226
223
|
async function* fromReadableStream(stream) {
|
|
227
224
|
const reader = stream.getReader();
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
225
|
+
let drained = false;
|
|
226
|
+
try {
|
|
227
|
+
while (true) {
|
|
228
|
+
const { done, value } = await reader.read();
|
|
229
|
+
if (done) {
|
|
230
|
+
drained = true;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
if (value !== void 0) yield value;
|
|
234
|
+
}
|
|
235
|
+
} finally {
|
|
236
|
+
if (drained) reader.releaseLock();
|
|
237
|
+
else await reader.cancel().catch(() => {});
|
|
232
238
|
}
|
|
233
239
|
}
|
|
234
|
-
//#endregion
|
|
235
|
-
//#region ../webrun-streams/src/recieve-iterator.ts
|
|
236
240
|
/**
|
|
237
241
|
* Inverse of {@link sendIterator}: turns a sequence of `{done, value, error}`
|
|
238
242
|
* chunks (delivered to the supplied callback by `installer`) into an async
|
|
@@ -255,8 +259,6 @@
|
|
|
255
259
|
};
|
|
256
260
|
});
|
|
257
261
|
}
|
|
258
|
-
//#endregion
|
|
259
|
-
//#region ../webrun-streams/src/send-iterator.ts
|
|
260
262
|
/**
|
|
261
263
|
* Drain an async iterator into a sink that consumes one chunk at a time.
|
|
262
264
|
*
|
|
@@ -281,7 +283,760 @@
|
|
|
281
283
|
}
|
|
282
284
|
}
|
|
283
285
|
//#endregion
|
|
284
|
-
//#region ../webrun-http-streams/
|
|
286
|
+
//#region ../webrun-http-streams/dist/index.js
|
|
287
|
+
const CR = 13;
|
|
288
|
+
const LF = 10;
|
|
289
|
+
const EMPTY = /* @__PURE__ */ new Uint8Array(0);
|
|
290
|
+
var ByteStreamError = class extends Error {
|
|
291
|
+
name = "ByteStreamError";
|
|
292
|
+
};
|
|
293
|
+
function toAsyncIterator(input) {
|
|
294
|
+
const asyncIter = input[Symbol.asyncIterator];
|
|
295
|
+
if (asyncIter) return asyncIter.call(input);
|
|
296
|
+
const syncIter = input[Symbol.iterator]();
|
|
297
|
+
return { next() {
|
|
298
|
+
return Promise.resolve(syncIter.next());
|
|
299
|
+
} };
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Discard an iterable we are contractually forbidden from consuming — a body
|
|
303
|
+
* skipped for HEAD/204, or one abandoned because the peer reported an error.
|
|
304
|
+
*
|
|
305
|
+
* The `.next()` is not optional: `.return()` on a generator still in suspended
|
|
306
|
+
* start is a no-op, so the body never runs and its `try/finally` never unwinds.
|
|
307
|
+
* Without it a wrapped ReadableStream or socket is never cancelled.
|
|
308
|
+
*/
|
|
309
|
+
async function discard(source) {
|
|
310
|
+
if (source === void 0) return;
|
|
311
|
+
const it = toAsyncIterator(source);
|
|
312
|
+
try {
|
|
313
|
+
await it.next();
|
|
314
|
+
await it.return?.();
|
|
315
|
+
} catch {}
|
|
316
|
+
}
|
|
317
|
+
function concatChunks(parts, totalLen) {
|
|
318
|
+
if (parts.length === 1) {
|
|
319
|
+
const only = parts[0];
|
|
320
|
+
if (only !== void 0) return only;
|
|
321
|
+
}
|
|
322
|
+
const out = new Uint8Array(totalLen);
|
|
323
|
+
let off = 0;
|
|
324
|
+
for (const p of parts) {
|
|
325
|
+
out.set(p, off);
|
|
326
|
+
off += p.byteLength;
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Pull-based reader over a byte source. Holds at most one pending buffer, and
|
|
332
|
+
* hands out `subarray` views rather than copies — a body never passes through
|
|
333
|
+
* an allocation here.
|
|
334
|
+
*/
|
|
335
|
+
var ByteReader = class {
|
|
336
|
+
#iter;
|
|
337
|
+
#buf = EMPTY;
|
|
338
|
+
#done = false;
|
|
339
|
+
constructor(input) {
|
|
340
|
+
this.#iter = toAsyncIterator(input);
|
|
341
|
+
}
|
|
342
|
+
/** Bytes already pulled from the source but not yet consumed. */
|
|
343
|
+
bufferedLength() {
|
|
344
|
+
return this.#buf.byteLength;
|
|
345
|
+
}
|
|
346
|
+
/** Pull one more non-empty chunk. Returns false at end of stream. */
|
|
347
|
+
async #pull() {
|
|
348
|
+
if (this.#done) return false;
|
|
349
|
+
while (true) {
|
|
350
|
+
const next = await this.#iter.next();
|
|
351
|
+
if (next.done) {
|
|
352
|
+
this.#done = true;
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
const chunk = next.value;
|
|
356
|
+
if (chunk.byteLength === 0) continue;
|
|
357
|
+
this.#buf = this.#buf.byteLength === 0 ? chunk : concatChunks([this.#buf, chunk], this.#buf.byteLength + chunk.byteLength);
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async peekByte() {
|
|
362
|
+
while (this.#buf.byteLength === 0) if (!await this.#pull()) return void 0;
|
|
363
|
+
return this.#buf[0];
|
|
364
|
+
}
|
|
365
|
+
/** Up to `max` bytes. `undefined` means end of stream. */
|
|
366
|
+
async readSome(max) {
|
|
367
|
+
while (this.#buf.byteLength === 0) if (!await this.#pull()) return void 0;
|
|
368
|
+
const take = Math.min(max, this.#buf.byteLength);
|
|
369
|
+
const out = this.#buf.subarray(0, take);
|
|
370
|
+
this.#buf = this.#buf.subarray(take);
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* One CRLF-terminated line, without the CRLF. A bare LF is rejected: real
|
|
375
|
+
* peers always send CRLF, and tolerating a bare LF is precisely the lenience
|
|
376
|
+
* that lets request smuggling through a proxy pair.
|
|
377
|
+
*
|
|
378
|
+
* The `maxBytes` bound is best-effort: it only rejects a line if the check
|
|
379
|
+
* happens to run before the line is fully buffered. A line already sitting in
|
|
380
|
+
* the buffer bypasses the check. Callers needing a hard per-line limit or
|
|
381
|
+
* aggregate bounds must keep their own running total.
|
|
382
|
+
*/
|
|
383
|
+
async readLine(maxBytes) {
|
|
384
|
+
let searched = 0;
|
|
385
|
+
while (true) {
|
|
386
|
+
const idx = this.#buf.indexOf(LF, searched);
|
|
387
|
+
if (idx !== -1) {
|
|
388
|
+
if (idx === 0 || this.#buf[idx - 1] !== CR) throw new ByteStreamError("bare LF line terminator; CRLF required");
|
|
389
|
+
const line = this.#buf.subarray(0, idx - 1);
|
|
390
|
+
this.#buf = this.#buf.subarray(idx + 1);
|
|
391
|
+
return line;
|
|
392
|
+
}
|
|
393
|
+
searched = this.#buf.byteLength;
|
|
394
|
+
if (searched > maxBytes) throw new ByteStreamError(`line exceeds ${maxBytes} bytes without CRLF`);
|
|
395
|
+
if (!await this.#pull()) throw new ByteStreamError(`stream ended after ${searched} bytes without CRLF`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/** Everything not yet consumed, lazily. */
|
|
399
|
+
async *rest() {
|
|
400
|
+
while (true) {
|
|
401
|
+
if (this.#buf.byteLength > 0) {
|
|
402
|
+
const out = this.#buf;
|
|
403
|
+
this.#buf = EMPTY;
|
|
404
|
+
yield out;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (!await this.#pull()) return;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* Raised for any byte sequence this codec refuses to interpret. Every case is
|
|
413
|
+
* a refusal to guess: HTTP/1.1 parsers that guess are how request smuggling
|
|
414
|
+
* works.
|
|
415
|
+
*/
|
|
416
|
+
var HttpParseError = class extends Error {
|
|
417
|
+
name = "HttpParseError";
|
|
418
|
+
};
|
|
419
|
+
const NEWLINE = 10;
|
|
420
|
+
/** Mirrors the HTTP/1.1 codec's default `maxHeaderBytes`. */
|
|
421
|
+
const MAX_ENVELOPE_BYTES = 65536;
|
|
422
|
+
const encoder$1 = new TextEncoder();
|
|
423
|
+
const decoder = new TextDecoder();
|
|
424
|
+
/**
|
|
425
|
+
* Encode an HTTP envelope plus optional body as one continuous byte stream:
|
|
426
|
+
*
|
|
427
|
+
* <JSON.stringify(envelope)>\n<body bytes...>
|
|
428
|
+
*
|
|
429
|
+
* `JSON.stringify` with default whitespace never emits a literal `\n`, so the
|
|
430
|
+
* first `0x0a` byte unambiguously terminates the envelope.
|
|
431
|
+
*
|
|
432
|
+
* This is the same wire shape used by the legacy `webrun-http-port` package.
|
|
433
|
+
*/
|
|
434
|
+
async function* encodeMessage(envelope, body) {
|
|
435
|
+
yield encoder$1.encode(`${JSON.stringify(envelope)}\n`);
|
|
436
|
+
if (!body) return;
|
|
437
|
+
for await (const chunk of body) if (chunk.byteLength > 0) yield chunk;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Consume an envelope-then-body byte stream. Returns the parsed envelope and
|
|
441
|
+
* an async iterable over the remaining body bytes.
|
|
442
|
+
*/
|
|
443
|
+
async function decodeMessage(input) {
|
|
444
|
+
const iter = toAsyncIterator(input);
|
|
445
|
+
const accum = [];
|
|
446
|
+
let accumLen = 0;
|
|
447
|
+
let before;
|
|
448
|
+
let tail;
|
|
449
|
+
while (true) {
|
|
450
|
+
const next = await iter.next();
|
|
451
|
+
if (next.done) throw new HttpParseError(`decodeMessage: stream ended after ${accumLen} bytes without delimiter (\\n)`);
|
|
452
|
+
const chunk = next.value;
|
|
453
|
+
if (chunk.byteLength === 0) continue;
|
|
454
|
+
const nl = chunk.indexOf(NEWLINE);
|
|
455
|
+
if (nl === -1) {
|
|
456
|
+
accum.push(chunk);
|
|
457
|
+
accumLen += chunk.byteLength;
|
|
458
|
+
if (accumLen > MAX_ENVELOPE_BYTES) throw new HttpParseError(`decodeMessage: envelope exceeds ${MAX_ENVELOPE_BYTES} bytes without delimiter (\\n)`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
accum.push(chunk.subarray(0, nl));
|
|
462
|
+
accumLen += nl;
|
|
463
|
+
before = concatChunks(accum, accumLen);
|
|
464
|
+
tail = chunk.subarray(nl + 1);
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
let envelope;
|
|
468
|
+
try {
|
|
469
|
+
envelope = JSON.parse(decoder.decode(before));
|
|
470
|
+
} catch (err) {
|
|
471
|
+
throw new HttpParseError(`decodeMessage: malformed envelope JSON at bytes 0..${before.byteLength}: ${err.message}`);
|
|
472
|
+
}
|
|
473
|
+
async function* body() {
|
|
474
|
+
if (tail.byteLength > 0) yield tail;
|
|
475
|
+
while (true) {
|
|
476
|
+
const next = await iter.next();
|
|
477
|
+
if (next.done) return;
|
|
478
|
+
if (next.value.byteLength > 0) yield next.value;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
envelope,
|
|
483
|
+
body: body()
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
const OPEN_BRACE = 123;
|
|
487
|
+
/**
|
|
488
|
+
* The original wire format — `<JSON.stringify(envelope)>\n<body bytes…>` —
|
|
489
|
+
* expressed as a `MessageCodec`. Direction-agnostic: requests and responses
|
|
490
|
+
* serialise identically.
|
|
491
|
+
*
|
|
492
|
+
* Retained so a peer pair can be upgraded in either order; see ADR-0006.
|
|
493
|
+
*/
|
|
494
|
+
const jsonEnvelopeCodec = {
|
|
495
|
+
name: "json-envelope",
|
|
496
|
+
sniff: (byte) => byte === OPEN_BRACE,
|
|
497
|
+
encodeRequest: (env, body) => encodeMessage(env, body),
|
|
498
|
+
encodeResponse: (env, body) => encodeMessage(env, body),
|
|
499
|
+
decodeRequest: (input) => decodeMessage(input),
|
|
500
|
+
decodeResponse: (input) => decodeMessage(input)
|
|
501
|
+
};
|
|
502
|
+
const TCHAR = /* @__PURE__ */ new Set("!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
|
|
503
|
+
const SP = 32;
|
|
504
|
+
const HTAB = 9;
|
|
505
|
+
function isTokenChar(byte) {
|
|
506
|
+
return byte > SP && byte < 127 && TCHAR.has(String.fromCharCode(byte));
|
|
507
|
+
}
|
|
508
|
+
function isToken(value) {
|
|
509
|
+
if (value.length === 0) return false;
|
|
510
|
+
for (const ch of value) if (!TCHAR.has(ch)) return false;
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Header bytes are latin-1: byte-preserving, matching Node, and lossless
|
|
515
|
+
* across a decode/encode round-trip. Control characters are rejected
|
|
516
|
+
* separately by `assertValidHeaderValue`.
|
|
517
|
+
*/
|
|
518
|
+
function decodeLatin1(bytes) {
|
|
519
|
+
let out = "";
|
|
520
|
+
for (const byte of bytes) out += String.fromCharCode(byte);
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
function encodeLatin1(text) {
|
|
524
|
+
const out = new Uint8Array(text.length);
|
|
525
|
+
for (let i = 0; i < text.length; i++) {
|
|
526
|
+
const code = text.charCodeAt(i);
|
|
527
|
+
if (code > 255) throw new HttpParseError(`not latin-1 encodable: ${JSON.stringify(text)}`);
|
|
528
|
+
out[i] = code;
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
const HOST_IP_LITERAL = /^\[[0-9A-Fa-f:.]+\](:\d{1,5})?$/;
|
|
533
|
+
const HOST_REG_NAME = /^[A-Za-z0-9._~-]+(:\d{1,5})?$/;
|
|
534
|
+
/**
|
|
535
|
+
* Validates a value that is about to become (or was read as) a `Host`
|
|
536
|
+
* header: untrusted input either way, so an unrecognised shape is a refusal,
|
|
537
|
+
* not a guess. Rejects a present-but-empty value, one carrying userinfo
|
|
538
|
+
* (`evil.com@good.com`), and — the reason this also runs on encode — one
|
|
539
|
+
* smuggling a CRLF-terminated line into the authority of a caller-supplied
|
|
540
|
+
* url. Never applied to trusted configuration (a codec's `opts.host`).
|
|
541
|
+
*/
|
|
542
|
+
function assertValidHost(host) {
|
|
543
|
+
if (!HOST_IP_LITERAL.test(host) && !HOST_REG_NAME.test(host)) throw new HttpParseError(`invalid Host header: ${JSON.stringify(host)}`);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* A request-target must be entirely visible ASCII (RFC 9110 VCHAR) — nothing
|
|
547
|
+
* `<= 0x20` (space and every control character, CR/LF included) and nothing
|
|
548
|
+
* `>= 0x7F` (DEL and beyond). Applied on encode (`splitTarget`, so a
|
|
549
|
+
* caller-supplied url can never inject a second request line) and on decode
|
|
550
|
+
* (so a relay that decodes then re-encodes can never put an embedded control
|
|
551
|
+
* character back on the wire).
|
|
552
|
+
*/
|
|
553
|
+
function assertValidTarget(target) {
|
|
554
|
+
for (let i = 0; i < target.length; i++) {
|
|
555
|
+
const code = target.charCodeAt(i);
|
|
556
|
+
if (code <= SP || code >= 127) throw new HttpParseError(`invalid request-target: ${JSON.stringify(target)}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Shared control-character check behind both `assertValidHeaderValue` and
|
|
561
|
+
* `assertValidStatusText` — the two were previously checked by different,
|
|
562
|
+
* looser rules (statusText only rejected CR/LF), which is exactly the
|
|
563
|
+
* asymmetry class C1 and M5 are both instances of. `label` is prepended
|
|
564
|
+
* verbatim to each message, so callers keep their own wording.
|
|
565
|
+
*/
|
|
566
|
+
function assertNoControlChars(label, value) {
|
|
567
|
+
for (let i = 0; i < value.length; i++) {
|
|
568
|
+
const code = value.charCodeAt(i);
|
|
569
|
+
if (code === 13 || code === 10) throw new HttpParseError(`${label} contains CR or LF`);
|
|
570
|
+
if (code < SP && code !== HTAB || code === 127) throw new HttpParseError(`${label} contains a control character`);
|
|
571
|
+
if (code > 255) throw new HttpParseError(`${label} is not latin-1 encodable`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
function assertValidHeaderValue(name, value) {
|
|
575
|
+
assertNoControlChars(`header "${name}" value`, value);
|
|
576
|
+
}
|
|
577
|
+
/** Same character class as a header value (M5) — CR/LF, every C0 control, and DEL. */
|
|
578
|
+
function assertValidStatusText(value) {
|
|
579
|
+
assertNoControlChars("statusText", value);
|
|
580
|
+
}
|
|
581
|
+
function encodeHeaderLines(headers) {
|
|
582
|
+
let out = "";
|
|
583
|
+
for (const [name, value] of headers) {
|
|
584
|
+
if (!isToken(name)) throw new HttpParseError(`invalid header name: ${JSON.stringify(name)}`);
|
|
585
|
+
assertValidHeaderValue(name, value);
|
|
586
|
+
out += `${name}: ${value}\r\n`;
|
|
587
|
+
}
|
|
588
|
+
return out;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Read to the blank line. `alreadyUsed` is the byte count of the start line,
|
|
592
|
+
* so the bound covers the whole head section rather than the headers alone.
|
|
593
|
+
*/
|
|
594
|
+
async function readHeaderSection(reader, maxHeaderBytes, alreadyUsed) {
|
|
595
|
+
const headers = [];
|
|
596
|
+
let used = alreadyUsed;
|
|
597
|
+
while (true) {
|
|
598
|
+
const remaining = maxHeaderBytes - used;
|
|
599
|
+
if (remaining <= 0) throw new HttpParseError(`head section exceeds ${maxHeaderBytes} bytes`);
|
|
600
|
+
const lineBytes = await reader.readLine(remaining);
|
|
601
|
+
used += lineBytes.byteLength + 2;
|
|
602
|
+
if (used > maxHeaderBytes) throw new HttpParseError(`head section exceeds ${maxHeaderBytes} bytes`);
|
|
603
|
+
if (lineBytes.byteLength === 0) return headers;
|
|
604
|
+
if (lineBytes[0] === SP || lineBytes[0] === HTAB) throw new HttpParseError("obs-fold header continuation is not accepted");
|
|
605
|
+
const line = decodeLatin1(lineBytes);
|
|
606
|
+
const colon = line.indexOf(":");
|
|
607
|
+
if (colon <= 0) throw new HttpParseError(`malformed header line: ${JSON.stringify(line)}`);
|
|
608
|
+
const name = line.slice(0, colon);
|
|
609
|
+
if (!isToken(name)) throw new HttpParseError(`invalid header name: ${JSON.stringify(name)}`);
|
|
610
|
+
const value = line.slice(colon + 1).replace(/^[ \t]+/, "").replace(/[ \t]+$/, "");
|
|
611
|
+
assertValidHeaderValue(name, value);
|
|
612
|
+
headers.push([name, value]);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function getAll(headers, name) {
|
|
616
|
+
const lower = name.toLowerCase();
|
|
617
|
+
return headers.filter(([k]) => k.toLowerCase() === lower).map(([, v]) => v);
|
|
618
|
+
}
|
|
619
|
+
function withoutHeaders(headers, names) {
|
|
620
|
+
const drop = new Set(names.map((n) => n.toLowerCase()));
|
|
621
|
+
return headers.filter(([k]) => !drop.has(k.toLowerCase()));
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Parse a `Content-Length` field into a single validated value.
|
|
625
|
+
*
|
|
626
|
+
* RFC 9110 §8.6 permits the value to be a comma-separated list of identical
|
|
627
|
+
* numbers (a relay may have appended one), so the list is folded; differing
|
|
628
|
+
* values are a refusal, not a choice. Shared by `resolveFraming` on decode and
|
|
629
|
+
* by the encoder's declared-length check, which previously carried its own
|
|
630
|
+
* copy that did NOT split on commas — so decode accepted `5, 5` while encode
|
|
631
|
+
* rejected it, and a relay that decoded then re-encoded threw.
|
|
632
|
+
*
|
|
633
|
+
* Returns undefined when the field is absent.
|
|
634
|
+
*/
|
|
635
|
+
function parseContentLength(values) {
|
|
636
|
+
if (values.length === 0) return void 0;
|
|
637
|
+
const unique = new Set(values.flatMap((v) => v.split(",").map((s) => s.trim())));
|
|
638
|
+
if (unique.size !== 1) throw new HttpParseError(`conflicting Content-Length values: ${[...unique].join(", ")}`);
|
|
639
|
+
const raw = [...unique][0];
|
|
640
|
+
if (!/^\d{1,15}$/.test(raw)) throw new HttpParseError(`invalid Content-Length: ${JSON.stringify(raw)}`);
|
|
641
|
+
return Number(raw);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Statuses that carry no body whatever the headers say (RFC 9110 §8.6): 1xx,
|
|
645
|
+
* 204 and 304. Defined once because the encoder must not frame a body for
|
|
646
|
+
* them and the decoder must not try to read one — two lists that agreed today
|
|
647
|
+
* and were free to drift tomorrow.
|
|
648
|
+
*
|
|
649
|
+
* A response to HEAD is also bodyless, but that depends on the request rather
|
|
650
|
+
* than the status, so callers test it separately.
|
|
651
|
+
*/
|
|
652
|
+
function isBodylessStatus(status) {
|
|
653
|
+
return status < 200 || status === 204 || status === 304;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* RFC 9112 §6.3, with every ambiguity turned into a refusal. In particular a
|
|
657
|
+
* message declaring both Content-Length and Transfer-Encoding is rejected
|
|
658
|
+
* rather than resolved — disagreeing on which one wins is request smuggling.
|
|
659
|
+
*/
|
|
660
|
+
function resolveFraming(headers, version) {
|
|
661
|
+
const te = getAll(headers, "transfer-encoding");
|
|
662
|
+
const cl = getAll(headers, "content-length");
|
|
663
|
+
if (te.length > 0 && cl.length > 0) throw new HttpParseError("message declares both Content-Length and Transfer-Encoding; refusing (request smuggling)");
|
|
664
|
+
if (te.length > 0) {
|
|
665
|
+
const encodings = te.join(",").split(",").map((s) => s.trim().toLowerCase()).filter((s) => s !== "");
|
|
666
|
+
if (encodings.length !== 1 || encodings[0] !== "chunked") throw new HttpParseError(`unsupported Transfer-Encoding: ${JSON.stringify(te.join(", "))}`);
|
|
667
|
+
if (version === "HTTP/1.0") throw new HttpParseError("Transfer-Encoding is not valid in HTTP/1.0");
|
|
668
|
+
return { kind: "chunked" };
|
|
669
|
+
}
|
|
670
|
+
const length = parseContentLength(cl);
|
|
671
|
+
if (length !== void 0) return {
|
|
672
|
+
kind: "length",
|
|
673
|
+
length
|
|
674
|
+
};
|
|
675
|
+
return { kind: "none" };
|
|
676
|
+
}
|
|
677
|
+
const CRLF = new Uint8Array([13, 10]);
|
|
678
|
+
const LAST_CHUNK = new Uint8Array([
|
|
679
|
+
48,
|
|
680
|
+
13,
|
|
681
|
+
10,
|
|
682
|
+
13,
|
|
683
|
+
10
|
|
684
|
+
]);
|
|
685
|
+
const encoder = new TextEncoder();
|
|
686
|
+
/**
|
|
687
|
+
* Chunk sizes are written in HEXADECIMAL — a 21-byte chunk is `15`. Writing
|
|
688
|
+
* them in decimal is the defect note 15 found in @libp2p/http, and it is the
|
|
689
|
+
* one that cannot be caught downstream: decimal digits are also valid hex, so
|
|
690
|
+
* a conforming parser silently reads the wrong length.
|
|
691
|
+
*
|
|
692
|
+
* Zero-length source chunks are skipped; a zero-sized chunk on the wire is the
|
|
693
|
+
* body terminator.
|
|
694
|
+
*/
|
|
695
|
+
async function* encodeChunked(body) {
|
|
696
|
+
for await (const chunk of body) {
|
|
697
|
+
if (chunk.byteLength === 0) continue;
|
|
698
|
+
yield encoder.encode(`${chunk.byteLength.toString(16)}\r\n`);
|
|
699
|
+
yield chunk;
|
|
700
|
+
yield CRLF;
|
|
701
|
+
}
|
|
702
|
+
yield LAST_CHUNK;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Decode a chunked body, yielding data chunks as they arrive. Nothing is
|
|
706
|
+
* accumulated: a chunk larger than the transport's frame is yielded in pieces.
|
|
707
|
+
*/
|
|
708
|
+
async function* decodeChunked(reader, maxLineBytes) {
|
|
709
|
+
while (true) {
|
|
710
|
+
const sizeLine = decodeLatin1(await reader.readLine(maxLineBytes));
|
|
711
|
+
const semicolon = sizeLine.indexOf(";");
|
|
712
|
+
const sizeText = semicolon === -1 ? sizeLine : sizeLine.slice(0, semicolon);
|
|
713
|
+
if (!/^[0-9a-fA-F]{1,32}$/.test(sizeText)) throw new HttpParseError(`invalid chunk size: ${JSON.stringify(sizeLine)}`);
|
|
714
|
+
const size = Number.parseInt(sizeText, 16);
|
|
715
|
+
if (!Number.isSafeInteger(size)) throw new HttpParseError(`chunk size is too large: ${JSON.stringify(sizeLine)}`);
|
|
716
|
+
if (size === 0) {
|
|
717
|
+
let used = 0;
|
|
718
|
+
while (true) {
|
|
719
|
+
const remaining = maxLineBytes - used;
|
|
720
|
+
if (remaining <= 0) throw new HttpParseError(`trailer section exceeds ${maxLineBytes} bytes`);
|
|
721
|
+
const lineBytes = await reader.readLine(remaining);
|
|
722
|
+
used += lineBytes.byteLength + 2;
|
|
723
|
+
if (used > maxLineBytes) throw new HttpParseError(`trailer section exceeds ${maxLineBytes} bytes`);
|
|
724
|
+
if (lineBytes.byteLength === 0) break;
|
|
725
|
+
}
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
let remaining = size;
|
|
729
|
+
while (remaining > 0) {
|
|
730
|
+
const part = await reader.readSome(remaining);
|
|
731
|
+
if (part === void 0) throw new HttpParseError(`chunk truncated: ${remaining} of ${size} bytes missing`);
|
|
732
|
+
remaining -= part.byteLength;
|
|
733
|
+
yield part;
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
if ((await reader.readLine(2)).byteLength !== 0) throw new HttpParseError("chunk data not terminated by CRLF");
|
|
737
|
+
} catch (err) {
|
|
738
|
+
if (err instanceof ByteStreamError) throw new HttpParseError("chunk data not terminated by CRLF");
|
|
739
|
+
throw err;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const VERSIONS = /* @__PURE__ */ new Set(["HTTP/1.1", "HTTP/1.0"]);
|
|
744
|
+
const ABSOLUTE_FORM = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
|
|
745
|
+
/**
|
|
746
|
+
* One message per Duplex call (ADR-0006), so bytes after a complete message
|
|
747
|
+
* are an error. Checked against what is ALREADY BUFFERED rather than by
|
|
748
|
+
* awaiting end-of-stream: a live socket from a keep-alive peer never reaches
|
|
749
|
+
* EOF, so awaiting one would hang instead of failing.
|
|
750
|
+
*/
|
|
751
|
+
function assertNoBufferedBytes(reader) {
|
|
752
|
+
const extra = reader.bufferedLength();
|
|
753
|
+
if (extra > 0) throw new HttpParseError(`${extra} trailing bytes after a complete message`);
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* The public error contract (I2): everything leaving `decodeRequest` /
|
|
757
|
+
* `decodeResponse` is an `HttpParseError`, whether the refusal happened
|
|
758
|
+
* synchronously (a malformed start line or header) or lazily while the body
|
|
759
|
+
* is later drained. `ByteStreamError` — the `ByteReader`'s own class, never
|
|
760
|
+
* exported — is the one thing converted here, message and all preserved via
|
|
761
|
+
* `cause`. Anything else is a genuine failure of the underlying source (a
|
|
762
|
+
* dropped socket, say) and must reach the caller unchanged: blanket-catching
|
|
763
|
+
* would hide that distinction.
|
|
764
|
+
*/
|
|
765
|
+
function toHttpParseError(err) {
|
|
766
|
+
if (err instanceof ByteStreamError) throw new HttpParseError(err.message, { cause: err });
|
|
767
|
+
throw err;
|
|
768
|
+
}
|
|
769
|
+
/** Applies `toHttpParseError` across the whole lifetime of a body generator. */
|
|
770
|
+
async function* convertBodyErrors(source) {
|
|
771
|
+
try {
|
|
772
|
+
yield* source;
|
|
773
|
+
} catch (err) {
|
|
774
|
+
toHttpParseError(err);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
async function* readBody(reader, framing, opts, noneMeansEof) {
|
|
778
|
+
if (framing.kind === "chunked") {
|
|
779
|
+
yield* decodeChunked(reader, opts.maxHeaderBytes);
|
|
780
|
+
assertNoBufferedBytes(reader);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (framing.kind === "length") {
|
|
784
|
+
let remaining = framing.length;
|
|
785
|
+
while (remaining > 0) {
|
|
786
|
+
const part = await reader.readSome(remaining);
|
|
787
|
+
if (part === void 0) throw new HttpParseError(`body truncated: ${remaining} of ${framing.length} bytes missing`);
|
|
788
|
+
remaining -= part.byteLength;
|
|
789
|
+
yield part;
|
|
790
|
+
}
|
|
791
|
+
assertNoBufferedBytes(reader);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (noneMeansEof) {
|
|
795
|
+
yield* reader.rest();
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
assertNoBufferedBytes(reader);
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* `readLine`'s bound is best-effort — it is skipped when the line is already
|
|
802
|
+
* buffered — so a very long start line can reach these messages intact. Since
|
|
803
|
+
* a refusal is now echoed back to the sender in a 400 body, quote only enough
|
|
804
|
+
* to diagnose rather than reflecting the whole thing.
|
|
805
|
+
*/
|
|
806
|
+
function quoteLine(line) {
|
|
807
|
+
const MAX = 120;
|
|
808
|
+
return line.length <= MAX ? JSON.stringify(line) : `${JSON.stringify(line.slice(0, MAX))} (truncated from ${line.length} chars)`;
|
|
809
|
+
}
|
|
810
|
+
async function decodeRequest(input, opts) {
|
|
811
|
+
const reader = new ByteReader(input);
|
|
812
|
+
try {
|
|
813
|
+
const startBytes = await reader.readLine(opts.maxHeaderBytes);
|
|
814
|
+
const startLine = decodeLatin1(startBytes);
|
|
815
|
+
const parts = startLine.split(" ");
|
|
816
|
+
if (parts.length !== 3) throw new HttpParseError(`malformed request line: ${quoteLine(startLine)}`);
|
|
817
|
+
const [method, target, version] = parts;
|
|
818
|
+
if (!isToken(method)) throw new HttpParseError(`invalid method: ${JSON.stringify(method)}`);
|
|
819
|
+
if (!VERSIONS.has(version)) throw new HttpParseError(`unsupported HTTP version: ${JSON.stringify(version)}`);
|
|
820
|
+
if (target === "*") throw new HttpParseError("asterisk-form request target is not supported");
|
|
821
|
+
assertValidTarget(target);
|
|
822
|
+
const headers = await readHeaderSection(reader, opts.maxHeaderBytes, startBytes.byteLength + 2);
|
|
823
|
+
const hosts = getAll(headers, "host");
|
|
824
|
+
if (hosts.length > 1) throw new HttpParseError("multiple Host headers");
|
|
825
|
+
const host = hosts[0];
|
|
826
|
+
let url;
|
|
827
|
+
if (target.startsWith("/")) {
|
|
828
|
+
if (version === "HTTP/1.1" && host === void 0) throw new HttpParseError("HTTP/1.1 request has no Host header");
|
|
829
|
+
if (host !== void 0) assertValidHost(host);
|
|
830
|
+
url = `${opts.scheme}://${host ?? opts.host}${target}`;
|
|
831
|
+
} else if (ABSOLUTE_FORM.test(target)) url = target;
|
|
832
|
+
else throw new HttpParseError(`unsupported request target: ${JSON.stringify(target)}`);
|
|
833
|
+
try {
|
|
834
|
+
new URL(url);
|
|
835
|
+
} catch {
|
|
836
|
+
throw new HttpParseError(`decoded url is not a valid URL: ${JSON.stringify(url)}`);
|
|
837
|
+
}
|
|
838
|
+
return {
|
|
839
|
+
envelope: {
|
|
840
|
+
url,
|
|
841
|
+
method,
|
|
842
|
+
headers
|
|
843
|
+
},
|
|
844
|
+
body: convertBodyErrors(readBody(reader, resolveFraming(headers, version), opts, false))
|
|
845
|
+
};
|
|
846
|
+
} catch (err) {
|
|
847
|
+
toHttpParseError(err);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
async function decodeResponse(input, opts, method) {
|
|
851
|
+
const reader = new ByteReader(input);
|
|
852
|
+
try {
|
|
853
|
+
const startBytes = await reader.readLine(opts.maxHeaderBytes);
|
|
854
|
+
const startLine = decodeLatin1(startBytes);
|
|
855
|
+
const firstSp = startLine.indexOf(" ");
|
|
856
|
+
if (firstSp === -1) throw new HttpParseError(`malformed status line: ${quoteLine(startLine)}`);
|
|
857
|
+
const version = startLine.slice(0, firstSp);
|
|
858
|
+
if (!VERSIONS.has(version)) throw new HttpParseError(`unsupported HTTP version: ${JSON.stringify(version)}`);
|
|
859
|
+
const afterVersion = startLine.slice(firstSp + 1);
|
|
860
|
+
const secondSp = afterVersion.indexOf(" ");
|
|
861
|
+
const codeText = secondSp === -1 ? afterVersion : afterVersion.slice(0, secondSp);
|
|
862
|
+
if (!/^\d{3}$/.test(codeText)) throw new HttpParseError(`invalid status code: ${JSON.stringify(codeText)}`);
|
|
863
|
+
const status = Number(codeText);
|
|
864
|
+
const statusText = secondSp === -1 ? "" : afterVersion.slice(secondSp + 1);
|
|
865
|
+
const headers = await readHeaderSection(reader, opts.maxHeaderBytes, startBytes.byteLength + 2);
|
|
866
|
+
const bodyless = isBodylessStatus(status) || method.toUpperCase() === "HEAD";
|
|
867
|
+
const framing = bodyless ? { kind: "none" } : resolveFraming(headers, version);
|
|
868
|
+
return {
|
|
869
|
+
envelope: {
|
|
870
|
+
status,
|
|
871
|
+
statusText,
|
|
872
|
+
headers
|
|
873
|
+
},
|
|
874
|
+
body: convertBodyErrors(readBody(reader, framing, opts, !bodyless))
|
|
875
|
+
};
|
|
876
|
+
} catch (err) {
|
|
877
|
+
toHttpParseError(err);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
/** Headers the codec owns: a caller-supplied copy is dropped and re-derived. */
|
|
881
|
+
const REQUEST_OWNED = [
|
|
882
|
+
"host",
|
|
883
|
+
"connection",
|
|
884
|
+
"transfer-encoding"
|
|
885
|
+
];
|
|
886
|
+
const RESPONSE_OWNED = ["connection", "transfer-encoding"];
|
|
887
|
+
/**
|
|
888
|
+
* Split a URL into an origin-form request target and an authority *without*
|
|
889
|
+
* going through `new URL()`. `URL` normalises percent-encoding and would
|
|
890
|
+
* re-serialise the target — which is the exact class of defect note 15 found
|
|
891
|
+
* in @libp2p/http, where rebuilding the request line silently dropped
|
|
892
|
+
* `url.search`. Here the target is a verbatim slice of the caller's string.
|
|
893
|
+
*/
|
|
894
|
+
function splitTarget(url, opts) {
|
|
895
|
+
if (url.startsWith("/")) {
|
|
896
|
+
assertValidTarget(url);
|
|
897
|
+
return {
|
|
898
|
+
target: url,
|
|
899
|
+
authority: opts.host
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
const match = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/([^/?#]*)([^#]*)/.exec(url);
|
|
903
|
+
if (!match) throw new HttpParseError(`cannot derive a request target from url: ${JSON.stringify(url)}`);
|
|
904
|
+
const rawAuthority = match[1];
|
|
905
|
+
if (rawAuthority === "") throw new HttpParseError(`url has no authority: ${JSON.stringify(url)}`);
|
|
906
|
+
const at = rawAuthority.lastIndexOf("@");
|
|
907
|
+
const authority = at === -1 ? rawAuthority : rawAuthority.slice(at + 1);
|
|
908
|
+
assertValidHost(authority);
|
|
909
|
+
const rawTarget = match[2];
|
|
910
|
+
const target = rawTarget === "" ? "/" : rawTarget.startsWith("/") ? rawTarget : `/${rawTarget}`;
|
|
911
|
+
assertValidTarget(target);
|
|
912
|
+
return {
|
|
913
|
+
target,
|
|
914
|
+
authority
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
async function* emitBody(body, declared) {
|
|
918
|
+
if (declared === void 0) {
|
|
919
|
+
yield* encodeChunked(body);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
let sent = 0;
|
|
923
|
+
for await (const chunk of body) {
|
|
924
|
+
if (chunk.byteLength === 0) continue;
|
|
925
|
+
sent += chunk.byteLength;
|
|
926
|
+
if (sent > declared) throw new HttpParseError(`body exceeds declared Content-Length ${declared} (${sent} bytes so far)`);
|
|
927
|
+
yield chunk;
|
|
928
|
+
}
|
|
929
|
+
if (sent !== declared) throw new HttpParseError(`body is ${sent} bytes but Content-Length declares ${declared}`);
|
|
930
|
+
}
|
|
931
|
+
async function* encodeRequest(env, body, opts) {
|
|
932
|
+
if (!isToken(env.method)) throw new HttpParseError(`invalid method: ${JSON.stringify(env.method)}`);
|
|
933
|
+
const { target, authority } = splitTarget(env.url, opts);
|
|
934
|
+
if (authority === "") throw new HttpParseError("no Host available: url has no authority and no host is configured");
|
|
935
|
+
const carried = withoutHeaders(env.headers, REQUEST_OWNED);
|
|
936
|
+
const declared = parseContentLength(getAll(carried, "content-length"));
|
|
937
|
+
let head = `${env.method} ${target} HTTP/1.1\r\n`;
|
|
938
|
+
head += `Host: ${authority}\r\n`;
|
|
939
|
+
head += encodeHeaderLines(carried);
|
|
940
|
+
head += "Connection: close\r\n";
|
|
941
|
+
if (body !== void 0 && declared === void 0) head += "Transfer-Encoding: chunked\r\n";
|
|
942
|
+
head += "\r\n";
|
|
943
|
+
yield encodeLatin1(head);
|
|
944
|
+
if (body === void 0) {
|
|
945
|
+
if (declared !== void 0 && declared !== 0) throw new HttpParseError(`body is 0 bytes but Content-Length declares ${declared}`);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
yield* emitBody(body, declared);
|
|
949
|
+
}
|
|
950
|
+
async function* encodeResponse(env, body, _opts, requestMethod) {
|
|
951
|
+
if (!Number.isInteger(env.status) || env.status < 100 || env.status > 599) throw new HttpParseError(`invalid status: ${env.status}`);
|
|
952
|
+
const reason = env.statusText ?? "";
|
|
953
|
+
assertValidStatusText(reason);
|
|
954
|
+
const bodylessStatus = isBodylessStatus(env.status);
|
|
955
|
+
const bodyless = bodylessStatus || requestMethod?.toUpperCase() === "HEAD";
|
|
956
|
+
const owned = bodylessStatus ? [...RESPONSE_OWNED, "content-length"] : RESPONSE_OWNED;
|
|
957
|
+
const carried = withoutHeaders(env.headers, owned);
|
|
958
|
+
const declared = parseContentLength(getAll(carried, "content-length"));
|
|
959
|
+
let head = `HTTP/1.1 ${env.status} ${reason}\r\n`;
|
|
960
|
+
head += encodeHeaderLines(carried);
|
|
961
|
+
head += "Connection: close\r\n";
|
|
962
|
+
if (!bodyless && body !== void 0 && declared === void 0) head += "Transfer-Encoding: chunked\r\n";
|
|
963
|
+
head += "\r\n";
|
|
964
|
+
yield encodeLatin1(head);
|
|
965
|
+
if (bodyless) {
|
|
966
|
+
await discard(body);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (body === void 0) {
|
|
970
|
+
if (declared !== void 0 && declared !== 0) throw new HttpParseError(`body is 0 bytes but Content-Length declares ${declared}`);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
yield* emitBody(body, declared);
|
|
974
|
+
}
|
|
975
|
+
function newHttpCodec(options = {}) {
|
|
976
|
+
const opts = {
|
|
977
|
+
scheme: options.scheme ?? "http",
|
|
978
|
+
host: options.host ?? "localhost",
|
|
979
|
+
maxHeaderBytes: options.maxHeaderBytes ?? 65536
|
|
980
|
+
};
|
|
981
|
+
return {
|
|
982
|
+
name: "http/1.1",
|
|
983
|
+
sniff: (byte) => isTokenChar(byte),
|
|
984
|
+
encodeRequest: (env, body) => encodeRequest(env, body, opts),
|
|
985
|
+
encodeResponse: (env, body, o) => encodeResponse(env, body, opts, o?.method),
|
|
986
|
+
decodeRequest: (input) => decodeRequest(input, opts),
|
|
987
|
+
decodeResponse: (input, o) => decodeResponse(input, opts, o.method)
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
const httpCodec = newHttpCodec();
|
|
991
|
+
/**
|
|
992
|
+
* Dispatches on byte 0. The formats are self-identifying — a JSON envelope
|
|
993
|
+
* always begins `{`, which is not a token character and so can never begin an
|
|
994
|
+
* HTTP start-line — so no negotiation handshake, magic prefix, or version byte
|
|
995
|
+
* is needed.
|
|
996
|
+
*
|
|
997
|
+
* This is what makes a mixed-version peer pair safe: readers accept either
|
|
998
|
+
* format, so the two ends can be upgraded in any order.
|
|
999
|
+
*/
|
|
1000
|
+
function newSniffingCodec(options) {
|
|
1001
|
+
const { write, accept } = options;
|
|
1002
|
+
async function pick(input) {
|
|
1003
|
+
const reader = new ByteReader(input);
|
|
1004
|
+
const first = await reader.peekByte();
|
|
1005
|
+
if (first === void 0) throw new HttpParseError("sniff: stream ended before any bytes arrived");
|
|
1006
|
+
const codec = accept.find((c) => c.sniff(first));
|
|
1007
|
+
if (!codec) throw new HttpParseError(`sniff: no accepted codec recognises a message starting with byte 0x${first.toString(16).padStart(2, "0")}`);
|
|
1008
|
+
return {
|
|
1009
|
+
codec,
|
|
1010
|
+
input: reader.rest()
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
return {
|
|
1014
|
+
name: `sniff(write=${write.name}; accept=${accept.map((c) => c.name).join(",")})`,
|
|
1015
|
+
sniff: (byte) => accept.some((c) => c.sniff(byte)),
|
|
1016
|
+
encodeRequest: (env, body) => write.encodeRequest(env, body),
|
|
1017
|
+
encodeResponse: (env, body, o) => write.encodeResponse(env, body, o),
|
|
1018
|
+
decodeRequest: async (input) => {
|
|
1019
|
+
const picked = await pick(input);
|
|
1020
|
+
try {
|
|
1021
|
+
return {
|
|
1022
|
+
...await picked.codec.decodeRequest(picked.input),
|
|
1023
|
+
codec: picked.codec
|
|
1024
|
+
};
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
if (error !== null && typeof error === "object") error.codec = picked.codec;
|
|
1027
|
+
throw error;
|
|
1028
|
+
}
|
|
1029
|
+
},
|
|
1030
|
+
decodeResponse: async (input, o) => {
|
|
1031
|
+
const picked = await pick(input);
|
|
1032
|
+
return picked.codec.decodeResponse(picked.input, o);
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
newSniffingCodec({
|
|
1037
|
+
write: httpCodec,
|
|
1038
|
+
accept: [httpCodec, jsonEnvelopeCodec]
|
|
1039
|
+
});
|
|
285
1040
|
/**
|
|
286
1041
|
* The one place that knows a runtime may not implement request body streams,
|
|
287
1042
|
* and the buffering both fallbacks need. `fetch.ts` and `http-stubs.ts` each
|
|
@@ -325,8 +1080,6 @@
|
|
|
325
1080
|
function supportsRequestStreams() {
|
|
326
1081
|
return typeof Request === "function" && Object.getOwnPropertyDescriptor(Request.prototype, "body") != null;
|
|
327
1082
|
}
|
|
328
|
-
//#endregion
|
|
329
|
-
//#region ../webrun-http-streams/src/http-stubs.ts
|
|
330
1083
|
const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
|
|
331
1084
|
101,
|
|
332
1085
|
103,
|
|
@@ -471,11 +1224,14 @@
|
|
|
471
1224
|
params
|
|
472
1225
|
}, [messageChannel.port2]);
|
|
473
1226
|
const channel = newStreamChannel(messageChannel.port1);
|
|
1227
|
+
let drained = false;
|
|
474
1228
|
try {
|
|
475
1229
|
await channel.start();
|
|
476
1230
|
channel.sendAll(input);
|
|
477
1231
|
yield* channel.recieveAll();
|
|
1232
|
+
drained = true;
|
|
478
1233
|
} finally {
|
|
1234
|
+
if (!drained) channel.cancel();
|
|
479
1235
|
await channel.close();
|
|
480
1236
|
}
|
|
481
1237
|
}
|
|
@@ -485,11 +1241,33 @@
|
|
|
485
1241
|
const notifyAll = async (data) => {
|
|
486
1242
|
for (const listener of listeners) await listener(data);
|
|
487
1243
|
};
|
|
1244
|
+
/**
|
|
1245
|
+
* Release whatever we are sending. NOT awaited: `.return()` on an async
|
|
1246
|
+
* generator parked awaiting its own source is queued behind that pending
|
|
1247
|
+
* `next()`, so awaiting it here would block the message handler — and the
|
|
1248
|
+
* chunk that would unblock it can only arrive through that same handler.
|
|
1249
|
+
*/
|
|
1250
|
+
const cancelOutgoing = () => {
|
|
1251
|
+
for (const it of [...iterators]) {
|
|
1252
|
+
const iterable = it;
|
|
1253
|
+
Promise.resolve(iterable.return?.()).catch(() => {});
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
488
1256
|
const channel = newInvokationChannel({
|
|
489
1257
|
port,
|
|
490
|
-
handler: (data) =>
|
|
1258
|
+
handler: (data) => {
|
|
1259
|
+
const message = data;
|
|
1260
|
+
if (message?.cancel) {
|
|
1261
|
+
cancelOutgoing();
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
return notifyAll(message);
|
|
1265
|
+
}
|
|
491
1266
|
});
|
|
492
1267
|
const start = () => channel.start();
|
|
1268
|
+
const cancel = () => {
|
|
1269
|
+
channel.invoke({ cancel: true }).catch(() => {});
|
|
1270
|
+
};
|
|
493
1271
|
const close = async () => {
|
|
494
1272
|
await notifyAll({ done: true });
|
|
495
1273
|
for (const it of [...iterators]) await it.return?.();
|
|
@@ -516,6 +1294,7 @@
|
|
|
516
1294
|
return {
|
|
517
1295
|
start,
|
|
518
1296
|
close,
|
|
1297
|
+
cancel,
|
|
519
1298
|
recieveAll,
|
|
520
1299
|
sendAll
|
|
521
1300
|
};
|
|
@@ -543,13 +1322,18 @@
|
|
|
543
1322
|
};
|
|
544
1323
|
}
|
|
545
1324
|
/**
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
* `
|
|
1325
|
+
* Ship a `Request` over a `MessageTarget` and await the `Response`, using this
|
|
1326
|
+
* package's own `sendStream` transport.
|
|
1327
|
+
*
|
|
1328
|
+
* @deprecated Prefer the port stack in `@statewalker/webrun-rpc`: open a port
|
|
1329
|
+
* (`multiplexPort` over one pipe, or `transferPortMux` where the platform can
|
|
1330
|
+
* transfer a real `MessagePort`), turn it into a `Duplex` with
|
|
1331
|
+
* `duplexOverPort`, and drive HTTP over it with `httpFetch` from
|
|
1332
|
+
* `@statewalker/webrun-http-streams`.
|
|
1333
|
+
*
|
|
1334
|
+
* Same caveat as {@link handleHttpRequests}: the transport underneath this
|
|
1335
|
+
* helper has no backpressure, no per-stream timeout, and no chunking to a
|
|
1336
|
+
* transport's message ceiling. Kept for existing ServiceWorker setups.
|
|
553
1337
|
*/
|
|
554
1338
|
async function sendHttpRequest(communicationPort, request) {
|
|
555
1339
|
return await newHttpClientStub(async (req) => {
|
|
@@ -557,7 +1341,7 @@
|
|
|
557
1341
|
})(request);
|
|
558
1342
|
}
|
|
559
1343
|
//#endregion
|
|
560
|
-
//#region
|
|
1344
|
+
//#region ../../../../node_modules/.pnpm/idb-keyval@6.3.0/node_modules/idb-keyval/dist/index.js
|
|
561
1345
|
function promisifyRequest(request) {
|
|
562
1346
|
return new Promise((resolve, reject) => {
|
|
563
1347
|
request.oncomplete = request.onsuccess = () => resolve(request.result);
|