@sproutboat/runtime 0.34.1 → 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 +13 -0
- package/package.json +1 -1
- package/src/client-ip.test.js +11 -2
- package/src/ingress-utf8.test.js +1 -1
- package/src/native-fetch-prelude.js +93 -49
- package/src/transport-embedded.js +3 -3
- package/src/wrap.ts +2 -1
- package/test/native-cpu-stamp.ts +129 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
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
|
+
|
|
3
16
|
## 0.34.1
|
|
4
17
|
|
|
5
18
|
### Patch 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/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);
|
|
@@ -5007,7 +5036,7 @@ function __sbRpc(op, extra) {
|
|
|
5007
5036
|
// pages with limit and cursor and returns each key's expiration and metadata. Earlier
|
|
5008
5037
|
// dates keep the synchronous shim below.
|
|
5009
5038
|
function __sbKvStrict() {
|
|
5010
|
-
return
|
|
5039
|
+
return __sbCompatDate() >= "2026-10-25";
|
|
5011
5040
|
}
|
|
5012
5041
|
function __sbKvKey(key, verb) {
|
|
5013
5042
|
const name = String(key);
|
|
@@ -5131,7 +5160,7 @@ function __sbMakeKv(ns) {
|
|
|
5131
5160
|
// are enforced, errors read "D1_ERROR: ...", and a BLOB comes back as an array of
|
|
5132
5161
|
// byte values. Earlier dates keep the old behaviour.
|
|
5133
5162
|
function __sbD1Strict() {
|
|
5134
|
-
return
|
|
5163
|
+
return __sbCompatDate() >= "2026-10-24";
|
|
5135
5164
|
}
|
|
5136
5165
|
function __sbD1Param(value, strict) {
|
|
5137
5166
|
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
|
@@ -5449,7 +5478,7 @@ function __sbR2Multipart(bucket, key, uploadId) {
|
|
|
5449
5478
|
// cursor, groups by delimiter and returns metadata only when `include` asks for it.
|
|
5450
5479
|
// Earlier dates keep the synchronous shim below.
|
|
5451
5480
|
function __sbR2Strict() {
|
|
5452
|
-
return
|
|
5481
|
+
return __sbCompatDate() >= "2026-10-26";
|
|
5453
5482
|
}
|
|
5454
5483
|
function __sbR2Key(key, verb) {
|
|
5455
5484
|
const name = String(key);
|
|
@@ -5790,7 +5819,7 @@ function __sbAssetResponse(path, r) {
|
|
|
5790
5819
|
// The queue name and consumer settings (bindings.queueConfig, from `queues` in
|
|
5791
5820
|
// sproutboat.jsonc) apply at every date: they are opt-in.
|
|
5792
5821
|
function __sbQueuesStrict() {
|
|
5793
|
-
return
|
|
5822
|
+
return __sbCompatDate() >= "2026-10-31";
|
|
5794
5823
|
}
|
|
5795
5824
|
|
|
5796
5825
|
// binding -> { binding, queue, maxBatchSize?, maxBatchTimeout?, maxRetries?, retryDelay?, deadLetterQueue? }
|
|
@@ -6106,7 +6135,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
6106
6135
|
sendBatch(messages, options) {
|
|
6107
6136
|
// From 2026-10-28 sendBatch(messages, { delaySeconds }) delays every message that
|
|
6108
6137
|
// sets none of its own, as on Workers; before, the batch option was ignored.
|
|
6109
|
-
const batchDelay =
|
|
6138
|
+
const batchDelay = __sbCompatDate() >= "2026-10-28" ? (options && options.delaySeconds) || 0 : 0;
|
|
6110
6139
|
const list = [];
|
|
6111
6140
|
for (let j = 0; j < (messages || []).length; j++) {
|
|
6112
6141
|
const m = messages[j];
|
|
@@ -6193,7 +6222,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
6193
6222
|
const binding = bindings.services[i].binding;
|
|
6194
6223
|
const fetcher = {
|
|
6195
6224
|
fetch(input, init) {
|
|
6196
|
-
if (
|
|
6225
|
+
if (__sbCompatDate() >= "2026-10-22") return __sbServiceFetch(binding, input, init);
|
|
6197
6226
|
const parts = __sbFetchParts(input, init);
|
|
6198
6227
|
const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbPartsBody(parts) };
|
|
6199
6228
|
return __sbUpstreamResponse(globalThis.__sbFetchUpstream("service.fetch", msg));
|
|
@@ -6383,7 +6412,7 @@ function __sbHandles(handlers, name) {
|
|
|
6383
6412
|
// Callee: the receiver of one request. A default export that is a class (a
|
|
6384
6413
|
// WorkerEntrypoint) is instantiated per request with its ctx and env, as in workerd.
|
|
6385
6414
|
function __sbReceiver(handlers, ctx) {
|
|
6386
|
-
return __sbIsFn(handlers) ? new handlers(ctx,
|
|
6415
|
+
return __sbIsFn(handlers) ? new handlers(ctx, __sbEnvValue()) : handlers;
|
|
6387
6416
|
}
|
|
6388
6417
|
|
|
6389
6418
|
// Callee: runs RPC method `method` of `receiver` with the arguments in the request body.
|
|
@@ -6412,7 +6441,7 @@ async function __sbRpcDispatch(handlers, receiver, method, request) {
|
|
|
6412
6441
|
// throws), a Request given as init brings its body, GET and HEAD refuse a body, and the
|
|
6413
6442
|
// method and redirect mode are checked. Before it, the parts are read off the input.
|
|
6414
6443
|
function __sbFetchParts(input, init) {
|
|
6415
|
-
if (
|
|
6444
|
+
if (__sbCompatDate() >= "2026-10-23") {
|
|
6416
6445
|
const request = new Request(input, init);
|
|
6417
6446
|
const headers = [];
|
|
6418
6447
|
request.headers.forEach((v, k) => headers.push([k, v]));
|
|
@@ -6488,7 +6517,7 @@ function __sbUpstreamResponse(r) {
|
|
|
6488
6517
|
function __sbStreamsBody(body) {
|
|
6489
6518
|
return (
|
|
6490
6519
|
body instanceof ReadableStream &&
|
|
6491
|
-
|
|
6520
|
+
__sbCompatDate() >= "2026-10-27" &&
|
|
6492
6521
|
globalThis.__sbBodyStreamable != null &&
|
|
6493
6522
|
globalThis.__sbBodyStreamable()
|
|
6494
6523
|
);
|
|
@@ -6615,7 +6644,7 @@ function __sbFollowHop(op, msg, label, mode, signal, hops) {
|
|
|
6615
6644
|
const step = (res) => {
|
|
6616
6645
|
const location = res.headers.get("location");
|
|
6617
6646
|
const redirect = res.status === 301 || res.status === 302 || res.status === 303 || res.status === 307 || res.status === 308;
|
|
6618
|
-
if (!(
|
|
6647
|
+
if (!(__sbCompatDate() >= "2026-10-22") || mode === "manual" || !redirect || location == null) return res;
|
|
6619
6648
|
if (mode === "error") throw new TypeError(label + ": redirect not allowed (redirect: \"error\")");
|
|
6620
6649
|
if (hops >= 20) throw new TypeError(label + ": too many redirects");
|
|
6621
6650
|
const dropped = __sbRawBody(res);
|
|
@@ -6652,9 +6681,9 @@ function __sbFollowHop(op, msg, label, mode, signal, hops) {
|
|
|
6652
6681
|
// (__sbAsyncAtAnyDate). `always` is for RPC, which has no older behaviour to keep.
|
|
6653
6682
|
function __sbUpstream(op, msg, label, signal, always) {
|
|
6654
6683
|
if (signal) signal.throwIfAborted();
|
|
6655
|
-
const promised = always ||
|
|
6684
|
+
const promised = always || __sbCompatDate() >= "2026-10-22" || __sbAsyncAtAnyDate();
|
|
6656
6685
|
if (!__sbHttpAsyncEnabled() || !promised) return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
|
|
6657
|
-
if (
|
|
6686
|
+
if (__sbCompatDate() >= "2026-10-22") return __sbUpstreamStreamed(op, msg, label, signal);
|
|
6658
6687
|
return new Promise((resolve, reject) => {
|
|
6659
6688
|
let onAbort = null;
|
|
6660
6689
|
const settled = () => {
|
|
@@ -6701,7 +6730,7 @@ globalThis.__sbInstallFetch = function () {
|
|
|
6701
6730
|
parts = __sbFetchParts(input, init);
|
|
6702
6731
|
} catch (error) {
|
|
6703
6732
|
// From 2026-10-23 an invalid request rejects, as in workerd; before it, it threw.
|
|
6704
|
-
if (
|
|
6733
|
+
if (__sbCompatDate() >= "2026-10-23") return Promise.reject(error);
|
|
6705
6734
|
throw error;
|
|
6706
6735
|
}
|
|
6707
6736
|
if (__sbWsOutboundOn() && __sbWsIsUpgrade(parts)) return __sbWsFetchUpgrade(parts);
|
|
@@ -6762,7 +6791,7 @@ function __sbMakeDONamespace(binding, className) {
|
|
|
6762
6791
|
let req;
|
|
6763
6792
|
// From 2026-10-23 the object gets the Request Fetch makes of input and init, as in
|
|
6764
6793
|
// workerd: method, headers and body survive, and the input's body is used up.
|
|
6765
|
-
if (
|
|
6794
|
+
if (__sbCompatDate() >= "2026-10-23") {
|
|
6766
6795
|
req = new Request(input, init);
|
|
6767
6796
|
} else if (__sbIsObj(input) && __sbIsStr(input.url) && !init) {
|
|
6768
6797
|
req = input;
|
|
@@ -6814,7 +6843,7 @@ function __sbMakeDONamespace(binding, className) {
|
|
|
6814
6843
|
// The object runs in this sprout on every transport (see above), so an RPC call is a
|
|
6815
6844
|
// direct call; only its storage goes to the broker.
|
|
6816
6845
|
function __sbDOv2() {
|
|
6817
|
-
return
|
|
6846
|
+
return __sbCompatDate() >= "2026-10-29";
|
|
6818
6847
|
}
|
|
6819
6848
|
|
|
6820
6849
|
function __sbDOSha(text) {
|
|
@@ -7060,7 +7089,7 @@ function __sbGetDOInstance(cls, idText, idObj) {
|
|
|
7060
7089
|
// The hibernation API, from 2026-10-30 (where WebSocketPair exists).
|
|
7061
7090
|
const holder = { inst: null, auto: null, cls, id, restored: [] };
|
|
7062
7091
|
if (__sbWsEnabled()) __sbDOWebSocketState(state, holder);
|
|
7063
|
-
inst = new Ctor(state,
|
|
7092
|
+
inst = new Ctor(state, __sbEnvValue());
|
|
7064
7093
|
holder.inst = inst;
|
|
7065
7094
|
inst.__sbTasks = __sbTasks;
|
|
7066
7095
|
inst.__sbGate = gate;
|
|
@@ -7154,7 +7183,7 @@ function __sbDOStorage(cls, id) {
|
|
|
7154
7183
|
// Values written in the new format start with __sbDOTag (no JSON text starts with "#"),
|
|
7155
7184
|
// so either side reads both.
|
|
7156
7185
|
function __sbDOGated() {
|
|
7157
|
-
return
|
|
7186
|
+
return __sbCompatDate() >= "2026-10-28";
|
|
7158
7187
|
}
|
|
7159
7188
|
|
|
7160
7189
|
function __sbDOMakeGate() {
|
|
@@ -8436,7 +8465,7 @@ function __sbWsHostResponse(r) {
|
|
|
8436
8465
|
}
|
|
8437
8466
|
// The compatibility date goes along: a standalone or platform server carries client sockets
|
|
8438
8467
|
// from 2026-11-01 only (sproutd, whose bridge ignores it, from 2026-10-30).
|
|
8439
|
-
const id = Number(__sbWsOp("o", 0,
|
|
8468
|
+
const id = Number(__sbWsOp("o", 0, __sbCompatDate(), 0));
|
|
8440
8469
|
if (!(id > 0)) return new Response("WebSockets are not supported by this host yet", { status: 501 });
|
|
8441
8470
|
ws._host = id;
|
|
8442
8471
|
if (ws._peer !== null && ws._peer._hib !== null && __sbWsHibOn()) __sbWsHibRegister(ws._peer, id);
|
|
@@ -8496,7 +8525,7 @@ function __sbWsPump(ws) {
|
|
|
8496
8525
|
// described at __sbWsOp, ids with the top bit set. Returning that Response from a handler
|
|
8497
8526
|
// relays the socket to the client through a pair.
|
|
8498
8527
|
function __sbWsOutboundOn() {
|
|
8499
|
-
return
|
|
8528
|
+
return __sbCompatDate() >= "2026-11-01";
|
|
8500
8529
|
}
|
|
8501
8530
|
let __sbWsWakeable = -1;
|
|
8502
8531
|
function __sbWsCanWake() {
|
|
@@ -8783,7 +8812,7 @@ function __sbWsProxy(r, upstream) {
|
|
|
8783
8812
|
// their tags and attachments) and the event is dispatched as webSocketMessage/webSocketClose.
|
|
8784
8813
|
let __sbWsHibHost = -1;
|
|
8785
8814
|
function __sbWsHibOn() {
|
|
8786
|
-
if (
|
|
8815
|
+
if (__sbCompatDate() < "2026-11-01" || !__sbDOGated()) return false;
|
|
8787
8816
|
if (__sbWsHibHost < 0 && __sbWsOp("H", 0, "", 0) === "1") __sbWsHibHost = 1;
|
|
8788
8817
|
return __sbWsHibHost === 1;
|
|
8789
8818
|
}
|
|
@@ -8915,7 +8944,9 @@ function __sbWsRestoreSocket(id) {
|
|
|
8915
8944
|
}
|
|
8916
8945
|
|
|
8917
8946
|
function __sbInstallWebSockets() {
|
|
8918
|
-
|
|
8947
|
+
Request._hasWsp = false;
|
|
8948
|
+
if (__sbCompatDate() < "2026-10-30") return;
|
|
8949
|
+
Request._hasWsp = true;
|
|
8919
8950
|
globalThis.WebSocketPair = __SbWebSocketPair;
|
|
8920
8951
|
globalThis.WebSocket = __SbWebSocket;
|
|
8921
8952
|
globalThis.WebSocketRequestResponsePair = __SbWebSocketRequestResponsePair;
|
|
@@ -8978,7 +9009,7 @@ function __sbBindingCallIngress(request) {
|
|
|
8978
9009
|
if (vouched) {
|
|
8979
9010
|
const url = headers.get("x-sb-url");
|
|
8980
9011
|
// The callee sees its caller's URL from compatibility date 2026-10-22, as in workerd.
|
|
8981
|
-
if (url && URL.canParse(url) &&
|
|
9012
|
+
if (url && URL.canParse(url) && __sbCompatDate() >= "2026-10-22") request.url = url;
|
|
8982
9013
|
headers.delete("x-sb-remote-addr");
|
|
8983
9014
|
}
|
|
8984
9015
|
for (const name of ["x-sb-binding-call", "x-sb-url", "x-sb-edge-token"]) {
|
|
@@ -9078,7 +9109,7 @@ function __sbReqBodyOp(op, id, data) {
|
|
|
9078
9109
|
// Turns streaming on in the native server, from 2026-10-27. Called once at startup, after
|
|
9079
9110
|
// the compatibility date is set (__sbInstallFetch).
|
|
9080
9111
|
function __sbIngressStreamingSetup() {
|
|
9081
|
-
if (
|
|
9112
|
+
if (__sbCompatDate() >= "2026-10-27") __sbReqBodyOp("e", 0, "");
|
|
9082
9113
|
}
|
|
9083
9114
|
|
|
9084
9115
|
// request.body for a streamed request: chunks as the server receives them.
|
|
@@ -9155,7 +9186,7 @@ function __sbIngressStream(request, id) {
|
|
|
9155
9186
|
// handing the request to user code. Request.text() in Porffor otherwise treats
|
|
9156
9187
|
// the raw bytes as already-decoded characters and corrupts Unicode/HMAC input.
|
|
9157
9188
|
function __sbDecodeIngress(request) {
|
|
9158
|
-
const streamed =
|
|
9189
|
+
const streamed = __sbCompatDate() >= "2026-10-27" ? Number(__sbReqBodyOp("t", 0, "")) : 0;
|
|
9159
9190
|
if (streamed > 0) {
|
|
9160
9191
|
__sbIngressStream(request, streamed);
|
|
9161
9192
|
return;
|
|
@@ -9164,7 +9195,7 @@ function __sbDecodeIngress(request) {
|
|
|
9164
9195
|
if (raw == null) return;
|
|
9165
9196
|
// A GET or HEAD has no body, as in workerd (from 2026-10-23): request.body is null, and
|
|
9166
9197
|
// new Request(request) copies none. Before it, the body is the empty string.
|
|
9167
|
-
if (raw === "" && (request.method === "GET" || request.method === "HEAD") &&
|
|
9198
|
+
if (raw === "" && (request.method === "GET" || request.method === "HEAD") && __sbCompatDate() >= "2026-10-23") {
|
|
9168
9199
|
request.body = null;
|
|
9169
9200
|
return;
|
|
9170
9201
|
}
|
|
@@ -9181,6 +9212,14 @@ function __sbDecodeIngress(request) {
|
|
|
9181
9212
|
};
|
|
9182
9213
|
}
|
|
9183
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
|
+
|
|
9184
9223
|
globalThis.__sbEntry = function (handlers, request) {
|
|
9185
9224
|
__sbDecodeIngress(request);
|
|
9186
9225
|
const trigger = request.headers.get("x-sb-trigger");
|
|
@@ -9191,13 +9230,18 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
9191
9230
|
// are only set when a trusted front proxy forwarded them; colo, ASN and
|
|
9192
9231
|
// geo fields stay absent everywhere (#128 explicitly rejects faking them).
|
|
9193
9232
|
const __cf = request.cf || {};
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9198
|
-
|
|
9199
|
-
|
|
9200
|
-
|
|
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
|
+
}
|
|
9201
9245
|
request.cf = __cf;
|
|
9202
9246
|
// The edge's headers are gone: from 2026-10-23 the handler cannot change the rest.
|
|
9203
9247
|
__sbFreezeHeaders(request);
|
|
@@ -9227,8 +9271,8 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
9227
9271
|
if (rpc == null && !__sbHandles(handlers, "fetch")) throw new Error("Handler does not export a fetch() function.");
|
|
9228
9272
|
__res = rpc != null
|
|
9229
9273
|
? __sbRpcDispatch(handlers, receiver, rpc, request)
|
|
9230
|
-
:
|
|
9231
|
-
? receiver.fetch(request,
|
|
9274
|
+
: __sbCompatDate() >= "2026-09-28"
|
|
9275
|
+
? receiver.fetch(request, __sbEnvValue(), ctx)
|
|
9232
9276
|
: receiver.fetch(request, ctx);
|
|
9233
9277
|
} catch (error) {
|
|
9234
9278
|
// #179 — a synchronous throw anywhere in the handler must not take the
|
|
@@ -9260,8 +9304,8 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
9260
9304
|
try {
|
|
9261
9305
|
const event = { cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} };
|
|
9262
9306
|
const receiver = __sbReceiver(handlers, ctx);
|
|
9263
|
-
__sres =
|
|
9264
|
-
? receiver.scheduled(event,
|
|
9307
|
+
__sres = __sbCompatDate() >= "2026-09-28"
|
|
9308
|
+
? receiver.scheduled(event, __sbEnvValue(), ctx)
|
|
9265
9309
|
: receiver.scheduled(event, ctx);
|
|
9266
9310
|
} catch (error) {
|
|
9267
9311
|
// #179 — same rule as fetch: don't let a throw here crash the process.
|
|
@@ -9389,8 +9433,8 @@ async function __sbRunQueueBatch(handlers, body, ctx) {
|
|
|
9389
9433
|
};
|
|
9390
9434
|
}
|
|
9391
9435
|
try {
|
|
9392
|
-
const __qres =
|
|
9393
|
-
? handlers.queue(batch,
|
|
9436
|
+
const __qres = __sbCompatDate() >= "2026-09-28"
|
|
9437
|
+
? handlers.queue(batch, __sbEnvValue(), ctx)
|
|
9394
9438
|
: handlers.queue(batch, ctx);
|
|
9395
9439
|
// Was fire-and-forget: an async `queue()` had this default-ack pass run (and
|
|
9396
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
|
+
}
|