@sproutboat/runtime 0.34.0 → 0.34.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/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/client-ip.test.js +11 -2
- package/src/entry-crash.test.js +53 -4
- package/src/ingress-utf8.test.js +1 -1
- package/src/native-fetch-prelude.js +124 -49
- package/src/transport-embedded.js +3 -3
- package/src/wrap.ts +2 -1
- package/test/native-cpu-stamp.ts +129 -0
- package/test/native-unhandled-rejection.ts +133 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.34.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- a0ba6a3: Faster requests: about 57% of a request's CPU was Porffor's `globalThis` getter.
|
|
8
|
+
|
|
9
|
+
- **Toolchain, the root cause.** Porffor's `#get_globalThis` re-synced every top-level function and `var` of the module into the global object on every read, one hashed write per binding (about 20 µs with the prelude's ~120 bindings, so every `globalThis.x` in user code paid it too). Every binding is now written when the object is built, and a later read rewrites only the bindings whose value changed (a packed copy of the last value written is kept per binding: an unchanged binding costs one compare). 20,000 reads with 600 top-level functions: 9,300 ms before, a few ms after.
|
|
10
|
+
- **Runtime and toolchain, the request path.** The runtime's `globalThis.__sbCompat` / `globalThis.env` reads go through accessors over module-level `let`s that the app tail sets once, and `Request`/`Response` cache the class lookups and take the date and WebSocketPair state from statics the runtime sets. Until the tail has run (the shared template, unit tests) everything reads `globalThis` as before.
|
|
11
|
+
- **Client IP.** `request.cf` no longer reads and splits `SB_TRUSTED_PROXIES` four times a request: it is read once per process, and with no trusted proxy (the usual case) the peer is the client and the forwarded-header lookups are skipped.
|
|
12
|
+
- **`x-sb-cpu-ms`.** The stamp is appended to the handler's own Response instead of building a second Response with a copy of every header. The same responses are stamped, with the same headers and body.
|
|
13
|
+
|
|
14
|
+
A 404 on one pinned x86 core, CPU per request when saturated, standalone: 319 µs before, 127 µs with the getter patch alone, 113 µs with the request-path change, 81 µs with the client-IP and stamp changes.
|
|
15
|
+
|
|
16
|
+
## 0.34.1
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- e62b72d: An unhandled promise rejection no longer ends the process. A D1 call that failed and was never awaited (`db.prepare("INSERT ...").run()` that broke a foreign key) threw its error out of Porffor's promise job loop into the host's "Uncaught ..." exit, taking every in-flight request down even with a try/catch around the call. As on Workers it is now reported: a log line (`Uncaught (in promise) ...`) and an `unhandledrejection` event (`PromiseRejectionEvent` with `promise` and `reason`) on the global scope when `addEventListener("unhandledrejection", ...)` has a listener, and the process and other requests carry on. This applies at every compatibility date, because a crash is a bug rather than a behaviour an app can depend on. The toolchain's `patchUnhandledRejections` calls the runtime's `globalThis.__sbOnUnhandledRejection`; plain Porffor without it keeps the throw. Applies to every binding call that returns a rejecting Promise (D1, KV, R2, Durable Objects, Queues, fetch). An un-awaited `async function` that throws is reported the same way (it used to be dropped silently): the C runtime that rejects it now hands the promise to the same check.
|
|
21
|
+
|
|
3
22
|
## 0.34.0
|
|
4
23
|
|
|
5
24
|
### Minor Changes
|
package/package.json
CHANGED
package/src/client-ip.test.js
CHANGED
|
@@ -17,7 +17,7 @@ const load = new Function(
|
|
|
17
17
|
"__envMap",
|
|
18
18
|
`${src.slice(start, end)}
|
|
19
19
|
const __sbEnv = (k) => __envMap[k];
|
|
20
|
-
return { __sbNormalizeIp, __sbIpInCidr, __sbIpTrusted, __sbClientIp, __sbSplitList, __sbTrustedProxyHeader };`,
|
|
20
|
+
return { __sbNormalizeIp, __sbIpInCidr, __sbIpTrusted, __sbClientIp, __sbSplitList, __sbTrustedProxyHeader, __sbTrustedProxiesOnce };`,
|
|
21
21
|
);
|
|
22
22
|
const H = load(new Proxy({}, { get: (_, k) => env[k] }));
|
|
23
23
|
|
|
@@ -62,7 +62,9 @@ test("__sbClientIp: a trusted peer resolves the rightmost untrusted XFF hop", ()
|
|
|
62
62
|
|
|
63
63
|
test("__sbClientIp: an untrusted peer cannot use XFF even if it sends one", () => {
|
|
64
64
|
env = { SB_TRUSTED_PROXIES: "10.0.0.0/8" };
|
|
65
|
-
expect(H.__sbClientIp(req({ "x-sb-remote-addr": "198.51.100.9", "x-forwarded-for": "1.1.1.1" }))).toBe(
|
|
65
|
+
expect(H.__sbClientIp(req({ "x-sb-remote-addr": "198.51.100.9", "x-forwarded-for": "1.1.1.1" }))).toBe(
|
|
66
|
+
"198.51.100.9",
|
|
67
|
+
);
|
|
66
68
|
});
|
|
67
69
|
|
|
68
70
|
test("__sbTrustedProxyHeader: no trusted proxies configured means the header is ignored", () => {
|
|
@@ -83,3 +85,10 @@ test("__sbTrustedProxyHeader: a trusted peer's value passes through, absent stay
|
|
|
83
85
|
expect(H.__sbTrustedProxyHeader(r, "x-sb-http-protocol")).toBe("HTTP/2");
|
|
84
86
|
expect(H.__sbTrustedProxyHeader(r, "x-sb-tls-cipher")).toBeUndefined();
|
|
85
87
|
});
|
|
88
|
+
|
|
89
|
+
test("__sbTrustedProxiesOnce reads SB_TRUSTED_PROXIES once and keeps the list", () => {
|
|
90
|
+
env = { SB_TRUSTED_PROXIES: "10.0.0.0/8, 192.0.2.1" };
|
|
91
|
+
expect(H.__sbTrustedProxiesOnce()).toEqual(["10.0.0.0/8", "192.0.2.1"]);
|
|
92
|
+
env = { SB_TRUSTED_PROXIES: "198.51.100.0/24" };
|
|
93
|
+
expect(H.__sbTrustedProxiesOnce()).toEqual(["10.0.0.0/8", "192.0.2.1"]); // fixed for the process
|
|
94
|
+
});
|
package/src/entry-crash.test.js
CHANGED
|
@@ -18,10 +18,7 @@ const load = new Function(
|
|
|
18
18
|
// Every Porffor.c`...` block in the prelude only runs inside a function body
|
|
19
19
|
// (never at load time), so a no-op tag covers all of them: __sbCpuMs and
|
|
20
20
|
// __sbEnv just see an empty result, same as "native op returned nothing."
|
|
21
|
-
const H = load(
|
|
22
|
-
{ c: () => "" },
|
|
23
|
-
new Proxy({}, { get: () => undefined }),
|
|
24
|
-
);
|
|
21
|
+
const H = load({ c: () => "" }, new Proxy({}, { get: () => undefined }));
|
|
25
22
|
|
|
26
23
|
const req = (overrides = {}) => ({
|
|
27
24
|
headers: { get: (k) => (overrides.headers && overrides.headers[k.toLowerCase()]) ?? null },
|
|
@@ -61,3 +58,55 @@ test("a throwing fetch handler doesn't stop the next request from being served",
|
|
|
61
58
|
const second = await H.__sbEntry(handlers, req({ boom: false }));
|
|
62
59
|
expect(await second.text()).toBe("ok");
|
|
63
60
|
});
|
|
61
|
+
|
|
62
|
+
// A promise that rejects with nobody waiting on it (an un-awaited D1 run() that broke a
|
|
63
|
+
// foreign key) must be reported, not end the process: Porffor's promise job loop calls
|
|
64
|
+
// globalThis.__sbOnUnhandledRejection (toolchain patchUnhandledRejections).
|
|
65
|
+
test("an unhandled rejection is logged and fires an unhandledrejection event", () => {
|
|
66
|
+
const logged = [];
|
|
67
|
+
const consoleError = console.error;
|
|
68
|
+
const dispatch = globalThis.dispatchEvent;
|
|
69
|
+
const Ctor = globalThis.PromiseRejectionEvent;
|
|
70
|
+
const events = [];
|
|
71
|
+
console.error = (line) => logged.push(String(line));
|
|
72
|
+
globalThis.PromiseRejectionEvent = class {
|
|
73
|
+
constructor(type, init) {
|
|
74
|
+
this.type = type;
|
|
75
|
+
this.reason = init.reason;
|
|
76
|
+
this.promise = init.promise;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
globalThis.dispatchEvent = (event) => events.push(event);
|
|
80
|
+
try {
|
|
81
|
+
const promise = Promise.reject(new Error("D1_ERROR: FOREIGN KEY constraint failed"));
|
|
82
|
+
promise.catch(() => {});
|
|
83
|
+
globalThis.__sbOnUnhandledRejection(new Error("D1_ERROR: FOREIGN KEY constraint failed"), promise);
|
|
84
|
+
} finally {
|
|
85
|
+
console.error = consoleError;
|
|
86
|
+
globalThis.dispatchEvent = dispatch;
|
|
87
|
+
globalThis.PromiseRejectionEvent = Ctor;
|
|
88
|
+
}
|
|
89
|
+
expect(logged.join("\n")).toContain("Uncaught (in promise)");
|
|
90
|
+
expect(logged.join("\n")).toContain("FOREIGN KEY");
|
|
91
|
+
expect(events).toHaveLength(1);
|
|
92
|
+
expect(events[0].type).toBe("unhandledrejection");
|
|
93
|
+
expect(events[0].reason.message).toContain("FOREIGN KEY");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a throwing unhandledrejection listener does not escape the hook", () => {
|
|
97
|
+
const consoleError = console.error;
|
|
98
|
+
const dispatch = globalThis.dispatchEvent;
|
|
99
|
+
const Ctor = globalThis.PromiseRejectionEvent;
|
|
100
|
+
console.error = () => {};
|
|
101
|
+
globalThis.PromiseRejectionEvent = class {};
|
|
102
|
+
globalThis.dispatchEvent = () => {
|
|
103
|
+
throw new Error("listener broke");
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
expect(() => globalThis.__sbOnUnhandledRejection(new Error("x"), undefined)).not.toThrow();
|
|
107
|
+
} finally {
|
|
108
|
+
console.error = consoleError;
|
|
109
|
+
globalThis.dispatchEvent = dispatch;
|
|
110
|
+
globalThis.PromiseRejectionEvent = Ctor;
|
|
111
|
+
}
|
|
112
|
+
});
|
package/src/ingress-utf8.test.js
CHANGED
|
@@ -7,7 +7,7 @@ const rawBody = src.slice(src.indexOf("function __sbRawBody("), src.indexOf("fun
|
|
|
7
7
|
if (!decoder || !ingress || !rawBody) throw new Error("Ingress decoder source not found");
|
|
8
8
|
// oxlint-disable-next-line no-function-constructor -- Exercise the text-only runtime prelude.
|
|
9
9
|
const load = new Function(
|
|
10
|
-
`// The C fast paths (Porffor.c) cannot run here: the stubs make the JS fallback run.\nfunction __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\n${decoder}\n${rawBody}\n${ingress}\nreturn __sbDecodeIngress;`,
|
|
10
|
+
`// The C fast paths (Porffor.c) cannot run here: the stubs make the JS fallback run.\nfunction __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\nfunction __sbCompatDate() { return undefined; }\n${decoder}\n${rawBody}\n${ingress}\nreturn __sbDecodeIngress;`,
|
|
11
11
|
);
|
|
12
12
|
const decode = load();
|
|
13
13
|
const raw = (text) => Array.from(new TextEncoder().encode(text), (byte) => String.fromCharCode(byte)).join("");
|
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
// Reads of globalThis are expensive in Porffor (the getter rebuilds the global object on each read);
|
|
2
|
+
// the app tail (wrap.ts) sets these two once. See sproutd/benchmarks/per-request-2026-10-26.
|
|
3
|
+
let __sbCompatNow;
|
|
4
|
+
let __sbEnvNow;
|
|
5
|
+
function __sbCompatDate() {
|
|
6
|
+
return __sbCompatNow === undefined ? globalThis.__sbCompat : __sbCompatNow;
|
|
7
|
+
}
|
|
8
|
+
function __sbEnvValue() {
|
|
9
|
+
return __sbEnvNow === undefined ? globalThis.env : __sbEnvNow;
|
|
10
|
+
}
|
|
11
|
+
// Setters, not bare assignments in the tail: the template and every app must compile the prelude identically.
|
|
12
|
+
function __sbSetCompat(date) {
|
|
13
|
+
__sbCompatNow = date;
|
|
14
|
+
Request._cd = date;
|
|
15
|
+
}
|
|
16
|
+
function __sbSetEnv(value) {
|
|
17
|
+
__sbEnvNow = value;
|
|
18
|
+
}
|
|
19
|
+
// Referenced, so the shared template keeps them: an app tail can only call what the template compiled.
|
|
20
|
+
globalThis.__sbSetCompat = __sbSetCompat;
|
|
21
|
+
globalThis.__sbSetEnv = __sbSetEnv;
|
|
1
22
|
// WEB STREAMS: generated by gen-streams.ts, do not edit.
|
|
2
23
|
/* oxlint-disable -- generated third-party implementation */
|
|
3
24
|
// web-streams-polyfill 4.3.0, MIT, copyright Mattias Buelens (see WEB_STREAMS_LICENSE)
|
|
@@ -3095,17 +3116,25 @@ function __sbCpuMs() {
|
|
|
3095
3116
|
function __sbTagCpu(res, t0) {
|
|
3096
3117
|
const cpu = __sbCpuMs() - t0;
|
|
3097
3118
|
try {
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3119
|
+
// A string body and no Set-Cookie, as before. The native writer reads Headers' flat `_entries`,
|
|
3120
|
+
// so the stamp is appended to the handler's own Response instead of building a second one (a
|
|
3121
|
+
// Headers, a body extraction and a status check, plus a copy of every header); an x-sb-cpu-ms
|
|
3122
|
+
// the handler set itself is replaced by the measured one, as before.
|
|
3123
|
+
const headers = res && res.headers;
|
|
3124
|
+
if (headers && __sbIsStr(__sbRawBody(res)) && !headers.has("set-cookie")) {
|
|
3125
|
+
const stamp = (cpu >= 0 ? cpu : 0).toFixed(3);
|
|
3126
|
+
const entries = headers._entries;
|
|
3127
|
+
let set = false;
|
|
3128
|
+
for (let i = 0; i < entries.length; i += 2) {
|
|
3129
|
+
if (entries[i] === "x-sb-cpu-ms") {
|
|
3130
|
+
entries[i + 1] = stamp;
|
|
3131
|
+
set = true;
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
if (!set) headers._push("x-sb-cpu-ms", stamp);
|
|
3106
3135
|
}
|
|
3107
3136
|
} catch {
|
|
3108
|
-
/*
|
|
3137
|
+
/* the handler's Response goes out unstamped */
|
|
3109
3138
|
}
|
|
3110
3139
|
return res;
|
|
3111
3140
|
}
|
|
@@ -3323,7 +3352,7 @@ if (Headers.prototype[Symbol.iterator] == null) Headers.prototype[Symbol.iterato
|
|
|
3323
3352
|
// and delete with a TypeError. A copy (new Headers(h), new Response(body, res)) is
|
|
3324
3353
|
// mutable. The runtime's own writes after the handler lift the guard first.
|
|
3325
3354
|
function __sbFreezeHeaders(message) {
|
|
3326
|
-
if (
|
|
3355
|
+
if (__sbCompatDate() >= "2026-10-23") message.headers._immutable = true;
|
|
3327
3356
|
return message;
|
|
3328
3357
|
}
|
|
3329
3358
|
|
|
@@ -3752,7 +3781,7 @@ if (typeof globalThis.EventTarget !== "function") {
|
|
|
3752
3781
|
if (this.aborted) return;
|
|
3753
3782
|
this.aborted = true;
|
|
3754
3783
|
// workerd's default reason reads "The operation was aborted" (from 2026-10-23 here).
|
|
3755
|
-
const message =
|
|
3784
|
+
const message = __sbCompatDate() >= "2026-10-23" ? "The operation was aborted" : "This operation was aborted";
|
|
3756
3785
|
this.reason = reason === undefined ? namedError("AbortError", message) : reason;
|
|
3757
3786
|
const event = new Event("abort");
|
|
3758
3787
|
if (typeof this.onabort === "function") this.onabort.call(this, event);
|
|
@@ -3789,6 +3818,22 @@ if (typeof globalThis.EventTarget !== "function") {
|
|
|
3789
3818
|
globalThis.EventTarget = EventTarget;
|
|
3790
3819
|
globalThis.AbortSignal = AbortSignal;
|
|
3791
3820
|
globalThis.AbortController = AbortController;
|
|
3821
|
+
// Workers' global event target: `addEventListener("unhandledrejection", fn)` hears every
|
|
3822
|
+
// promise that rejected with nobody waiting on it (see __sbOnUnhandledRejection).
|
|
3823
|
+
class PromiseRejectionEvent extends Event {
|
|
3824
|
+
constructor(type, init) {
|
|
3825
|
+
super(type, init);
|
|
3826
|
+
this.promise = init ? init.promise : undefined;
|
|
3827
|
+
this.reason = init ? init.reason : undefined;
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
const globalTarget = new EventTarget();
|
|
3831
|
+
globalThis.PromiseRejectionEvent = PromiseRejectionEvent;
|
|
3832
|
+
if (typeof globalThis.addEventListener !== "function") {
|
|
3833
|
+
globalThis.addEventListener = (type, listener, options) => globalTarget.addEventListener(type, listener, options);
|
|
3834
|
+
globalThis.removeEventListener = (type, listener, options) => globalTarget.removeEventListener(type, listener, options);
|
|
3835
|
+
globalThis.dispatchEvent = (event) => globalTarget.dispatchEvent(event);
|
|
3836
|
+
}
|
|
3792
3837
|
}
|
|
3793
3838
|
|
|
3794
3839
|
// TextEncoderStream and TextDecoderStream over the Web Streams ponyfill (W2 conformance).
|
|
@@ -4991,7 +5036,7 @@ function __sbRpc(op, extra) {
|
|
|
4991
5036
|
// pages with limit and cursor and returns each key's expiration and metadata. Earlier
|
|
4992
5037
|
// dates keep the synchronous shim below.
|
|
4993
5038
|
function __sbKvStrict() {
|
|
4994
|
-
return
|
|
5039
|
+
return __sbCompatDate() >= "2026-10-25";
|
|
4995
5040
|
}
|
|
4996
5041
|
function __sbKvKey(key, verb) {
|
|
4997
5042
|
const name = String(key);
|
|
@@ -5115,7 +5160,7 @@ function __sbMakeKv(ns) {
|
|
|
5115
5160
|
// are enforced, errors read "D1_ERROR: ...", and a BLOB comes back as an array of
|
|
5116
5161
|
// byte values. Earlier dates keep the old behaviour.
|
|
5117
5162
|
function __sbD1Strict() {
|
|
5118
|
-
return
|
|
5163
|
+
return __sbCompatDate() >= "2026-10-24";
|
|
5119
5164
|
}
|
|
5120
5165
|
function __sbD1Param(value, strict) {
|
|
5121
5166
|
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
|
@@ -5433,7 +5478,7 @@ function __sbR2Multipart(bucket, key, uploadId) {
|
|
|
5433
5478
|
// cursor, groups by delimiter and returns metadata only when `include` asks for it.
|
|
5434
5479
|
// Earlier dates keep the synchronous shim below.
|
|
5435
5480
|
function __sbR2Strict() {
|
|
5436
|
-
return
|
|
5481
|
+
return __sbCompatDate() >= "2026-10-26";
|
|
5437
5482
|
}
|
|
5438
5483
|
function __sbR2Key(key, verb) {
|
|
5439
5484
|
const name = String(key);
|
|
@@ -5774,7 +5819,7 @@ function __sbAssetResponse(path, r) {
|
|
|
5774
5819
|
// The queue name and consumer settings (bindings.queueConfig, from `queues` in
|
|
5775
5820
|
// sproutboat.jsonc) apply at every date: they are opt-in.
|
|
5776
5821
|
function __sbQueuesStrict() {
|
|
5777
|
-
return
|
|
5822
|
+
return __sbCompatDate() >= "2026-10-31";
|
|
5778
5823
|
}
|
|
5779
5824
|
|
|
5780
5825
|
// binding -> { binding, queue, maxBatchSize?, maxBatchTimeout?, maxRetries?, retryDelay?, deadLetterQueue? }
|
|
@@ -6090,7 +6135,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
6090
6135
|
sendBatch(messages, options) {
|
|
6091
6136
|
// From 2026-10-28 sendBatch(messages, { delaySeconds }) delays every message that
|
|
6092
6137
|
// sets none of its own, as on Workers; before, the batch option was ignored.
|
|
6093
|
-
const batchDelay =
|
|
6138
|
+
const batchDelay = __sbCompatDate() >= "2026-10-28" ? (options && options.delaySeconds) || 0 : 0;
|
|
6094
6139
|
const list = [];
|
|
6095
6140
|
for (let j = 0; j < (messages || []).length; j++) {
|
|
6096
6141
|
const m = messages[j];
|
|
@@ -6177,7 +6222,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
6177
6222
|
const binding = bindings.services[i].binding;
|
|
6178
6223
|
const fetcher = {
|
|
6179
6224
|
fetch(input, init) {
|
|
6180
|
-
if (
|
|
6225
|
+
if (__sbCompatDate() >= "2026-10-22") return __sbServiceFetch(binding, input, init);
|
|
6181
6226
|
const parts = __sbFetchParts(input, init);
|
|
6182
6227
|
const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbPartsBody(parts) };
|
|
6183
6228
|
return __sbUpstreamResponse(globalThis.__sbFetchUpstream("service.fetch", msg));
|
|
@@ -6367,7 +6412,7 @@ function __sbHandles(handlers, name) {
|
|
|
6367
6412
|
// Callee: the receiver of one request. A default export that is a class (a
|
|
6368
6413
|
// WorkerEntrypoint) is instantiated per request with its ctx and env, as in workerd.
|
|
6369
6414
|
function __sbReceiver(handlers, ctx) {
|
|
6370
|
-
return __sbIsFn(handlers) ? new handlers(ctx,
|
|
6415
|
+
return __sbIsFn(handlers) ? new handlers(ctx, __sbEnvValue()) : handlers;
|
|
6371
6416
|
}
|
|
6372
6417
|
|
|
6373
6418
|
// Callee: runs RPC method `method` of `receiver` with the arguments in the request body.
|
|
@@ -6396,7 +6441,7 @@ async function __sbRpcDispatch(handlers, receiver, method, request) {
|
|
|
6396
6441
|
// throws), a Request given as init brings its body, GET and HEAD refuse a body, and the
|
|
6397
6442
|
// method and redirect mode are checked. Before it, the parts are read off the input.
|
|
6398
6443
|
function __sbFetchParts(input, init) {
|
|
6399
|
-
if (
|
|
6444
|
+
if (__sbCompatDate() >= "2026-10-23") {
|
|
6400
6445
|
const request = new Request(input, init);
|
|
6401
6446
|
const headers = [];
|
|
6402
6447
|
request.headers.forEach((v, k) => headers.push([k, v]));
|
|
@@ -6472,7 +6517,7 @@ function __sbUpstreamResponse(r) {
|
|
|
6472
6517
|
function __sbStreamsBody(body) {
|
|
6473
6518
|
return (
|
|
6474
6519
|
body instanceof ReadableStream &&
|
|
6475
|
-
|
|
6520
|
+
__sbCompatDate() >= "2026-10-27" &&
|
|
6476
6521
|
globalThis.__sbBodyStreamable != null &&
|
|
6477
6522
|
globalThis.__sbBodyStreamable()
|
|
6478
6523
|
);
|
|
@@ -6599,7 +6644,7 @@ function __sbFollowHop(op, msg, label, mode, signal, hops) {
|
|
|
6599
6644
|
const step = (res) => {
|
|
6600
6645
|
const location = res.headers.get("location");
|
|
6601
6646
|
const redirect = res.status === 301 || res.status === 302 || res.status === 303 || res.status === 307 || res.status === 308;
|
|
6602
|
-
if (!(
|
|
6647
|
+
if (!(__sbCompatDate() >= "2026-10-22") || mode === "manual" || !redirect || location == null) return res;
|
|
6603
6648
|
if (mode === "error") throw new TypeError(label + ": redirect not allowed (redirect: \"error\")");
|
|
6604
6649
|
if (hops >= 20) throw new TypeError(label + ": too many redirects");
|
|
6605
6650
|
const dropped = __sbRawBody(res);
|
|
@@ -6636,9 +6681,9 @@ function __sbFollowHop(op, msg, label, mode, signal, hops) {
|
|
|
6636
6681
|
// (__sbAsyncAtAnyDate). `always` is for RPC, which has no older behaviour to keep.
|
|
6637
6682
|
function __sbUpstream(op, msg, label, signal, always) {
|
|
6638
6683
|
if (signal) signal.throwIfAborted();
|
|
6639
|
-
const promised = always ||
|
|
6684
|
+
const promised = always || __sbCompatDate() >= "2026-10-22" || __sbAsyncAtAnyDate();
|
|
6640
6685
|
if (!__sbHttpAsyncEnabled() || !promised) return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
|
|
6641
|
-
if (
|
|
6686
|
+
if (__sbCompatDate() >= "2026-10-22") return __sbUpstreamStreamed(op, msg, label, signal);
|
|
6642
6687
|
return new Promise((resolve, reject) => {
|
|
6643
6688
|
let onAbort = null;
|
|
6644
6689
|
const settled = () => {
|
|
@@ -6685,7 +6730,7 @@ globalThis.__sbInstallFetch = function () {
|
|
|
6685
6730
|
parts = __sbFetchParts(input, init);
|
|
6686
6731
|
} catch (error) {
|
|
6687
6732
|
// From 2026-10-23 an invalid request rejects, as in workerd; before it, it threw.
|
|
6688
|
-
if (
|
|
6733
|
+
if (__sbCompatDate() >= "2026-10-23") return Promise.reject(error);
|
|
6689
6734
|
throw error;
|
|
6690
6735
|
}
|
|
6691
6736
|
if (__sbWsOutboundOn() && __sbWsIsUpgrade(parts)) return __sbWsFetchUpgrade(parts);
|
|
@@ -6746,7 +6791,7 @@ function __sbMakeDONamespace(binding, className) {
|
|
|
6746
6791
|
let req;
|
|
6747
6792
|
// From 2026-10-23 the object gets the Request Fetch makes of input and init, as in
|
|
6748
6793
|
// workerd: method, headers and body survive, and the input's body is used up.
|
|
6749
|
-
if (
|
|
6794
|
+
if (__sbCompatDate() >= "2026-10-23") {
|
|
6750
6795
|
req = new Request(input, init);
|
|
6751
6796
|
} else if (__sbIsObj(input) && __sbIsStr(input.url) && !init) {
|
|
6752
6797
|
req = input;
|
|
@@ -6798,7 +6843,7 @@ function __sbMakeDONamespace(binding, className) {
|
|
|
6798
6843
|
// The object runs in this sprout on every transport (see above), so an RPC call is a
|
|
6799
6844
|
// direct call; only its storage goes to the broker.
|
|
6800
6845
|
function __sbDOv2() {
|
|
6801
|
-
return
|
|
6846
|
+
return __sbCompatDate() >= "2026-10-29";
|
|
6802
6847
|
}
|
|
6803
6848
|
|
|
6804
6849
|
function __sbDOSha(text) {
|
|
@@ -7044,7 +7089,7 @@ function __sbGetDOInstance(cls, idText, idObj) {
|
|
|
7044
7089
|
// The hibernation API, from 2026-10-30 (where WebSocketPair exists).
|
|
7045
7090
|
const holder = { inst: null, auto: null, cls, id, restored: [] };
|
|
7046
7091
|
if (__sbWsEnabled()) __sbDOWebSocketState(state, holder);
|
|
7047
|
-
inst = new Ctor(state,
|
|
7092
|
+
inst = new Ctor(state, __sbEnvValue());
|
|
7048
7093
|
holder.inst = inst;
|
|
7049
7094
|
inst.__sbTasks = __sbTasks;
|
|
7050
7095
|
inst.__sbGate = gate;
|
|
@@ -7138,7 +7183,7 @@ function __sbDOStorage(cls, id) {
|
|
|
7138
7183
|
// Values written in the new format start with __sbDOTag (no JSON text starts with "#"),
|
|
7139
7184
|
// so either side reads both.
|
|
7140
7185
|
function __sbDOGated() {
|
|
7141
|
-
return
|
|
7186
|
+
return __sbCompatDate() >= "2026-10-28";
|
|
7142
7187
|
}
|
|
7143
7188
|
|
|
7144
7189
|
function __sbDOMakeGate() {
|
|
@@ -8420,7 +8465,7 @@ function __sbWsHostResponse(r) {
|
|
|
8420
8465
|
}
|
|
8421
8466
|
// The compatibility date goes along: a standalone or platform server carries client sockets
|
|
8422
8467
|
// from 2026-11-01 only (sproutd, whose bridge ignores it, from 2026-10-30).
|
|
8423
|
-
const id = Number(__sbWsOp("o", 0,
|
|
8468
|
+
const id = Number(__sbWsOp("o", 0, __sbCompatDate(), 0));
|
|
8424
8469
|
if (!(id > 0)) return new Response("WebSockets are not supported by this host yet", { status: 501 });
|
|
8425
8470
|
ws._host = id;
|
|
8426
8471
|
if (ws._peer !== null && ws._peer._hib !== null && __sbWsHibOn()) __sbWsHibRegister(ws._peer, id);
|
|
@@ -8480,7 +8525,7 @@ function __sbWsPump(ws) {
|
|
|
8480
8525
|
// described at __sbWsOp, ids with the top bit set. Returning that Response from a handler
|
|
8481
8526
|
// relays the socket to the client through a pair.
|
|
8482
8527
|
function __sbWsOutboundOn() {
|
|
8483
|
-
return
|
|
8528
|
+
return __sbCompatDate() >= "2026-11-01";
|
|
8484
8529
|
}
|
|
8485
8530
|
let __sbWsWakeable = -1;
|
|
8486
8531
|
function __sbWsCanWake() {
|
|
@@ -8767,7 +8812,7 @@ function __sbWsProxy(r, upstream) {
|
|
|
8767
8812
|
// their tags and attachments) and the event is dispatched as webSocketMessage/webSocketClose.
|
|
8768
8813
|
let __sbWsHibHost = -1;
|
|
8769
8814
|
function __sbWsHibOn() {
|
|
8770
|
-
if (
|
|
8815
|
+
if (__sbCompatDate() < "2026-11-01" || !__sbDOGated()) return false;
|
|
8771
8816
|
if (__sbWsHibHost < 0 && __sbWsOp("H", 0, "", 0) === "1") __sbWsHibHost = 1;
|
|
8772
8817
|
return __sbWsHibHost === 1;
|
|
8773
8818
|
}
|
|
@@ -8899,7 +8944,9 @@ function __sbWsRestoreSocket(id) {
|
|
|
8899
8944
|
}
|
|
8900
8945
|
|
|
8901
8946
|
function __sbInstallWebSockets() {
|
|
8902
|
-
|
|
8947
|
+
Request._hasWsp = false;
|
|
8948
|
+
if (__sbCompatDate() < "2026-10-30") return;
|
|
8949
|
+
Request._hasWsp = true;
|
|
8903
8950
|
globalThis.WebSocketPair = __SbWebSocketPair;
|
|
8904
8951
|
globalThis.WebSocket = __SbWebSocket;
|
|
8905
8952
|
globalThis.WebSocketRequestResponsePair = __SbWebSocketRequestResponsePair;
|
|
@@ -8962,7 +9009,7 @@ function __sbBindingCallIngress(request) {
|
|
|
8962
9009
|
if (vouched) {
|
|
8963
9010
|
const url = headers.get("x-sb-url");
|
|
8964
9011
|
// The callee sees its caller's URL from compatibility date 2026-10-22, as in workerd.
|
|
8965
|
-
if (url && URL.canParse(url) &&
|
|
9012
|
+
if (url && URL.canParse(url) && __sbCompatDate() >= "2026-10-22") request.url = url;
|
|
8966
9013
|
headers.delete("x-sb-remote-addr");
|
|
8967
9014
|
}
|
|
8968
9015
|
for (const name of ["x-sb-binding-call", "x-sb-url", "x-sb-edge-token"]) {
|
|
@@ -8983,6 +9030,21 @@ function __sbLogUncaught(where, error) {
|
|
|
8983
9030
|
}
|
|
8984
9031
|
}
|
|
8985
9032
|
|
|
9033
|
+
// A promise rejected and nothing was waiting on it (an un-awaited `db.prepare(...).run()`
|
|
9034
|
+
// that failed, say). As on Workers the request, the process and every other request go on:
|
|
9035
|
+
// the rejection is logged, and an "unhandledrejection" event fires on the global scope when
|
|
9036
|
+
// something listens. Porffor's promise job loop calls this hook (patchUnhandledRejections);
|
|
9037
|
+
// without it, it throws the reason out of the event loop and the process exits.
|
|
9038
|
+
globalThis.__sbOnUnhandledRejection = function (reason, promise) {
|
|
9039
|
+
__sbLogUncaught("promise", reason);
|
|
9040
|
+
try {
|
|
9041
|
+
if (__sbIsFn(globalThis.PromiseRejectionEvent) && __sbIsFn(globalThis.dispatchEvent))
|
|
9042
|
+
globalThis.dispatchEvent(new globalThis.PromiseRejectionEvent("unhandledrejection", { promise, reason, cancelable: true }));
|
|
9043
|
+
} catch (error) {
|
|
9044
|
+
__sbLogUncaught("unhandledrejection listener", error);
|
|
9045
|
+
}
|
|
9046
|
+
};
|
|
9047
|
+
|
|
8986
9048
|
// Shared by alarm and scheduled: both reply with an empty 204 once their
|
|
8987
9049
|
// handler (and anything it queued via `waitUntil`) has settled.
|
|
8988
9050
|
async function __sb204WithDrain(res, tasks) {
|
|
@@ -9047,7 +9109,7 @@ function __sbReqBodyOp(op, id, data) {
|
|
|
9047
9109
|
// Turns streaming on in the native server, from 2026-10-27. Called once at startup, after
|
|
9048
9110
|
// the compatibility date is set (__sbInstallFetch).
|
|
9049
9111
|
function __sbIngressStreamingSetup() {
|
|
9050
|
-
if (
|
|
9112
|
+
if (__sbCompatDate() >= "2026-10-27") __sbReqBodyOp("e", 0, "");
|
|
9051
9113
|
}
|
|
9052
9114
|
|
|
9053
9115
|
// request.body for a streamed request: chunks as the server receives them.
|
|
@@ -9124,7 +9186,7 @@ function __sbIngressStream(request, id) {
|
|
|
9124
9186
|
// handing the request to user code. Request.text() in Porffor otherwise treats
|
|
9125
9187
|
// the raw bytes as already-decoded characters and corrupts Unicode/HMAC input.
|
|
9126
9188
|
function __sbDecodeIngress(request) {
|
|
9127
|
-
const streamed =
|
|
9189
|
+
const streamed = __sbCompatDate() >= "2026-10-27" ? Number(__sbReqBodyOp("t", 0, "")) : 0;
|
|
9128
9190
|
if (streamed > 0) {
|
|
9129
9191
|
__sbIngressStream(request, streamed);
|
|
9130
9192
|
return;
|
|
@@ -9133,7 +9195,7 @@ function __sbDecodeIngress(request) {
|
|
|
9133
9195
|
if (raw == null) return;
|
|
9134
9196
|
// A GET or HEAD has no body, as in workerd (from 2026-10-23): request.body is null, and
|
|
9135
9197
|
// new Request(request) copies none. Before it, the body is the empty string.
|
|
9136
|
-
if (raw === "" && (request.method === "GET" || request.method === "HEAD") &&
|
|
9198
|
+
if (raw === "" && (request.method === "GET" || request.method === "HEAD") && __sbCompatDate() >= "2026-10-23") {
|
|
9137
9199
|
request.body = null;
|
|
9138
9200
|
return;
|
|
9139
9201
|
}
|
|
@@ -9150,6 +9212,14 @@ function __sbDecodeIngress(request) {
|
|
|
9150
9212
|
};
|
|
9151
9213
|
}
|
|
9152
9214
|
|
|
9215
|
+
// SB_TRUSTED_PROXIES is fixed for the life of a process (a sproutd worker gets its own after the fork,
|
|
9216
|
+
// and this runs per request, never before it), so it is read and split once, not four times a request.
|
|
9217
|
+
let __sbTrustedCache;
|
|
9218
|
+
function __sbTrustedProxiesOnce() {
|
|
9219
|
+
if (__sbTrustedCache === undefined) __sbTrustedCache = __sbSplitList(__sbEnv("SB_TRUSTED_PROXIES"));
|
|
9220
|
+
return __sbTrustedCache;
|
|
9221
|
+
}
|
|
9222
|
+
|
|
9153
9223
|
globalThis.__sbEntry = function (handlers, request) {
|
|
9154
9224
|
__sbDecodeIngress(request);
|
|
9155
9225
|
const trigger = request.headers.get("x-sb-trigger");
|
|
@@ -9160,13 +9230,18 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
9160
9230
|
// are only set when a trusted front proxy forwarded them; colo, ASN and
|
|
9161
9231
|
// geo fields stay absent everywhere (#128 explicitly rejects faking them).
|
|
9162
9232
|
const __cf = request.cf || {};
|
|
9163
|
-
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
|
|
9167
|
-
|
|
9168
|
-
|
|
9169
|
-
|
|
9233
|
+
if (__sbTrustedProxiesOnce().length === 0) {
|
|
9234
|
+
// No trusted proxy (the usual case): the peer is the client and no forwarded header counts.
|
|
9235
|
+
__cf.clientIp = __sbNormalizeIp(request.headers.get("x-sb-remote-addr") || "");
|
|
9236
|
+
} else {
|
|
9237
|
+
__cf.clientIp = __sbClientIp(request);
|
|
9238
|
+
const __httpProtocol = __sbTrustedProxyHeader(request, "x-sb-http-protocol");
|
|
9239
|
+
if (__httpProtocol) __cf.httpProtocol = __httpProtocol;
|
|
9240
|
+
const __tlsVersion = __sbTrustedProxyHeader(request, "x-sb-tls-version");
|
|
9241
|
+
if (__tlsVersion) __cf.tlsVersion = __tlsVersion;
|
|
9242
|
+
const __tlsCipher = __sbTrustedProxyHeader(request, "x-sb-tls-cipher");
|
|
9243
|
+
if (__tlsCipher) __cf.tlsCipher = __tlsCipher;
|
|
9244
|
+
}
|
|
9170
9245
|
request.cf = __cf;
|
|
9171
9246
|
// The edge's headers are gone: from 2026-10-23 the handler cannot change the rest.
|
|
9172
9247
|
__sbFreezeHeaders(request);
|
|
@@ -9196,8 +9271,8 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
9196
9271
|
if (rpc == null && !__sbHandles(handlers, "fetch")) throw new Error("Handler does not export a fetch() function.");
|
|
9197
9272
|
__res = rpc != null
|
|
9198
9273
|
? __sbRpcDispatch(handlers, receiver, rpc, request)
|
|
9199
|
-
:
|
|
9200
|
-
? receiver.fetch(request,
|
|
9274
|
+
: __sbCompatDate() >= "2026-09-28"
|
|
9275
|
+
? receiver.fetch(request, __sbEnvValue(), ctx)
|
|
9201
9276
|
: receiver.fetch(request, ctx);
|
|
9202
9277
|
} catch (error) {
|
|
9203
9278
|
// #179 — a synchronous throw anywhere in the handler must not take the
|
|
@@ -9229,8 +9304,8 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
9229
9304
|
try {
|
|
9230
9305
|
const event = { cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} };
|
|
9231
9306
|
const receiver = __sbReceiver(handlers, ctx);
|
|
9232
|
-
__sres =
|
|
9233
|
-
? receiver.scheduled(event,
|
|
9307
|
+
__sres = __sbCompatDate() >= "2026-09-28"
|
|
9308
|
+
? receiver.scheduled(event, __sbEnvValue(), ctx)
|
|
9234
9309
|
: receiver.scheduled(event, ctx);
|
|
9235
9310
|
} catch (error) {
|
|
9236
9311
|
// #179 — same rule as fetch: don't let a throw here crash the process.
|
|
@@ -9358,8 +9433,8 @@ async function __sbRunQueueBatch(handlers, body, ctx) {
|
|
|
9358
9433
|
};
|
|
9359
9434
|
}
|
|
9360
9435
|
try {
|
|
9361
|
-
const __qres =
|
|
9362
|
-
? handlers.queue(batch,
|
|
9436
|
+
const __qres = __sbCompatDate() >= "2026-09-28"
|
|
9437
|
+
? handlers.queue(batch, __sbEnvValue(), ctx)
|
|
9363
9438
|
: handlers.queue(batch, ctx);
|
|
9364
9439
|
// Was fire-and-forget: an async `queue()` had this default-ack pass run (and
|
|
9365
9440
|
// the response go out) before its own `await`s did, so any ack()/retry()
|
|
@@ -5323,7 +5323,7 @@ function __sbLtxConfigure() {
|
|
|
5323
5323
|
}
|
|
5324
5324
|
|
|
5325
5325
|
function __sbCall(reqJson) {
|
|
5326
|
-
if (
|
|
5326
|
+
if (__sbCompatDate() >= "2026-10-22") __sbLtxConfigure();
|
|
5327
5327
|
try {
|
|
5328
5328
|
return JSON.stringify(__sbEmbeddedDispatch(JSON.parse(reqJson)));
|
|
5329
5329
|
} catch (err) {
|
|
@@ -5710,8 +5710,8 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
|
|
|
5710
5710
|
try {
|
|
5711
5711
|
const receiver = __sbReceiver(handlers, ctx);
|
|
5712
5712
|
const event = { cron: crons[i], scheduledTime: now.getTime(), noRetry() {} };
|
|
5713
|
-
const res =
|
|
5714
|
-
? receiver.scheduled(event,
|
|
5713
|
+
const res = __sbCompatDate() >= "2026-09-28"
|
|
5714
|
+
? receiver.scheduled(event, __sbEnvValue(), ctx)
|
|
5715
5715
|
: receiver.scheduled(event, ctx);
|
|
5716
5716
|
if (res && __sbIsFn(res.then)) await res;
|
|
5717
5717
|
} catch (error) {
|
package/src/wrap.ts
CHANGED
|
@@ -234,7 +234,7 @@ export function wrapNativeFetchHandler(
|
|
|
234
234
|
);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
const env = `const env = ${JSON.stringify(vars)};\nglobalThis.env = env;\n`;
|
|
237
|
+
const env = `const env = ${JSON.stringify(vars)};\nglobalThis.env = env;\n__sbSetEnv(env);\n`;
|
|
238
238
|
const versionMetadataLine = versionMetadata
|
|
239
239
|
? `env[${JSON.stringify(versionMetadata.binding)}] = ${JSON.stringify({
|
|
240
240
|
id: versionMetadata.id,
|
|
@@ -246,6 +246,7 @@ export function wrapNativeFetchHandler(
|
|
|
246
246
|
// not be able to change the semantics it was compiled against at runtime.
|
|
247
247
|
const compat =
|
|
248
248
|
`globalThis.__sbCompat = ${JSON.stringify(compatibilityDate)};\n` +
|
|
249
|
+
`__sbSetCompat(${JSON.stringify(compatibilityDate)});\n` +
|
|
249
250
|
// #15 — the embedded transport derives its default data directory from this.
|
|
250
251
|
`globalThis.__sbAppName = ${JSON.stringify(appName)};\n` +
|
|
251
252
|
(assets ? `globalThis.__sbAssets = ${JSON.stringify(assets)};\n` : "");
|
|
@@ -0,0 +1,129 @@
|
|
|
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 { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
|
+
|
|
8
|
+
// The x-sb-cpu-ms stamp is added to the handler's own Response (it used to build a second one),
|
|
9
|
+
// and request.cf's client IP skips the trusted-proxy work when no proxy is configured. What goes on
|
|
10
|
+
// the wire must not change: which responses are stamped, the headers and the body, the client IP.
|
|
11
|
+
const handler = `export default {fetch(request) {
|
|
12
|
+
const path = new URL(request.url).pathname;
|
|
13
|
+
if (path === "/text") return new Response("hello");
|
|
14
|
+
if (path === "/json") return new Response('{"a":1}', { status: 201, headers: { "content-type": "application/json", "x-a": "1" } });
|
|
15
|
+
if (path === "/bytes") return new Response(new Uint8Array([1, 2, 3]));
|
|
16
|
+
if (path === "/cookies") {
|
|
17
|
+
const h = new Headers(); h.append("set-cookie", "a=1"); h.append("set-cookie", "b=2");
|
|
18
|
+
return new Response("c", { headers: h });
|
|
19
|
+
}
|
|
20
|
+
if (path === "/own") return new Response("own", { headers: { "x-sb-cpu-ms": "mine" } });
|
|
21
|
+
if (path === "/ip") return new Response(JSON.stringify({ ip: request.cf.clientIp, proto: request.cf.httpProtocol ?? null }));
|
|
22
|
+
return new Response("not found", { status: 404 });
|
|
23
|
+
}};`;
|
|
24
|
+
|
|
25
|
+
function freePort(): number {
|
|
26
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
27
|
+
const port = listener.port;
|
|
28
|
+
listener.stop(true);
|
|
29
|
+
return port;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-cpu-stamp-"));
|
|
33
|
+
const servers: ReturnType<typeof Bun.spawn>[] = [];
|
|
34
|
+
async function start(binary: string, env: Record<string, string>): Promise<number> {
|
|
35
|
+
const port = freePort();
|
|
36
|
+
const server = Bun.spawn([binary], {
|
|
37
|
+
cwd: workdir,
|
|
38
|
+
env: { ...process.env, ...env, PORT: String(port) },
|
|
39
|
+
stdout: "ignore",
|
|
40
|
+
stderr: "inherit",
|
|
41
|
+
});
|
|
42
|
+
servers.push(server);
|
|
43
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
44
|
+
try {
|
|
45
|
+
await fetch(`http://127.0.0.1:${port}/text`, { signal: AbortSignal.timeout(500) });
|
|
46
|
+
return port;
|
|
47
|
+
} catch {
|
|
48
|
+
if (server.exitCode !== null) break;
|
|
49
|
+
await Bun.sleep(100);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw new Error("compiled handler did not start");
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const porffor = await ensurePorffor();
|
|
56
|
+
const node = Bun.which("node");
|
|
57
|
+
if (!node) throw new Error("native cpu stamp test needs node on PATH");
|
|
58
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
59
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
60
|
+
const generated = join(workdir, "stamp.generated.js");
|
|
61
|
+
const binary = join(workdir, "stamp-native");
|
|
62
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, broker)));
|
|
63
|
+
const compile = Bun.spawn(
|
|
64
|
+
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
65
|
+
{
|
|
66
|
+
cwd: workdir,
|
|
67
|
+
env: process.env,
|
|
68
|
+
stdout: "pipe",
|
|
69
|
+
stderr: "pipe",
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
73
|
+
compile.exited,
|
|
74
|
+
new Response(compile.stdout).text(),
|
|
75
|
+
new Response(compile.stderr).text(),
|
|
76
|
+
]);
|
|
77
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
78
|
+
|
|
79
|
+
const port = await start(binary, {});
|
|
80
|
+
const get = (path: string, headers: Record<string, string> = {}, p = port) =>
|
|
81
|
+
fetch(`http://127.0.0.1:${p}${path}`, { headers });
|
|
82
|
+
|
|
83
|
+
const text = await get("/text");
|
|
84
|
+
assert.equal(await text.text(), "hello");
|
|
85
|
+
assert.match(text.headers.get("x-sb-cpu-ms") ?? "", /^\d+\.\d{3}$/, "a string body is stamped");
|
|
86
|
+
assert.equal(text.headers.get("content-type"), "text/plain;charset=UTF-8");
|
|
87
|
+
|
|
88
|
+
const json = await get("/json");
|
|
89
|
+
assert.equal(json.status, 201);
|
|
90
|
+
assert.equal(await json.text(), '{"a":1}');
|
|
91
|
+
assert.equal(json.headers.get("x-a"), "1");
|
|
92
|
+
assert.equal(json.headers.get("content-type"), "application/json");
|
|
93
|
+
assert.match(json.headers.get("x-sb-cpu-ms") ?? "", /^\d+\.\d{3}$/);
|
|
94
|
+
|
|
95
|
+
const notFound = await get("/missing");
|
|
96
|
+
assert.equal(notFound.status, 404);
|
|
97
|
+
assert.match(notFound.headers.get("x-sb-cpu-ms") ?? "", /^\d+\.\d{3}$/);
|
|
98
|
+
|
|
99
|
+
const bytes = await get("/bytes");
|
|
100
|
+
assert.deepEqual([...new Uint8Array(await bytes.arrayBuffer())], [1, 2, 3]);
|
|
101
|
+
assert.equal(bytes.headers.get("x-sb-cpu-ms"), null, "a binary body is not stamped");
|
|
102
|
+
|
|
103
|
+
const cookies = await get("/cookies");
|
|
104
|
+
assert.equal(await cookies.text(), "c");
|
|
105
|
+
assert.deepEqual(cookies.headers.getSetCookie(), ["a=1", "b=2"]);
|
|
106
|
+
assert.equal(cookies.headers.get("x-sb-cpu-ms"), null, "a Set-Cookie response is not stamped");
|
|
107
|
+
|
|
108
|
+
const own = await get("/own");
|
|
109
|
+
assert.match(own.headers.get("x-sb-cpu-ms") ?? "", /^\d+\.\d{3}$/, "the measured value replaces a handler-set one");
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < 5; i++) assert.equal(await (await get("/text")).text(), "hello");
|
|
112
|
+
|
|
113
|
+
// No trusted proxy: the peer is the client, forwarded headers do not count.
|
|
114
|
+
const plain = await (await get("/ip", { "x-forwarded-for": "203.0.113.9", "x-sb-http-protocol": "h2" })).json();
|
|
115
|
+
assert.deepEqual(plain, { ip: "127.0.0.1", proto: null });
|
|
116
|
+
// The peer is a trusted proxy: the forwarded client and protocol count.
|
|
117
|
+
const proxied = await start(binary, { SB_TRUSTED_PROXIES: "127.0.0.1" });
|
|
118
|
+
const forwarded = await (
|
|
119
|
+
await get("/ip", { "x-forwarded-for": "203.0.113.9", "x-sb-http-protocol": "h2" }, proxied)
|
|
120
|
+
).json();
|
|
121
|
+
assert.deepEqual(forwarded, { ip: "203.0.113.9", proto: "h2" });
|
|
122
|
+
console.log("Native x-sb-cpu-ms stamp and client IP: same responses on the wire, no second Response");
|
|
123
|
+
} finally {
|
|
124
|
+
for (const server of servers) {
|
|
125
|
+
server.kill(9);
|
|
126
|
+
await server.exited;
|
|
127
|
+
}
|
|
128
|
+
await rm(workdir, { recursive: true, force: true });
|
|
129
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
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 { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
|
+
|
|
8
|
+
// A promise that rejects with nobody waiting on it (a binding call that failed and was never
|
|
9
|
+
// awaited, say a D1 run() that broke a foreign key) must not take the process down, whatever
|
|
10
|
+
// the compatibility date. As on Workers it is logged, an "unhandledrejection" listener hears
|
|
11
|
+
// it, and the next request is served.
|
|
12
|
+
const handler = `addEventListener("unhandledrejection", (event) => {
|
|
13
|
+
globalThis.__heard = (globalThis.__heard || []).concat(String(event.reason && event.reason.message));
|
|
14
|
+
});
|
|
15
|
+
export default {async fetch(request) {
|
|
16
|
+
const path = new URL(request.url).pathname;
|
|
17
|
+
if (path === "/reject") {
|
|
18
|
+
try {
|
|
19
|
+
Promise.reject(new Error("D1_ERROR: FOREIGN KEY constraint failed"));
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return new Response("caught", { status: 500 });
|
|
22
|
+
}
|
|
23
|
+
return new Response("fired");
|
|
24
|
+
}
|
|
25
|
+
if (path === "/promise") {
|
|
26
|
+
try {
|
|
27
|
+
new Promise(function (resolve, reject) { reject(new Error("kv failed")); });
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return new Response("caught", { status: 500 });
|
|
30
|
+
}
|
|
31
|
+
return new Response("fired");
|
|
32
|
+
}
|
|
33
|
+
if (path === "/async") {
|
|
34
|
+
async function failNow() { throw new Error("async function failed"); }
|
|
35
|
+
failNow();
|
|
36
|
+
return new Response("fired");
|
|
37
|
+
}
|
|
38
|
+
if (path === "/heard") return new Response(JSON.stringify(globalThis.__heard || []));
|
|
39
|
+
return new Response("pong");
|
|
40
|
+
}};`;
|
|
41
|
+
|
|
42
|
+
function freePort(): number {
|
|
43
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
44
|
+
const port = listener.port;
|
|
45
|
+
listener.stop(true);
|
|
46
|
+
return port;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const date of ["2026-09-28", "2026-10-24"]) {
|
|
50
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-unhandled-"));
|
|
51
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
52
|
+
try {
|
|
53
|
+
const porffor = await ensurePorffor();
|
|
54
|
+
const node = Bun.which("node");
|
|
55
|
+
if (!node) throw new Error("native unhandled rejection test needs node on PATH");
|
|
56
|
+
const [core, broker] = await Promise.all([
|
|
57
|
+
readFile(preludePath, "utf8"),
|
|
58
|
+
readFile(transportPath("broker"), "utf8"),
|
|
59
|
+
]);
|
|
60
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
61
|
+
const generated = join(workdir, "unhandled.generated.js");
|
|
62
|
+
const binary = join(workdir, "unhandled-native");
|
|
63
|
+
await writeFile(
|
|
64
|
+
generated,
|
|
65
|
+
wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, broker), undefined, undefined, undefined, date),
|
|
66
|
+
);
|
|
67
|
+
const compile = Bun.spawn(
|
|
68
|
+
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
69
|
+
{
|
|
70
|
+
cwd: workdir,
|
|
71
|
+
env: process.env,
|
|
72
|
+
stdout: "pipe",
|
|
73
|
+
stderr: "pipe",
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
77
|
+
compile.exited,
|
|
78
|
+
new Response(compile.stdout).text(),
|
|
79
|
+
new Response(compile.stderr).text(),
|
|
80
|
+
]);
|
|
81
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
82
|
+
|
|
83
|
+
const port = freePort();
|
|
84
|
+
server = Bun.spawn([binary], {
|
|
85
|
+
cwd: workdir,
|
|
86
|
+
env: { ...process.env, PORT: String(port), SB_BROKER_TOKEN: "native-test-token" },
|
|
87
|
+
stdout: "ignore",
|
|
88
|
+
stderr: "pipe",
|
|
89
|
+
});
|
|
90
|
+
let ready = false;
|
|
91
|
+
for (let attempt = 0; attempt < 50 && !ready; attempt++) {
|
|
92
|
+
try {
|
|
93
|
+
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
|
|
94
|
+
ready = true;
|
|
95
|
+
} catch {
|
|
96
|
+
if (server.exitCode !== null) break;
|
|
97
|
+
await Bun.sleep(100);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
assert(ready, `compiled handler did not start (${date})`);
|
|
101
|
+
const get = async (path: string) => (await fetch(`http://127.0.0.1:${port}${path}`)).text();
|
|
102
|
+
|
|
103
|
+
assert.equal(await get("/reject"), "fired");
|
|
104
|
+
assert.equal(await get("/pong"), "pong", `the request after an unhandled rejection failed (${date})`);
|
|
105
|
+
assert.equal(await get("/promise"), "fired");
|
|
106
|
+
assert.equal(await get("/async"), "fired");
|
|
107
|
+
await Bun.sleep(300);
|
|
108
|
+
assert.equal(await get("/pong"), "pong", `the request after a rejected promise failed (${date})`);
|
|
109
|
+
assert.equal(server.exitCode, null, `the server exited on an unhandled rejection (${date})`);
|
|
110
|
+
assert.equal(await get("/pong"), "pong", `the request after an unawaited async function threw failed (${date})`);
|
|
111
|
+
const heard = JSON.parse(await get("/heard"));
|
|
112
|
+
assert(
|
|
113
|
+
heard.some((message: string) => message.includes("FOREIGN KEY")),
|
|
114
|
+
`no unhandledrejection event for the rejected promise (${date}): ${JSON.stringify(heard)}`,
|
|
115
|
+
);
|
|
116
|
+
assert(
|
|
117
|
+
heard.some((message: string) => message.includes("async function failed")),
|
|
118
|
+
`no unhandledrejection event for the async function (${date}): ${JSON.stringify(heard)}`,
|
|
119
|
+
);
|
|
120
|
+
server.kill(9);
|
|
121
|
+
await server.exited;
|
|
122
|
+
const log = await new Response(server.stderr).text();
|
|
123
|
+
assert(log.includes("Uncaught (in promise)"), `the rejection was not logged (${date}): ${log}`);
|
|
124
|
+
server = undefined;
|
|
125
|
+
console.log(`Native unhandled rejections are reported, not fatal (${date})`);
|
|
126
|
+
} finally {
|
|
127
|
+
if (server) {
|
|
128
|
+
server.kill(9);
|
|
129
|
+
await server.exited;
|
|
130
|
+
}
|
|
131
|
+
await rm(workdir, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
}
|