@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.34.0",
3
+ "version": "0.34.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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("198.51.100.9");
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
+ });
@@ -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
+ });
@@ -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
- const body = __sbRawBody(res);
3099
- if (__sbIsStr(body) && !res.headers.has("set-cookie")) {
3100
- const headers = {};
3101
- res.headers.forEach(function (value, name) {
3102
- headers[name] = value;
3103
- });
3104
- headers["x-sb-cpu-ms"] = (cpu >= 0 ? cpu : 0).toFixed(3);
3105
- return new Response(body, { status: res.status, headers: headers });
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
- /* fall through to the original response */
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 (globalThis.__sbCompat >= "2026-10-23") message.headers._immutable = true;
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 = globalThis.__sbCompat >= "2026-10-23" ? "The operation was aborted" : "This operation was aborted";
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 globalThis.__sbCompat >= "2026-10-25";
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 globalThis.__sbCompat >= "2026-10-24";
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 globalThis.__sbCompat >= "2026-10-26";
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 globalThis.__sbCompat >= "2026-10-31";
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 = globalThis.__sbCompat >= "2026-10-28" ? (options && options.delaySeconds) || 0 : 0;
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 (globalThis.__sbCompat >= "2026-10-22") return __sbServiceFetch(binding, input, init);
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, globalThis.env) : handlers;
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 (globalThis.__sbCompat >= "2026-10-23") {
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
- globalThis.__sbCompat >= "2026-10-27" &&
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 (!(globalThis.__sbCompat >= "2026-10-22") || mode === "manual" || !redirect || location == null) return res;
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 || globalThis.__sbCompat >= "2026-10-22" || __sbAsyncAtAnyDate();
6684
+ const promised = always || __sbCompatDate() >= "2026-10-22" || __sbAsyncAtAnyDate();
6640
6685
  if (!__sbHttpAsyncEnabled() || !promised) return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
6641
- if (globalThis.__sbCompat >= "2026-10-22") return __sbUpstreamStreamed(op, msg, label, signal);
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 (globalThis.__sbCompat >= "2026-10-23") return Promise.reject(error);
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 (globalThis.__sbCompat >= "2026-10-23") {
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 globalThis.__sbCompat >= "2026-10-29";
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, globalThis.env);
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 globalThis.__sbCompat >= "2026-10-28";
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, globalThis.__sbCompat, 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 globalThis.__sbCompat >= "2026-11-01";
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 (globalThis.__sbCompat < "2026-11-01" || !__sbDOGated()) return false;
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
- if (globalThis.__sbCompat < "2026-10-30") return;
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) && globalThis.__sbCompat >= "2026-10-22") request.url = 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 (globalThis.__sbCompat >= "2026-10-27") __sbReqBodyOp("e", 0, "");
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 = globalThis.__sbCompat >= "2026-10-27" ? Number(__sbReqBodyOp("t", 0, "")) : 0;
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") && globalThis.__sbCompat >= "2026-10-23") {
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
- __cf.clientIp = __sbClientIp(request);
9164
- const __httpProtocol = __sbTrustedProxyHeader(request, "x-sb-http-protocol");
9165
- if (__httpProtocol) __cf.httpProtocol = __httpProtocol;
9166
- const __tlsVersion = __sbTrustedProxyHeader(request, "x-sb-tls-version");
9167
- if (__tlsVersion) __cf.tlsVersion = __tlsVersion;
9168
- const __tlsCipher = __sbTrustedProxyHeader(request, "x-sb-tls-cipher");
9169
- if (__tlsCipher) __cf.tlsCipher = __tlsCipher;
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
- : globalThis.__sbCompat >= "2026-09-28"
9200
- ? receiver.fetch(request, globalThis.env, ctx)
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 = globalThis.__sbCompat >= "2026-09-28"
9233
- ? receiver.scheduled(event, globalThis.env, ctx)
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 = globalThis.__sbCompat >= "2026-09-28"
9362
- ? handlers.queue(batch, globalThis.env, ctx)
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 (globalThis.__sbCompat >= "2026-10-22") __sbLtxConfigure();
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 = globalThis.__sbCompat >= "2026-09-28"
5714
- ? receiver.scheduled(event, globalThis.env, ctx)
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
+ }