@sproutboat/runtime 0.22.0 → 0.23.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/CHANGELOG.md +26 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +84 -14
- package/test/native-fetch-request.ts +305 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.23.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- df612fc: `fetch(Request)`, `fetch(Request, init)` and `new Request(request, init)` behave as in workerd from compatibility date `2026-10-23`. This covers outgoing `fetch()`, service-binding `fetch()` and Durable Object stubs, on the broker and embedded transports alike. Every behaviour was checked against workerd 2026-09-27 by the platform's service-binding suite.
|
|
8
|
+
|
|
9
|
+
From `2026-10-23`:
|
|
10
|
+
|
|
11
|
+
- `fetch(input, init)` is `fetch(new Request(input, init))`, as the Fetch spec says. The Request's method, headers, body, redirect mode and signal apply unless `init` replaces them, and `init.headers` replaces all of the Request's headers.
|
|
12
|
+
- A Request given as `init` brings its method, headers and body.
|
|
13
|
+
- A body can be used once. `fetch(request)`, `new Request(request)` and reading it through `request.body` all use it, after which `bodyUsed` is true. Sending or copying a used Request throws `TypeError: Cannot reconstruct a Request with a used body.`, and reading it again throws workerd's "Body has already been used..." message. With a body in `init`, the source Request is left alone.
|
|
14
|
+
- A GET or HEAD Request with any body throws, even `""` or one inherited from another Request. An incoming GET or HEAD has `request.body === null`.
|
|
15
|
+
- Methods are workerd's: known names in any case are uppercased, and anything else throws `Invalid HTTP method string`. That includes `CONNECT` and custom names.
|
|
16
|
+
- `redirect` must be `"follow"` or `"manual"`. As in workerd, `"error"` throws when the Request is made.
|
|
17
|
+
- The headers of an incoming request, a fetched or service-binding response, and `Response.redirect()` are immutable: `set`, `append` and `delete` throw `TypeError: Can't modify immutable headers.` Copies are mutable, so `new Request(request)`, `new Response(body, response)` and `new Headers(h)` can still be changed.
|
|
18
|
+
- `AbortSignal.abort()`'s default reason reads "The operation was aborted", as in workerd.
|
|
19
|
+
|
|
20
|
+
With an earlier `compatibility_date` these keep their old behaviour: a copy shares the body, any method name and redirect mode passes, and headers stay mutable. To get the new behaviour, move `compatibility_date` to `2026-10-23` and rebuild.
|
|
21
|
+
|
|
22
|
+
These are fixed at every date, since no app can depend on the old behaviour:
|
|
23
|
+
|
|
24
|
+
- `Request.signal` exists. A Request made with `signal` passes it on to copies and to `fetch()`, which aborts with it.
|
|
25
|
+
- A `ReadableStream` body in an outgoing `fetch()` is sent as its bytes. Before, it went out as the text `[object ReadableStream]`.
|
|
26
|
+
- `request.clone()` and `response.clone()` tee a stream body, so both copies can read it. Before, the second reader failed.
|
|
27
|
+
- A copy of an incoming request keeps its raw bytes. Its `text()` decodes UTF-8, and `fetch()` sends the bytes unchanged.
|
|
28
|
+
|
|
3
29
|
## 0.22.0
|
|
4
30
|
|
|
5
31
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -3315,6 +3315,15 @@ for (const E of [Error, TypeError, RangeError, ReferenceError, SyntaxError, Eval
|
|
|
3315
3315
|
// Object.fromEntries(headers) work. Porffor's Headers has entries() but no iterator.
|
|
3316
3316
|
if (Headers.prototype[Symbol.iterator] == null) Headers.prototype[Symbol.iterator] = Headers.prototype.entries;
|
|
3317
3317
|
|
|
3318
|
+
// workerd's immutable headers, from compatibility date 2026-10-23: an incoming request's,
|
|
3319
|
+
// a fetched or service-binding response's, and Response.redirect()'s refuse append, set
|
|
3320
|
+
// and delete with a TypeError. A copy (new Headers(h), new Response(body, res)) is
|
|
3321
|
+
// mutable. The runtime's own writes after the handler lift the guard first.
|
|
3322
|
+
function __sbFreezeHeaders(message) {
|
|
3323
|
+
if (globalThis.__sbCompat >= "2026-10-23") message.headers._immutable = true;
|
|
3324
|
+
return message;
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3318
3327
|
// Porffor alpha-10 has no static Response.redirect().
|
|
3319
3328
|
if (Response.redirect == null) {
|
|
3320
3329
|
Response.redirect = function (url, status = 302) {
|
|
@@ -3322,7 +3331,7 @@ if (Response.redirect == null) {
|
|
|
3322
3331
|
throw new RangeError("invalid redirect status");
|
|
3323
3332
|
}
|
|
3324
3333
|
// Serialized like the URL parser does (a bare origin gains its "/"); invalid URLs throw.
|
|
3325
|
-
return new Response(null, { status, headers: { location: new URL(String(url)).href } });
|
|
3334
|
+
return __sbFreezeHeaders(new Response(null, { status, headers: { location: new URL(String(url)).href } }));
|
|
3326
3335
|
};
|
|
3327
3336
|
}
|
|
3328
3337
|
|
|
@@ -3739,7 +3748,9 @@ if (typeof globalThis.EventTarget !== "function") {
|
|
|
3739
3748
|
__sbAbort(reason) {
|
|
3740
3749
|
if (this.aborted) return;
|
|
3741
3750
|
this.aborted = true;
|
|
3742
|
-
|
|
3751
|
+
// workerd's default reason reads "The operation was aborted" (from 2026-10-23 here).
|
|
3752
|
+
const message = globalThis.__sbCompat >= "2026-10-23" ? "The operation was aborted" : "This operation was aborted";
|
|
3753
|
+
this.reason = reason === undefined ? namedError("AbortError", message) : reason;
|
|
3743
3754
|
const event = new Event("abort");
|
|
3744
3755
|
if (typeof this.onabort === "function") this.onabort.call(this, event);
|
|
3745
3756
|
this.dispatchEvent(event);
|
|
@@ -5229,7 +5240,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
5229
5240
|
fetch(input, init) {
|
|
5230
5241
|
if (globalThis.__sbCompat >= "2026-10-22") return __sbServiceFetch(binding, input, init);
|
|
5231
5242
|
const parts = __sbFetchParts(input, init);
|
|
5232
|
-
const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...
|
|
5243
|
+
const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbPartsBody(parts) };
|
|
5233
5244
|
return __sbUpstreamResponse(globalThis.__sbFetchUpstream("service.fetch", msg));
|
|
5234
5245
|
},
|
|
5235
5246
|
};
|
|
@@ -5251,9 +5262,9 @@ async function __sbServiceFetch(binding, input, init) {
|
|
|
5251
5262
|
const parts = __sbFetchParts(input, init);
|
|
5252
5263
|
if (parts.signal) parts.signal.throwIfAborted();
|
|
5253
5264
|
// A stream body is read to the end first: the wire carries a whole body.
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...
|
|
5265
|
+
const body = __sbUnwrapBody(parts.body);
|
|
5266
|
+
const wire = body instanceof ReadableStream ? __sbRequestBody(await __sbCollectBody(body)) : __sbPartsBody({ ...parts, body });
|
|
5267
|
+
const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...wire };
|
|
5257
5268
|
const response = await __sbFetchFollowing("service.fetch", msg, "sproutboat service " + binding, parts.redirect, parts.signal);
|
|
5258
5269
|
// The callee threw: as in workerd, its error is thrown here.
|
|
5259
5270
|
const uncaught = response.headers.get("x-sb-uncaught");
|
|
@@ -5436,7 +5447,29 @@ async function __sbRpcDispatch(handlers, receiver, method, request) {
|
|
|
5436
5447
|
|
|
5437
5448
|
// fetch(input, init) as Fetch reads it: a Request's method, headers, body, redirect mode
|
|
5438
5449
|
// and signal apply unless init overrides them; a URL object stands for its href.
|
|
5450
|
+
// From compatibility date 2026-10-23 it is Fetch's own first step, `new Request(input,
|
|
5451
|
+
// init)`, as in workerd: the input Request's body is used up (a second fetch of it
|
|
5452
|
+
// throws), a Request given as init brings its body, GET and HEAD refuse a body, and the
|
|
5453
|
+
// method and redirect mode are checked. Before it, the parts are read off the input.
|
|
5439
5454
|
function __sbFetchParts(input, init) {
|
|
5455
|
+
if (globalThis.__sbCompat >= "2026-10-23") {
|
|
5456
|
+
const request = new Request(input, init);
|
|
5457
|
+
const headers = [];
|
|
5458
|
+
request.headers.forEach((v, k) => headers.push([k, v]));
|
|
5459
|
+
const body = request._bodyStream != null ? request._bodyStream : request._bodyInit;
|
|
5460
|
+
// A stream the runtime made over a value is sent as that value, unread: it is used
|
|
5461
|
+
// all the same, so a Request that gave it (as input or as init) reads as used.
|
|
5462
|
+
if (body instanceof ReadableStream && body._sbBody !== undefined) body._disturbed = true;
|
|
5463
|
+
return {
|
|
5464
|
+
url: request.url,
|
|
5465
|
+
method: request.method,
|
|
5466
|
+
headers,
|
|
5467
|
+
body: body === undefined ? null : body,
|
|
5468
|
+
latin1: request._bodyLatin1 === true,
|
|
5469
|
+
redirect: request.redirect,
|
|
5470
|
+
signal: request._signal || null,
|
|
5471
|
+
};
|
|
5472
|
+
}
|
|
5440
5473
|
const req = input != null && !__sbIsStr(input) && input.url != null ? input : null;
|
|
5441
5474
|
const url = req ? String(req.url) : input != null && input.href != null ? String(input.href) : String(input);
|
|
5442
5475
|
const opts = init || {};
|
|
@@ -5449,8 +5482,17 @@ function __sbFetchParts(input, init) {
|
|
|
5449
5482
|
const body = opts.body !== undefined && !(opts instanceof Request) ? opts.body : req && __sbRawBody(req) !== "" ? __sbRawBody(req) : null;
|
|
5450
5483
|
const method = opts.method || (req && req.method) || "GET";
|
|
5451
5484
|
const redirect = opts.redirect || (req && req.redirect) || "follow";
|
|
5452
|
-
const signal = opts.signal || (req && req.
|
|
5453
|
-
|
|
5485
|
+
const signal = opts.signal || (req && req._signal) || null;
|
|
5486
|
+
// An incoming request's body is its raw bytes, one character each: sent as bytes.
|
|
5487
|
+
const latin1 = body != null && opts.body === undefined && req !== null && req._bodyLatin1 === true;
|
|
5488
|
+
return { url, method, headers, body, latin1, redirect, signal };
|
|
5489
|
+
}
|
|
5490
|
+
|
|
5491
|
+
// The wire fields of a fetch body (see __sbRequestBody). Raw ingress bytes go as bytes,
|
|
5492
|
+
// not as text encoded again.
|
|
5493
|
+
function __sbPartsBody(parts) {
|
|
5494
|
+
if (parts.latin1 === true && __sbIsStr(parts.body)) return { body: parts.body, bodyBinary: true };
|
|
5495
|
+
return __sbRequestBody(parts.body);
|
|
5454
5496
|
}
|
|
5455
5497
|
|
|
5456
5498
|
// An upstream reply (fetch or service.fetch) -> a Response. Headers are appended,
|
|
@@ -5469,7 +5511,7 @@ function __sbUpstreamResponse(r) {
|
|
|
5469
5511
|
if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
|
|
5470
5512
|
const response = __sbRawBodyResponse(r.body, { status: r.status || 502, headers: respHeaders });
|
|
5471
5513
|
if (!typed) response.headers.delete("content-type");
|
|
5472
|
-
return response;
|
|
5514
|
+
return __sbFreezeHeaders(response);
|
|
5473
5515
|
}
|
|
5474
5516
|
|
|
5475
5517
|
// A streamed upstream response (from compatibility date 2026-10-22, with the other
|
|
@@ -5520,7 +5562,7 @@ function __sbUpstreamStreamed(op, msg, label, signal) {
|
|
|
5520
5562
|
__sbCancelAsync(id);
|
|
5521
5563
|
},
|
|
5522
5564
|
});
|
|
5523
|
-
resolve(new Response(body, { status: reply.status || 502, headers }));
|
|
5565
|
+
resolve(__sbFreezeHeaders(new Response(body, { status: reply.status || 502, headers })));
|
|
5524
5566
|
return;
|
|
5525
5567
|
}
|
|
5526
5568
|
const frame = JSON.parse(raw);
|
|
@@ -5645,9 +5687,23 @@ function __sbUpstream(op, msg, label, signal, always) {
|
|
|
5645
5687
|
// prelude must not replace fetch just by being evaluated.
|
|
5646
5688
|
globalThis.__sbInstallFetch = function () {
|
|
5647
5689
|
globalThis.fetch = function (input, init) {
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5690
|
+
let parts;
|
|
5691
|
+
try {
|
|
5692
|
+
parts = __sbFetchParts(input, init);
|
|
5693
|
+
} catch (error) {
|
|
5694
|
+
// From 2026-10-23 an invalid request rejects, as in workerd; before it, it threw.
|
|
5695
|
+
if (globalThis.__sbCompat >= "2026-10-23") return Promise.reject(error);
|
|
5696
|
+
throw error;
|
|
5697
|
+
}
|
|
5698
|
+
const send = (body) => {
|
|
5699
|
+
const msg = { url: parts.url, method: parts.method, headers: parts.headers, ...body };
|
|
5700
|
+
return __sbFetchFollowing("fetch", msg, "sproutboat fetch", parts.redirect, parts.signal);
|
|
5701
|
+
};
|
|
5702
|
+
// A stream body is read to the end first, as for a service binding: the wire carries a
|
|
5703
|
+
// whole body. It was sent as the text "[object ReadableStream]".
|
|
5704
|
+
const stream = __sbUnwrapBody(parts.body);
|
|
5705
|
+
if (stream instanceof ReadableStream) return __sbCollectBody(stream).then((bytes) => send(__sbRequestBody(bytes)));
|
|
5706
|
+
return send(__sbPartsBody(parts));
|
|
5651
5707
|
};
|
|
5652
5708
|
};
|
|
5653
5709
|
|
|
@@ -5691,7 +5747,11 @@ function __sbMakeDONamespace(binding, className) {
|
|
|
5691
5747
|
return {
|
|
5692
5748
|
fetch(input, init) {
|
|
5693
5749
|
let req;
|
|
5694
|
-
|
|
5750
|
+
// From 2026-10-23 the object gets the Request Fetch makes of input and init, as in
|
|
5751
|
+
// workerd: method, headers and body survive, and the input's body is used up.
|
|
5752
|
+
if (globalThis.__sbCompat >= "2026-10-23") {
|
|
5753
|
+
req = new Request(input, init);
|
|
5754
|
+
} else if (__sbIsObj(input) && __sbIsStr(input.url) && !init) {
|
|
5695
5755
|
req = input;
|
|
5696
5756
|
} else {
|
|
5697
5757
|
const url = __sbIsStr(input) ? input : String((input && input.url) || "https://do/");
|
|
@@ -6101,6 +6161,8 @@ function __sbStreamingSupported() {
|
|
|
6101
6161
|
function __sbStreamResponse(response) {
|
|
6102
6162
|
const stream = __sbRawBody(response);
|
|
6103
6163
|
const id = __sbStreamOp("o", "0", "");
|
|
6164
|
+
// A fetched response returned as it is has immutable headers; this mark is ours.
|
|
6165
|
+
response.headers._immutable = false;
|
|
6104
6166
|
response.headers.set("x-sb-stream", id);
|
|
6105
6167
|
response.body = "";
|
|
6106
6168
|
(async () => {
|
|
@@ -6227,6 +6289,12 @@ async function __sbQueueWithDrain(result, tasks) {
|
|
|
6227
6289
|
function __sbDecodeIngress(request) {
|
|
6228
6290
|
const raw = __sbRawBody(request);
|
|
6229
6291
|
if (raw == null) return;
|
|
6292
|
+
// A GET or HEAD has no body, as in workerd (from 2026-10-23): request.body is null, and
|
|
6293
|
+
// new Request(request) copies none. Before it, the body is the empty string.
|
|
6294
|
+
if (raw === "" && (request.method === "GET" || request.method === "HEAD") && globalThis.__sbCompat >= "2026-10-23") {
|
|
6295
|
+
request.body = null;
|
|
6296
|
+
return;
|
|
6297
|
+
}
|
|
6230
6298
|
// Raw bytes, one character each: arrayBuffer(), blob() and body read them as bytes.
|
|
6231
6299
|
request._bodyLatin1 = true;
|
|
6232
6300
|
let decoded = null;
|
|
@@ -6258,6 +6326,8 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
6258
6326
|
const __tlsCipher = __sbTrustedProxyHeader(request, "x-sb-tls-cipher");
|
|
6259
6327
|
if (__tlsCipher) __cf.tlsCipher = __tlsCipher;
|
|
6260
6328
|
request.cf = __cf;
|
|
6329
|
+
// The edge's headers are gone: from 2026-10-23 the handler cannot change the rest.
|
|
6330
|
+
__sbFreezeHeaders(request);
|
|
6261
6331
|
// #28 — per-invocation CPU time. One fetch turn per process (serial), so the
|
|
6262
6332
|
// process CPU delta across the handler is this invocation's CPU.
|
|
6263
6333
|
// ponytail: serial-turn assumption; revisit if the profile ever allows
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
6
|
+
import { createBroker, listen } from "../../wire/src/broker";
|
|
7
|
+
import { EMPTY_BINDINGS, preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
8
|
+
|
|
9
|
+
// fetch(Request), fetch(Request, init) and new Request(request, init) on a compiled sprout,
|
|
10
|
+
// through the broker transport, against what workerd does (the platform's
|
|
11
|
+
// tests/service-bindings suite holds workerd's answers). The same handler is compiled at
|
|
12
|
+
// compatibility date 2026-10-23, where Request follows Fetch as workerd does, and at
|
|
13
|
+
// 2026-10-22, where an app keeps the older behaviour: a copied Request shares its body,
|
|
14
|
+
// headers stay mutable and any method name passes.
|
|
15
|
+
const handler = `
|
|
16
|
+
const textStream = (...parts) =>
|
|
17
|
+
new ReadableStream({
|
|
18
|
+
start(c) {
|
|
19
|
+
for (const p of parts) c.enqueue(new TextEncoder().encode(p));
|
|
20
|
+
c.close();
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
const attempt = async (step) => {
|
|
24
|
+
try {
|
|
25
|
+
return await step();
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return e.name + ": " + e.message;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default {
|
|
32
|
+
async fetch(request, env) {
|
|
33
|
+
const url = new URL(request.url);
|
|
34
|
+
const up = url.searchParams.get("up");
|
|
35
|
+
const echo = async (res) => res.json();
|
|
36
|
+
if (url.pathname === "/ping") return new Response("pong");
|
|
37
|
+
if (url.pathname === "/fetch") {
|
|
38
|
+
const viaRequest = await echo(await fetch(new Request(up + "/echo", { method: "PUT", headers: { "x-from": "request" }, body: "req body" })));
|
|
39
|
+
const plusInit = await echo(
|
|
40
|
+
await fetch(new Request(up + "/echo", { method: "PUT", headers: { "x-from": "request" }, body: "req body" }), { method: "PATCH", headers: { "x-from": "init" } }),
|
|
41
|
+
);
|
|
42
|
+
const initBody = await echo(await fetch(new Request(up + "/echo", { method: "PUT", body: "req body" }), { body: "init body" }));
|
|
43
|
+
const stream = await echo(await fetch(new Request(up + "/echo", { method: "POST", body: textStream("in ", "a request"), duplex: "half" })));
|
|
44
|
+
const initStream = await echo(await fetch(up + "/echo", { method: "POST", body: textStream("in ", "init"), duplex: "half" }));
|
|
45
|
+
const initRequest = new Request("https://other/", { method: "POST", headers: { "x-from": "init-request" }, body: "init req" });
|
|
46
|
+
const asInit = { ...(await echo(await fetch(up + "/echo", initRequest))), initUsed: initRequest.bodyUsed };
|
|
47
|
+
const manual = (await fetch(new Request(up + "/redirect", { redirect: "manual" }))).status;
|
|
48
|
+
const aborted = await attempt(() => fetch(new Request(up + "/echo", { signal: AbortSignal.abort() })).then((r) => r.status));
|
|
49
|
+
const once = new Request(up + "/echo", { method: "POST", body: "once" });
|
|
50
|
+
const first = (await echo(await fetch(once))).body;
|
|
51
|
+
const usedUp = once.bodyUsed;
|
|
52
|
+
const again = await attempt(() => fetch(once).then(echo).then((e) => e.body));
|
|
53
|
+
const getBody = await attempt(() => fetch(up + "/echo", { body: "x" }).then((r) => r.status));
|
|
54
|
+
return Response.json({ viaRequest, plusInit, initBody, stream, initStream, asInit, manual, aborted, first, usedUp, again, getBody });
|
|
55
|
+
}
|
|
56
|
+
if (url.pathname === "/forward") {
|
|
57
|
+
// The incoming request's raw bytes reach the upstream intact.
|
|
58
|
+
const copied = await echo(await fetch(new Request(up + "/echo", request)));
|
|
59
|
+
return Response.json({ copied, incomingUsed: request.bodyUsed });
|
|
60
|
+
}
|
|
61
|
+
if (url.pathname === "/request") {
|
|
62
|
+
const a = new Request("https://peer/a", { method: "POST", headers: { "x-a": "1" }, body: "abc" });
|
|
63
|
+
const b = new Request(a, { headers: { "x-b": "2" } });
|
|
64
|
+
const copy = { aUsed: a.bodyUsed, aRead: await attempt(() => a.text()), b: await b.text(), headers: [...b.headers] };
|
|
65
|
+
const s = new Request("https://peer/s", { method: "POST", body: textStream("tee ", "me"), duplex: "half" });
|
|
66
|
+
const t = s.clone();
|
|
67
|
+
const tee = [await t.text(), await s.text()];
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const signalled = new Request(new Request("https://peer/", { signal: controller.signal }));
|
|
70
|
+
controller.abort();
|
|
71
|
+
const res = await fetch(url.searchParams.get("up") + "/echo");
|
|
72
|
+
await res.text();
|
|
73
|
+
return Response.json({
|
|
74
|
+
copy,
|
|
75
|
+
tee,
|
|
76
|
+
signal: [signalled.signal.aborted, new Request("https://peer/").signal.aborted],
|
|
77
|
+
getBody: await attempt(() => new Request("https://peer/", { body: "" }).method),
|
|
78
|
+
method: await attempt(() => new Request("https://peer/", { method: "custom" }).method),
|
|
79
|
+
redirect: await attempt(() => new Request("https://peer/", { redirect: "error" }).redirect),
|
|
80
|
+
incoming: await attempt(() => (request.headers.set("x-t", "1"), "mutable")),
|
|
81
|
+
fetched: await attempt(() => (res.headers.set("x-t", "1"), "mutable")),
|
|
82
|
+
copiedResponse: await attempt(() => (new Response(null, res).headers.set("x-t", "1"), "mutable")),
|
|
83
|
+
incomingBody: request.body === null ? "none" : typeof request.body,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return new Response("no route", { status: 404 });
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
function freePort(): number {
|
|
92
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
93
|
+
const port = listener.port;
|
|
94
|
+
listener.stop(true);
|
|
95
|
+
return port;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const upstream = Bun.serve({
|
|
99
|
+
hostname: "127.0.0.1",
|
|
100
|
+
port: 0,
|
|
101
|
+
async fetch(request) {
|
|
102
|
+
const url = new URL(request.url);
|
|
103
|
+
if (url.pathname === "/redirect") return new Response(null, { status: 302, headers: { location: "/echo?hop=1" } });
|
|
104
|
+
const bytes = new Uint8Array(await request.arrayBuffer());
|
|
105
|
+
let binaryIntact = bytes.length === 256;
|
|
106
|
+
for (let i = 0; binaryIntact && i < 256; i++) binaryIntact = bytes[i] === i;
|
|
107
|
+
return Response.json({
|
|
108
|
+
method: request.method,
|
|
109
|
+
from: request.headers.get("x-from"),
|
|
110
|
+
type: request.headers.get("content-type"),
|
|
111
|
+
body: binaryIntact ? "bytes 0-255" : new TextDecoder().decode(bytes),
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
const up = `http://127.0.0.1:${upstream.port}`;
|
|
116
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-fetch-request-"));
|
|
117
|
+
const broker = createBroker({
|
|
118
|
+
db: ":memory:",
|
|
119
|
+
token: "native-fetch-request",
|
|
120
|
+
bindings: { ...EMPTY_BINDINGS, resources: {} },
|
|
121
|
+
egressAllow: ["127.0.0.1"],
|
|
122
|
+
});
|
|
123
|
+
const brokerServer = listen(broker, "127.0.0.1", 0);
|
|
124
|
+
const servers: ReturnType<typeof Bun.spawn>[] = [];
|
|
125
|
+
|
|
126
|
+
// Compiles the handler at `date` and starts it; resolves to a GET helper for it.
|
|
127
|
+
async function start(porffor: string, node: string, source: string, date: string) {
|
|
128
|
+
const port = freePort();
|
|
129
|
+
const generated = join(workdir, `fetch-request-${date}.generated.js`);
|
|
130
|
+
const binary = join(workdir, `fetch-request-${date}`);
|
|
131
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, source, {}, EMPTY_BINDINGS, undefined, date));
|
|
132
|
+
const compile = Bun.spawn(
|
|
133
|
+
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
134
|
+
{
|
|
135
|
+
cwd: workdir,
|
|
136
|
+
stdout: "pipe",
|
|
137
|
+
stderr: "pipe",
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
const [exitCode, stderr] = await Promise.all([compile.exited, new Response(compile.stderr).text()]);
|
|
141
|
+
assert.equal(exitCode, 0, `Porffor native compile at ${date} failed:\n${stderr}`);
|
|
142
|
+
const server = Bun.spawn([binary], {
|
|
143
|
+
cwd: workdir,
|
|
144
|
+
env: {
|
|
145
|
+
...process.env,
|
|
146
|
+
PORT: String(port),
|
|
147
|
+
SB_BROKER_PORT: String(brokerServer.port),
|
|
148
|
+
SB_BROKER_TOKEN: "native-fetch-request",
|
|
149
|
+
},
|
|
150
|
+
stdout: "ignore",
|
|
151
|
+
stderr: "inherit",
|
|
152
|
+
});
|
|
153
|
+
servers.push(server);
|
|
154
|
+
const call = async (path: string, init?: RequestInit) => {
|
|
155
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
156
|
+
const res = await fetch(`http://127.0.0.1:${port}${path}${sep}up=${encodeURIComponent(up)}`, {
|
|
157
|
+
...init,
|
|
158
|
+
signal: AbortSignal.timeout(20_000),
|
|
159
|
+
});
|
|
160
|
+
const text = await res.text();
|
|
161
|
+
assert.equal(res.status, 200, `${date} ${path}: ${text}`);
|
|
162
|
+
return JSON.parse(text);
|
|
163
|
+
};
|
|
164
|
+
for (let i = 0; i < 50; i++) {
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetch(`http://127.0.0.1:${port}/ping`);
|
|
167
|
+
if ((await res.text()) === "pong") return call;
|
|
168
|
+
} catch {
|
|
169
|
+
if (server.exitCode !== null) break;
|
|
170
|
+
await Bun.sleep(100);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
throw new Error(`compiled handler at ${date} did not start`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const porffor = await ensurePorffor({ cacheRoot: workdir });
|
|
178
|
+
const node = Bun.which("node");
|
|
179
|
+
if (!node) throw new Error("native fetch-request test needs node on PATH");
|
|
180
|
+
const [core, transport] = await Promise.all([
|
|
181
|
+
readFile(preludePath, "utf8"),
|
|
182
|
+
readFile(transportPath("broker"), "utf8"),
|
|
183
|
+
]);
|
|
184
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
185
|
+
const source = core.replace(TRANSPORT_MARKER, transport);
|
|
186
|
+
const [strict, legacy] = await Promise.all([
|
|
187
|
+
start(porffor, node, source, "2026-10-23"),
|
|
188
|
+
start(porffor, node, source, "2026-10-22"),
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
const echo = (method: string, from: string | null, type: string | null, body: string) => ({
|
|
192
|
+
method,
|
|
193
|
+
from,
|
|
194
|
+
type,
|
|
195
|
+
body,
|
|
196
|
+
});
|
|
197
|
+
const plain = "text/plain;charset=UTF-8";
|
|
198
|
+
const bytes = new Uint8Array(256).map((_, i) => i);
|
|
199
|
+
|
|
200
|
+
// From 2026-10-23: fetch() is fetch(new Request(input, init)), as in workerd.
|
|
201
|
+
assert.deepEqual(await strict("/fetch"), {
|
|
202
|
+
viaRequest: echo("PUT", "request", plain, "req body"),
|
|
203
|
+
plusInit: echo("PATCH", "init", null, "req body"),
|
|
204
|
+
initBody: echo("PUT", null, plain, "init body"),
|
|
205
|
+
stream: echo("POST", null, null, "in a request"),
|
|
206
|
+
initStream: echo("POST", null, null, "in init"),
|
|
207
|
+
asInit: { ...echo("POST", "init-request", plain, "init req"), initUsed: true },
|
|
208
|
+
manual: 302,
|
|
209
|
+
aborted: "AbortError: The operation was aborted",
|
|
210
|
+
first: "once",
|
|
211
|
+
usedUp: true,
|
|
212
|
+
again: "TypeError: Cannot reconstruct a Request with a used body.",
|
|
213
|
+
getBody: "TypeError: Request with a GET or HEAD method cannot have a body.",
|
|
214
|
+
});
|
|
215
|
+
assert.deepEqual(await strict("/forward", { method: "POST", body: bytes, headers: { "x-from": "client" } }), {
|
|
216
|
+
copied: echo("POST", "client", null, "bytes 0-255"),
|
|
217
|
+
incomingUsed: true,
|
|
218
|
+
});
|
|
219
|
+
assert.deepEqual(await strict("/request"), {
|
|
220
|
+
copy: {
|
|
221
|
+
aUsed: true,
|
|
222
|
+
aRead:
|
|
223
|
+
"TypeError: Body has already been used. It can only be used once. Use tee() first if you need to read it twice.",
|
|
224
|
+
b: "abc",
|
|
225
|
+
headers: [["x-b", "2"]],
|
|
226
|
+
},
|
|
227
|
+
tee: ["tee me", "tee me"],
|
|
228
|
+
signal: [true, false],
|
|
229
|
+
getBody: "TypeError: Request with a GET or HEAD method cannot have a body.",
|
|
230
|
+
method: "TypeError: Invalid HTTP method string: custom",
|
|
231
|
+
redirect:
|
|
232
|
+
'TypeError: Invalid redirect value, must be one of "follow" or "manual" ("error" won\'t be implemented since it does not make sense at the edge; use "manual" and check the response status code).',
|
|
233
|
+
incoming: "TypeError: Can't modify immutable headers.",
|
|
234
|
+
fetched: "TypeError: Can't modify immutable headers.",
|
|
235
|
+
copiedResponse: "mutable",
|
|
236
|
+
incomingBody: "none",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Before it: the Request's method, headers and body still reach the upstream (fixed at
|
|
240
|
+
// every date, as are stream bodies and raw bytes), but a Request can be sent twice, a
|
|
241
|
+
// copy leaves the source readable, and headers, methods and redirect modes are not checked.
|
|
242
|
+
const old = await legacy("/fetch");
|
|
243
|
+
assert.deepEqual(
|
|
244
|
+
{
|
|
245
|
+
viaRequest: old.viaRequest,
|
|
246
|
+
plusInit: old.plusInit,
|
|
247
|
+
stream: old.stream,
|
|
248
|
+
initStream: old.initStream,
|
|
249
|
+
manual: old.manual,
|
|
250
|
+
again: old.again,
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
viaRequest: echo("PUT", "request", plain, "req body"),
|
|
254
|
+
plusInit: echo("PATCH", "init", null, "req body"),
|
|
255
|
+
stream: echo("POST", null, null, "in a request"),
|
|
256
|
+
initStream: echo("POST", null, null, "in init"),
|
|
257
|
+
manual: 302,
|
|
258
|
+
again: "once",
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
assert.deepEqual(await legacy("/forward", { method: "POST", body: bytes, headers: { "x-from": "client" } }), {
|
|
262
|
+
copied: echo("POST", "client", null, "bytes 0-255"),
|
|
263
|
+
incomingUsed: false,
|
|
264
|
+
});
|
|
265
|
+
const oldRequest = await legacy("/request");
|
|
266
|
+
assert.deepEqual(
|
|
267
|
+
{
|
|
268
|
+
aUsed: oldRequest.copy.aUsed,
|
|
269
|
+
aRead: oldRequest.copy.aRead,
|
|
270
|
+
tee: oldRequest.tee,
|
|
271
|
+
signal: oldRequest.signal,
|
|
272
|
+
method: oldRequest.method,
|
|
273
|
+
redirect: oldRequest.redirect,
|
|
274
|
+
incoming: oldRequest.incoming,
|
|
275
|
+
fetched: oldRequest.fetched,
|
|
276
|
+
incomingBody: oldRequest.incomingBody,
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
aUsed: false,
|
|
280
|
+
aRead: "abc",
|
|
281
|
+
tee: ["tee me", "tee me"],
|
|
282
|
+
signal: [true, false],
|
|
283
|
+
method: "CUSTOM",
|
|
284
|
+
redirect: "error",
|
|
285
|
+
incoming: "mutable",
|
|
286
|
+
fetched: "mutable",
|
|
287
|
+
incomingBody: "object",
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
console.log(
|
|
292
|
+
"fetch(Request) and new Request(request, init) follow workerd from 2026-10-23 (body used up, init merging, " +
|
|
293
|
+
"GET/HEAD bodies, methods, redirect modes, signals, immutable headers); stream bodies, clone() tees and raw bytes at every date; " +
|
|
294
|
+
"2026-10-22 keeps the older Request semantics",
|
|
295
|
+
);
|
|
296
|
+
} finally {
|
|
297
|
+
for (const server of servers) {
|
|
298
|
+
server.kill(9);
|
|
299
|
+
await server.exited;
|
|
300
|
+
}
|
|
301
|
+
brokerServer.stop();
|
|
302
|
+
broker.close();
|
|
303
|
+
upstream.stop(true);
|
|
304
|
+
await rm(workdir, { recursive: true, force: true });
|
|
305
|
+
}
|