@sproutboat/runtime 0.34.1 → 0.34.3
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 +27 -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 +126 -54
- package/src/transport-embedded.js +184 -5
- package/src/wrap.ts +2 -1
- package/test/native-cpu-stamp.ts +129 -0
- package/test/native-d1.ts +97 -3
- package/test/native-json-strings.ts +131 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.34.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 49ac7df: Faster `atob`: each character is looked up in a 128-entry table instead of `indexOf` over the alphabet, and the same pass validates, so the regex test over the whole input is gone. Same output and same `InvalidCharacterError` cases (checked against the platform `atob` on 9,000 random inputs and edge cases).
|
|
8
|
+
|
|
9
|
+
A JWT check decodes three base64url parts. On one pinned x86 core at a fixed rate, CPU per request: like 854 to 761 us, create 922 to 866 us; feed and post reads do not call `atob`.
|
|
10
|
+
- 745f94c: Faster D1 in standalone builds: a D1 call no longer goes through `JSON.stringify` and `JSON.parse` on both sides of the in-process dispatch. The request object is handed straight to the dispatcher and the reply object comes straight back (the C layer still returns its own JSON, parsed once). Same results, same error messages, no change under the broker transport.
|
|
11
|
+
|
|
12
|
+
On one pinned x86 core at a fixed rate, CPU per request against a 500,000-row feed API: feed (20 rows) 1,418 to 1,133 us, post read 394 to 332 us, like 854 to 728 us, create 922 to 857 us, the realistic mix 670 to 580 us.
|
|
13
|
+
- 88c7bff: Faster strict D1 queries in standalone builds (compatibility date 2026-10-24 and later): the rows of a result are built as JS arrays in C, straight from the SQLite cells, instead of being written as JSON text in C and parsed again in JS. NULL, numbers, text (UTF-8 decoded exactly as the JSON path decoded it, including invalid sequences), BLOBs (byte arrays) and empty results are unchanged. Only a small header (columns, changes, last row id) still goes through JSON. Older compatibility dates and non-D1 operations keep the JSON path.
|
|
14
|
+
|
|
15
|
+
On one pinned x86 core at a fixed rate, CPU per request against a 500,000-row feed API, see the PR for numbers.
|
|
16
|
+
|
|
17
|
+
## 0.34.2
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- a0ba6a3: Faster requests: about 57% of a request's CPU was Porffor's `globalThis` getter.
|
|
22
|
+
|
|
23
|
+
- **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.
|
|
24
|
+
- **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.
|
|
25
|
+
- **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.
|
|
26
|
+
- **`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.
|
|
27
|
+
|
|
28
|
+
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.
|
|
29
|
+
|
|
3
30
|
## 0.34.1
|
|
4
31
|
|
|
5
32
|
### 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
|
|
|
@@ -3610,16 +3639,24 @@ if (globalThis.crypto.getRandomValues == null) {
|
|
|
3610
3639
|
// so a string held as UTF-16 (one from JSON.parse or TextDecoder, say) decoded to the
|
|
3611
3640
|
// wrong bytes, and btoa encoded its UTF-16 units.
|
|
3612
3641
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
3642
|
+
// Sextet per character code (255 = not base64): a table read rather than an indexOf over the
|
|
3643
|
+
// alphabet, and one pass that also validates, so no regex test over the whole input.
|
|
3644
|
+
const sextet = [];
|
|
3645
|
+
for (let i = 0; i < 128; i++) sextet.push(255);
|
|
3646
|
+
for (let i = 0; i < alphabet.length; i++) sextet[alphabet.charCodeAt(i)] = i;
|
|
3613
3647
|
globalThis.atob = function (data) {
|
|
3614
3648
|
let text = String(data).replace(/[\t\n\f\r ]/g, "");
|
|
3615
3649
|
if (text.length % 4 === 0) text = text.replace(/==?$/, "");
|
|
3616
|
-
if (text.length % 4 === 1
|
|
3650
|
+
if (text.length % 4 === 1) throw invalidCharacter("atob: the string is not valid base64");
|
|
3617
3651
|
const parts = [];
|
|
3618
3652
|
let chunk = "";
|
|
3619
3653
|
let bits = 0;
|
|
3620
3654
|
let count = 0;
|
|
3621
3655
|
for (let i = 0; i < text.length; i++) {
|
|
3622
|
-
|
|
3656
|
+
const code = text.charCodeAt(i);
|
|
3657
|
+
const value = code < 128 ? sextet[code] : 255;
|
|
3658
|
+
if (value === 255) throw invalidCharacter("atob: the string is not valid base64");
|
|
3659
|
+
bits = (bits << 6) | value;
|
|
3623
3660
|
count += 6;
|
|
3624
3661
|
if (count >= 8) {
|
|
3625
3662
|
count -= 8;
|
|
@@ -3752,7 +3789,7 @@ if (typeof globalThis.EventTarget !== "function") {
|
|
|
3752
3789
|
if (this.aborted) return;
|
|
3753
3790
|
this.aborted = true;
|
|
3754
3791
|
// workerd's default reason reads "The operation was aborted" (from 2026-10-23 here).
|
|
3755
|
-
const message =
|
|
3792
|
+
const message = __sbCompatDate() >= "2026-10-23" ? "The operation was aborted" : "This operation was aborted";
|
|
3756
3793
|
this.reason = reason === undefined ? namedError("AbortError", message) : reason;
|
|
3757
3794
|
const event = new Event("abort");
|
|
3758
3795
|
if (typeof this.onabort === "function") this.onabort.call(this, event);
|
|
@@ -5007,7 +5044,7 @@ function __sbRpc(op, extra) {
|
|
|
5007
5044
|
// pages with limit and cursor and returns each key's expiration and metadata. Earlier
|
|
5008
5045
|
// dates keep the synchronous shim below.
|
|
5009
5046
|
function __sbKvStrict() {
|
|
5010
|
-
return
|
|
5047
|
+
return __sbCompatDate() >= "2026-10-25";
|
|
5011
5048
|
}
|
|
5012
5049
|
function __sbKvKey(key, verb) {
|
|
5013
5050
|
const name = String(key);
|
|
@@ -5131,7 +5168,7 @@ function __sbMakeKv(ns) {
|
|
|
5131
5168
|
// are enforced, errors read "D1_ERROR: ...", and a BLOB comes back as an array of
|
|
5132
5169
|
// byte values. Earlier dates keep the old behaviour.
|
|
5133
5170
|
function __sbD1Strict() {
|
|
5134
|
-
return
|
|
5171
|
+
return __sbCompatDate() >= "2026-10-24";
|
|
5135
5172
|
}
|
|
5136
5173
|
function __sbD1Param(value, strict) {
|
|
5137
5174
|
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
|
@@ -5144,11 +5181,30 @@ function __sbD1Param(value, strict) {
|
|
|
5144
5181
|
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- workerd's D1_TYPE_ERROR names the value's typeof
|
|
5145
5182
|
throw new Error("D1_TYPE_ERROR: Type '" + typeof value + "' not supported for value '" + String(value) + "'");
|
|
5146
5183
|
}
|
|
5184
|
+
// An embedded (standalone) build sets this to hand the request object straight to its
|
|
5185
|
+
// dispatcher. D1 replies are the biggest ones a handler sees (20 rows is 100 cells), and
|
|
5186
|
+
// the JSON.stringify and JSON.parse around the in-process call cost more than SQLite did.
|
|
5187
|
+
// The broker transport leaves it unset and keeps the string hop. (No initializer: the transport is placed ahead of the prelude, and `= null` would run after its assignment.)
|
|
5188
|
+
// oxlint-disable-next-line no-unassigned-vars -- the embedded transport assigns it; the broker transport does not.
|
|
5189
|
+
var __sbCallDirect;
|
|
5190
|
+
function __sbRpcDirect(op, extra) {
|
|
5191
|
+
if (!__sbCallDirect) return __sbRpc(op, extra);
|
|
5192
|
+
const req = { v: 1, id: ++__sbReqId, op };
|
|
5193
|
+
if (extra) for (const k in extra) req[k] = extra[k];
|
|
5194
|
+
let reply;
|
|
5195
|
+
try {
|
|
5196
|
+
reply = __sbCallDirect(req);
|
|
5197
|
+
} catch (err) {
|
|
5198
|
+
throw new Error(`sproutboat ${op}: ${String((err && err.message) || err) || "failed"}`);
|
|
5199
|
+
}
|
|
5200
|
+
if (reply && reply.ok === false) throw new Error(`sproutboat ${op}: ${reply.error || "failed"}`);
|
|
5201
|
+
return reply;
|
|
5202
|
+
}
|
|
5147
5203
|
function __sbD1Rpc(op, extra, strict) {
|
|
5148
|
-
if (!strict) return
|
|
5204
|
+
if (!strict) return __sbRpcDirect(op, extra);
|
|
5149
5205
|
extra.strict = true;
|
|
5150
5206
|
try {
|
|
5151
|
-
return
|
|
5207
|
+
return __sbRpcDirect(op, extra);
|
|
5152
5208
|
} catch (err) {
|
|
5153
5209
|
// "sproutboat d1.query: D1_ERROR: ..." -> "D1_ERROR: ...", as workerd words it.
|
|
5154
5210
|
const message = String((err && err.message) || err);
|
|
@@ -5211,7 +5267,8 @@ function __sbD1Meta(meta, started) {
|
|
|
5211
5267
|
}
|
|
5212
5268
|
// Columns and rows of a strict reply (an older broker answers with objects only).
|
|
5213
5269
|
function __sbD1Table(r) {
|
|
5214
|
-
|
|
5270
|
+
// rows built in C (r.native) already hold BLOBs as byte arrays
|
|
5271
|
+
if (r.columns) return { columns: r.columns, rows: r.native === true ? r.rows : __sbD1Rows(r) };
|
|
5215
5272
|
const objects = r.results || [];
|
|
5216
5273
|
const columns = objects.length ? Object.keys(objects[0]) : [];
|
|
5217
5274
|
const rows = [];
|
|
@@ -5449,7 +5506,7 @@ function __sbR2Multipart(bucket, key, uploadId) {
|
|
|
5449
5506
|
// cursor, groups by delimiter and returns metadata only when `include` asks for it.
|
|
5450
5507
|
// Earlier dates keep the synchronous shim below.
|
|
5451
5508
|
function __sbR2Strict() {
|
|
5452
|
-
return
|
|
5509
|
+
return __sbCompatDate() >= "2026-10-26";
|
|
5453
5510
|
}
|
|
5454
5511
|
function __sbR2Key(key, verb) {
|
|
5455
5512
|
const name = String(key);
|
|
@@ -5790,7 +5847,7 @@ function __sbAssetResponse(path, r) {
|
|
|
5790
5847
|
// The queue name and consumer settings (bindings.queueConfig, from `queues` in
|
|
5791
5848
|
// sproutboat.jsonc) apply at every date: they are opt-in.
|
|
5792
5849
|
function __sbQueuesStrict() {
|
|
5793
|
-
return
|
|
5850
|
+
return __sbCompatDate() >= "2026-10-31";
|
|
5794
5851
|
}
|
|
5795
5852
|
|
|
5796
5853
|
// binding -> { binding, queue, maxBatchSize?, maxBatchTimeout?, maxRetries?, retryDelay?, deadLetterQueue? }
|
|
@@ -6106,7 +6163,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
6106
6163
|
sendBatch(messages, options) {
|
|
6107
6164
|
// From 2026-10-28 sendBatch(messages, { delaySeconds }) delays every message that
|
|
6108
6165
|
// sets none of its own, as on Workers; before, the batch option was ignored.
|
|
6109
|
-
const batchDelay =
|
|
6166
|
+
const batchDelay = __sbCompatDate() >= "2026-10-28" ? (options && options.delaySeconds) || 0 : 0;
|
|
6110
6167
|
const list = [];
|
|
6111
6168
|
for (let j = 0; j < (messages || []).length; j++) {
|
|
6112
6169
|
const m = messages[j];
|
|
@@ -6193,7 +6250,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
6193
6250
|
const binding = bindings.services[i].binding;
|
|
6194
6251
|
const fetcher = {
|
|
6195
6252
|
fetch(input, init) {
|
|
6196
|
-
if (
|
|
6253
|
+
if (__sbCompatDate() >= "2026-10-22") return __sbServiceFetch(binding, input, init);
|
|
6197
6254
|
const parts = __sbFetchParts(input, init);
|
|
6198
6255
|
const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbPartsBody(parts) };
|
|
6199
6256
|
return __sbUpstreamResponse(globalThis.__sbFetchUpstream("service.fetch", msg));
|
|
@@ -6383,7 +6440,7 @@ function __sbHandles(handlers, name) {
|
|
|
6383
6440
|
// Callee: the receiver of one request. A default export that is a class (a
|
|
6384
6441
|
// WorkerEntrypoint) is instantiated per request with its ctx and env, as in workerd.
|
|
6385
6442
|
function __sbReceiver(handlers, ctx) {
|
|
6386
|
-
return __sbIsFn(handlers) ? new handlers(ctx,
|
|
6443
|
+
return __sbIsFn(handlers) ? new handlers(ctx, __sbEnvValue()) : handlers;
|
|
6387
6444
|
}
|
|
6388
6445
|
|
|
6389
6446
|
// Callee: runs RPC method `method` of `receiver` with the arguments in the request body.
|
|
@@ -6412,7 +6469,7 @@ async function __sbRpcDispatch(handlers, receiver, method, request) {
|
|
|
6412
6469
|
// throws), a Request given as init brings its body, GET and HEAD refuse a body, and the
|
|
6413
6470
|
// method and redirect mode are checked. Before it, the parts are read off the input.
|
|
6414
6471
|
function __sbFetchParts(input, init) {
|
|
6415
|
-
if (
|
|
6472
|
+
if (__sbCompatDate() >= "2026-10-23") {
|
|
6416
6473
|
const request = new Request(input, init);
|
|
6417
6474
|
const headers = [];
|
|
6418
6475
|
request.headers.forEach((v, k) => headers.push([k, v]));
|
|
@@ -6488,7 +6545,7 @@ function __sbUpstreamResponse(r) {
|
|
|
6488
6545
|
function __sbStreamsBody(body) {
|
|
6489
6546
|
return (
|
|
6490
6547
|
body instanceof ReadableStream &&
|
|
6491
|
-
|
|
6548
|
+
__sbCompatDate() >= "2026-10-27" &&
|
|
6492
6549
|
globalThis.__sbBodyStreamable != null &&
|
|
6493
6550
|
globalThis.__sbBodyStreamable()
|
|
6494
6551
|
);
|
|
@@ -6615,7 +6672,7 @@ function __sbFollowHop(op, msg, label, mode, signal, hops) {
|
|
|
6615
6672
|
const step = (res) => {
|
|
6616
6673
|
const location = res.headers.get("location");
|
|
6617
6674
|
const redirect = res.status === 301 || res.status === 302 || res.status === 303 || res.status === 307 || res.status === 308;
|
|
6618
|
-
if (!(
|
|
6675
|
+
if (!(__sbCompatDate() >= "2026-10-22") || mode === "manual" || !redirect || location == null) return res;
|
|
6619
6676
|
if (mode === "error") throw new TypeError(label + ": redirect not allowed (redirect: \"error\")");
|
|
6620
6677
|
if (hops >= 20) throw new TypeError(label + ": too many redirects");
|
|
6621
6678
|
const dropped = __sbRawBody(res);
|
|
@@ -6652,9 +6709,9 @@ function __sbFollowHop(op, msg, label, mode, signal, hops) {
|
|
|
6652
6709
|
// (__sbAsyncAtAnyDate). `always` is for RPC, which has no older behaviour to keep.
|
|
6653
6710
|
function __sbUpstream(op, msg, label, signal, always) {
|
|
6654
6711
|
if (signal) signal.throwIfAborted();
|
|
6655
|
-
const promised = always ||
|
|
6712
|
+
const promised = always || __sbCompatDate() >= "2026-10-22" || __sbAsyncAtAnyDate();
|
|
6656
6713
|
if (!__sbHttpAsyncEnabled() || !promised) return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
|
|
6657
|
-
if (
|
|
6714
|
+
if (__sbCompatDate() >= "2026-10-22") return __sbUpstreamStreamed(op, msg, label, signal);
|
|
6658
6715
|
return new Promise((resolve, reject) => {
|
|
6659
6716
|
let onAbort = null;
|
|
6660
6717
|
const settled = () => {
|
|
@@ -6701,7 +6758,7 @@ globalThis.__sbInstallFetch = function () {
|
|
|
6701
6758
|
parts = __sbFetchParts(input, init);
|
|
6702
6759
|
} catch (error) {
|
|
6703
6760
|
// From 2026-10-23 an invalid request rejects, as in workerd; before it, it threw.
|
|
6704
|
-
if (
|
|
6761
|
+
if (__sbCompatDate() >= "2026-10-23") return Promise.reject(error);
|
|
6705
6762
|
throw error;
|
|
6706
6763
|
}
|
|
6707
6764
|
if (__sbWsOutboundOn() && __sbWsIsUpgrade(parts)) return __sbWsFetchUpgrade(parts);
|
|
@@ -6762,7 +6819,7 @@ function __sbMakeDONamespace(binding, className) {
|
|
|
6762
6819
|
let req;
|
|
6763
6820
|
// From 2026-10-23 the object gets the Request Fetch makes of input and init, as in
|
|
6764
6821
|
// workerd: method, headers and body survive, and the input's body is used up.
|
|
6765
|
-
if (
|
|
6822
|
+
if (__sbCompatDate() >= "2026-10-23") {
|
|
6766
6823
|
req = new Request(input, init);
|
|
6767
6824
|
} else if (__sbIsObj(input) && __sbIsStr(input.url) && !init) {
|
|
6768
6825
|
req = input;
|
|
@@ -6814,7 +6871,7 @@ function __sbMakeDONamespace(binding, className) {
|
|
|
6814
6871
|
// The object runs in this sprout on every transport (see above), so an RPC call is a
|
|
6815
6872
|
// direct call; only its storage goes to the broker.
|
|
6816
6873
|
function __sbDOv2() {
|
|
6817
|
-
return
|
|
6874
|
+
return __sbCompatDate() >= "2026-10-29";
|
|
6818
6875
|
}
|
|
6819
6876
|
|
|
6820
6877
|
function __sbDOSha(text) {
|
|
@@ -7060,7 +7117,7 @@ function __sbGetDOInstance(cls, idText, idObj) {
|
|
|
7060
7117
|
// The hibernation API, from 2026-10-30 (where WebSocketPair exists).
|
|
7061
7118
|
const holder = { inst: null, auto: null, cls, id, restored: [] };
|
|
7062
7119
|
if (__sbWsEnabled()) __sbDOWebSocketState(state, holder);
|
|
7063
|
-
inst = new Ctor(state,
|
|
7120
|
+
inst = new Ctor(state, __sbEnvValue());
|
|
7064
7121
|
holder.inst = inst;
|
|
7065
7122
|
inst.__sbTasks = __sbTasks;
|
|
7066
7123
|
inst.__sbGate = gate;
|
|
@@ -7154,7 +7211,7 @@ function __sbDOStorage(cls, id) {
|
|
|
7154
7211
|
// Values written in the new format start with __sbDOTag (no JSON text starts with "#"),
|
|
7155
7212
|
// so either side reads both.
|
|
7156
7213
|
function __sbDOGated() {
|
|
7157
|
-
return
|
|
7214
|
+
return __sbCompatDate() >= "2026-10-28";
|
|
7158
7215
|
}
|
|
7159
7216
|
|
|
7160
7217
|
function __sbDOMakeGate() {
|
|
@@ -8436,7 +8493,7 @@ function __sbWsHostResponse(r) {
|
|
|
8436
8493
|
}
|
|
8437
8494
|
// The compatibility date goes along: a standalone or platform server carries client sockets
|
|
8438
8495
|
// from 2026-11-01 only (sproutd, whose bridge ignores it, from 2026-10-30).
|
|
8439
|
-
const id = Number(__sbWsOp("o", 0,
|
|
8496
|
+
const id = Number(__sbWsOp("o", 0, __sbCompatDate(), 0));
|
|
8440
8497
|
if (!(id > 0)) return new Response("WebSockets are not supported by this host yet", { status: 501 });
|
|
8441
8498
|
ws._host = id;
|
|
8442
8499
|
if (ws._peer !== null && ws._peer._hib !== null && __sbWsHibOn()) __sbWsHibRegister(ws._peer, id);
|
|
@@ -8496,7 +8553,7 @@ function __sbWsPump(ws) {
|
|
|
8496
8553
|
// described at __sbWsOp, ids with the top bit set. Returning that Response from a handler
|
|
8497
8554
|
// relays the socket to the client through a pair.
|
|
8498
8555
|
function __sbWsOutboundOn() {
|
|
8499
|
-
return
|
|
8556
|
+
return __sbCompatDate() >= "2026-11-01";
|
|
8500
8557
|
}
|
|
8501
8558
|
let __sbWsWakeable = -1;
|
|
8502
8559
|
function __sbWsCanWake() {
|
|
@@ -8783,7 +8840,7 @@ function __sbWsProxy(r, upstream) {
|
|
|
8783
8840
|
// their tags and attachments) and the event is dispatched as webSocketMessage/webSocketClose.
|
|
8784
8841
|
let __sbWsHibHost = -1;
|
|
8785
8842
|
function __sbWsHibOn() {
|
|
8786
|
-
if (
|
|
8843
|
+
if (__sbCompatDate() < "2026-11-01" || !__sbDOGated()) return false;
|
|
8787
8844
|
if (__sbWsHibHost < 0 && __sbWsOp("H", 0, "", 0) === "1") __sbWsHibHost = 1;
|
|
8788
8845
|
return __sbWsHibHost === 1;
|
|
8789
8846
|
}
|
|
@@ -8915,7 +8972,9 @@ function __sbWsRestoreSocket(id) {
|
|
|
8915
8972
|
}
|
|
8916
8973
|
|
|
8917
8974
|
function __sbInstallWebSockets() {
|
|
8918
|
-
|
|
8975
|
+
Request._hasWsp = false;
|
|
8976
|
+
if (__sbCompatDate() < "2026-10-30") return;
|
|
8977
|
+
Request._hasWsp = true;
|
|
8919
8978
|
globalThis.WebSocketPair = __SbWebSocketPair;
|
|
8920
8979
|
globalThis.WebSocket = __SbWebSocket;
|
|
8921
8980
|
globalThis.WebSocketRequestResponsePair = __SbWebSocketRequestResponsePair;
|
|
@@ -8978,7 +9037,7 @@ function __sbBindingCallIngress(request) {
|
|
|
8978
9037
|
if (vouched) {
|
|
8979
9038
|
const url = headers.get("x-sb-url");
|
|
8980
9039
|
// The callee sees its caller's URL from compatibility date 2026-10-22, as in workerd.
|
|
8981
|
-
if (url && URL.canParse(url) &&
|
|
9040
|
+
if (url && URL.canParse(url) && __sbCompatDate() >= "2026-10-22") request.url = url;
|
|
8982
9041
|
headers.delete("x-sb-remote-addr");
|
|
8983
9042
|
}
|
|
8984
9043
|
for (const name of ["x-sb-binding-call", "x-sb-url", "x-sb-edge-token"]) {
|
|
@@ -9078,7 +9137,7 @@ function __sbReqBodyOp(op, id, data) {
|
|
|
9078
9137
|
// Turns streaming on in the native server, from 2026-10-27. Called once at startup, after
|
|
9079
9138
|
// the compatibility date is set (__sbInstallFetch).
|
|
9080
9139
|
function __sbIngressStreamingSetup() {
|
|
9081
|
-
if (
|
|
9140
|
+
if (__sbCompatDate() >= "2026-10-27") __sbReqBodyOp("e", 0, "");
|
|
9082
9141
|
}
|
|
9083
9142
|
|
|
9084
9143
|
// request.body for a streamed request: chunks as the server receives them.
|
|
@@ -9155,7 +9214,7 @@ function __sbIngressStream(request, id) {
|
|
|
9155
9214
|
// handing the request to user code. Request.text() in Porffor otherwise treats
|
|
9156
9215
|
// the raw bytes as already-decoded characters and corrupts Unicode/HMAC input.
|
|
9157
9216
|
function __sbDecodeIngress(request) {
|
|
9158
|
-
const streamed =
|
|
9217
|
+
const streamed = __sbCompatDate() >= "2026-10-27" ? Number(__sbReqBodyOp("t", 0, "")) : 0;
|
|
9159
9218
|
if (streamed > 0) {
|
|
9160
9219
|
__sbIngressStream(request, streamed);
|
|
9161
9220
|
return;
|
|
@@ -9164,7 +9223,7 @@ function __sbDecodeIngress(request) {
|
|
|
9164
9223
|
if (raw == null) return;
|
|
9165
9224
|
// A GET or HEAD has no body, as in workerd (from 2026-10-23): request.body is null, and
|
|
9166
9225
|
// new Request(request) copies none. Before it, the body is the empty string.
|
|
9167
|
-
if (raw === "" && (request.method === "GET" || request.method === "HEAD") &&
|
|
9226
|
+
if (raw === "" && (request.method === "GET" || request.method === "HEAD") && __sbCompatDate() >= "2026-10-23") {
|
|
9168
9227
|
request.body = null;
|
|
9169
9228
|
return;
|
|
9170
9229
|
}
|
|
@@ -9181,6 +9240,14 @@ function __sbDecodeIngress(request) {
|
|
|
9181
9240
|
};
|
|
9182
9241
|
}
|
|
9183
9242
|
|
|
9243
|
+
// SB_TRUSTED_PROXIES is fixed for the life of a process (a sproutd worker gets its own after the fork,
|
|
9244
|
+
// and this runs per request, never before it), so it is read and split once, not four times a request.
|
|
9245
|
+
let __sbTrustedCache;
|
|
9246
|
+
function __sbTrustedProxiesOnce() {
|
|
9247
|
+
if (__sbTrustedCache === undefined) __sbTrustedCache = __sbSplitList(__sbEnv("SB_TRUSTED_PROXIES"));
|
|
9248
|
+
return __sbTrustedCache;
|
|
9249
|
+
}
|
|
9250
|
+
|
|
9184
9251
|
globalThis.__sbEntry = function (handlers, request) {
|
|
9185
9252
|
__sbDecodeIngress(request);
|
|
9186
9253
|
const trigger = request.headers.get("x-sb-trigger");
|
|
@@ -9191,13 +9258,18 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
9191
9258
|
// are only set when a trusted front proxy forwarded them; colo, ASN and
|
|
9192
9259
|
// geo fields stay absent everywhere (#128 explicitly rejects faking them).
|
|
9193
9260
|
const __cf = request.cf || {};
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9198
|
-
|
|
9199
|
-
|
|
9200
|
-
|
|
9261
|
+
if (__sbTrustedProxiesOnce().length === 0) {
|
|
9262
|
+
// No trusted proxy (the usual case): the peer is the client and no forwarded header counts.
|
|
9263
|
+
__cf.clientIp = __sbNormalizeIp(request.headers.get("x-sb-remote-addr") || "");
|
|
9264
|
+
} else {
|
|
9265
|
+
__cf.clientIp = __sbClientIp(request);
|
|
9266
|
+
const __httpProtocol = __sbTrustedProxyHeader(request, "x-sb-http-protocol");
|
|
9267
|
+
if (__httpProtocol) __cf.httpProtocol = __httpProtocol;
|
|
9268
|
+
const __tlsVersion = __sbTrustedProxyHeader(request, "x-sb-tls-version");
|
|
9269
|
+
if (__tlsVersion) __cf.tlsVersion = __tlsVersion;
|
|
9270
|
+
const __tlsCipher = __sbTrustedProxyHeader(request, "x-sb-tls-cipher");
|
|
9271
|
+
if (__tlsCipher) __cf.tlsCipher = __tlsCipher;
|
|
9272
|
+
}
|
|
9201
9273
|
request.cf = __cf;
|
|
9202
9274
|
// The edge's headers are gone: from 2026-10-23 the handler cannot change the rest.
|
|
9203
9275
|
__sbFreezeHeaders(request);
|
|
@@ -9227,8 +9299,8 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
9227
9299
|
if (rpc == null && !__sbHandles(handlers, "fetch")) throw new Error("Handler does not export a fetch() function.");
|
|
9228
9300
|
__res = rpc != null
|
|
9229
9301
|
? __sbRpcDispatch(handlers, receiver, rpc, request)
|
|
9230
|
-
:
|
|
9231
|
-
? receiver.fetch(request,
|
|
9302
|
+
: __sbCompatDate() >= "2026-09-28"
|
|
9303
|
+
? receiver.fetch(request, __sbEnvValue(), ctx)
|
|
9232
9304
|
: receiver.fetch(request, ctx);
|
|
9233
9305
|
} catch (error) {
|
|
9234
9306
|
// #179 — a synchronous throw anywhere in the handler must not take the
|
|
@@ -9260,8 +9332,8 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
9260
9332
|
try {
|
|
9261
9333
|
const event = { cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} };
|
|
9262
9334
|
const receiver = __sbReceiver(handlers, ctx);
|
|
9263
|
-
__sres =
|
|
9264
|
-
? receiver.scheduled(event,
|
|
9335
|
+
__sres = __sbCompatDate() >= "2026-09-28"
|
|
9336
|
+
? receiver.scheduled(event, __sbEnvValue(), ctx)
|
|
9265
9337
|
: receiver.scheduled(event, ctx);
|
|
9266
9338
|
} catch (error) {
|
|
9267
9339
|
// #179 — same rule as fetch: don't let a throw here crash the process.
|
|
@@ -9389,8 +9461,8 @@ async function __sbRunQueueBatch(handlers, body, ctx) {
|
|
|
9389
9461
|
};
|
|
9390
9462
|
}
|
|
9391
9463
|
try {
|
|
9392
|
-
const __qres =
|
|
9393
|
-
? handlers.queue(batch,
|
|
9464
|
+
const __qres = __sbCompatDate() >= "2026-09-28"
|
|
9465
|
+
? handlers.queue(batch, __sbEnvValue(), ctx)
|
|
9394
9466
|
: handlers.queue(batch, ctx);
|
|
9395
9467
|
// Was fire-and-forget: an async `queue()` had this default-ack pass run (and
|
|
9396
9468
|
// the response go out) before its own `await`s did, so any ack()/retry()
|
|
@@ -1352,6 +1352,43 @@ static void sb_putjson(sb_buf* b, const char* s, size_t n) {
|
|
|
1352
1352
|
// doesn't start a valid sequence passes through as \u00XX, the same rule as
|
|
1353
1353
|
// the prelude's __sbFromUtf8. Raw byte producers (the HTTP client's body,
|
|
1354
1354
|
// BLOB columns) keep sb_putjson.
|
|
1355
|
+
// The UTF-16 units sb_putjson_text would write as u-escapes, for a TEXT cell handed over as
|
|
1356
|
+
// a JS string: same validity rules (an invalid lead byte is its own unit, an overlong form, a
|
|
1357
|
+
// surrogate or a value above U+10FFFF is not decoded). OUT has room for n units. *wide is set
|
|
1358
|
+
// when a unit is above 0xff.
|
|
1359
|
+
static size_t sb_text_units(const unsigned char* s, size_t n, uint16_t* out, int* wide) {
|
|
1360
|
+
size_t k = 0;
|
|
1361
|
+
*wide = 0;
|
|
1362
|
+
for (size_t i = 0; i < n; i++) {
|
|
1363
|
+
unsigned char c = s[i];
|
|
1364
|
+
if (c < 0x80) { out[k++] = c; continue; }
|
|
1365
|
+
unsigned int cp = 0, min = 0;
|
|
1366
|
+
size_t len = 0;
|
|
1367
|
+
if ((c & 0xe0) == 0xc0) { cp = c & 0x1f; len = 2; min = 0x80; }
|
|
1368
|
+
else if ((c & 0xf0) == 0xe0) { cp = c & 0x0f; len = 3; min = 0x800; }
|
|
1369
|
+
else if ((c & 0xf8) == 0xf0) { cp = c & 0x07; len = 4; min = 0x10000; }
|
|
1370
|
+
int ok = len > 0 && i + len <= n;
|
|
1371
|
+
for (size_t j = 1; ok && j < len; j++) {
|
|
1372
|
+
unsigned char cc = s[i + j];
|
|
1373
|
+
if ((cc & 0xc0) != 0x80) ok = 0;
|
|
1374
|
+
else cp = (cp << 6) | (cc & 0x3f);
|
|
1375
|
+
}
|
|
1376
|
+
if (ok && (cp < min || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff))) ok = 0;
|
|
1377
|
+
if (!ok) { out[k++] = c; if (c > 0xff) *wide = 1; continue; }
|
|
1378
|
+
if (cp >= 0x10000) {
|
|
1379
|
+
cp -= 0x10000;
|
|
1380
|
+
out[k++] = (uint16_t)(0xd800 + (cp >> 10));
|
|
1381
|
+
out[k++] = (uint16_t)(0xdc00 + (cp & 0x3ff));
|
|
1382
|
+
*wide = 1;
|
|
1383
|
+
} else {
|
|
1384
|
+
out[k++] = (uint16_t)cp;
|
|
1385
|
+
if (cp > 0xff) *wide = 1;
|
|
1386
|
+
}
|
|
1387
|
+
i += len - 1;
|
|
1388
|
+
}
|
|
1389
|
+
return k;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1355
1392
|
static void sb_putjson_text(sb_buf* b, const char* s, size_t n) {
|
|
1356
1393
|
sb_put(b, "\"", 1);
|
|
1357
1394
|
for (size_t i = 0; i < n; i++) {
|
|
@@ -2122,7 +2159,29 @@ static char* sb_sql_error(sqlite3* db, const char* message, int code, int skip)
|
|
|
2122
2159
|
// {"ok":true,"cols":[...],"rows":[[...]],"changes":n,"rowid":n}
|
|
2123
2160
|
// or {"ok":false,"error":"...","code":n,"offset":n}. In a D1 database (a path under
|
|
2124
2161
|
// /d1/) a BLOB comes back as {"$blob":"HEX"}; elsewhere as one char per byte.
|
|
2125
|
-
|
|
2162
|
+
// A strict D1 query can hand its rows back as cells instead of JSON text: the caller turns
|
|
2163
|
+
// them into JS arrays, with no JSON written here and none parsed there. t is 0 NULL,
|
|
2164
|
+
// 1 number, 2 TEXT (UTF-8 in p), 3 BLOB (bytes in p).
|
|
2165
|
+
typedef struct { unsigned char t; double d; char* p; int n; } sb_cell;
|
|
2166
|
+
typedef struct { sb_cell* c; size_t n; size_t cap; int ncol; int used; } sb_cells;
|
|
2167
|
+
static void sb_cells_free(sb_cells* cs) {
|
|
2168
|
+
if (!cs) return;
|
|
2169
|
+
for (size_t i = 0; i < cs->n; i++) free(cs->c[i].p);
|
|
2170
|
+
free(cs->c);
|
|
2171
|
+
cs->c = 0; cs->n = cs->cap = 0;
|
|
2172
|
+
}
|
|
2173
|
+
static sb_cell* sb_cells_add(sb_cells* cs) {
|
|
2174
|
+
if (cs->n == cs->cap) {
|
|
2175
|
+
size_t cap = cs->cap ? cs->cap * 2 : 64;
|
|
2176
|
+
sb_cell* grown = (sb_cell*)realloc(cs->c, cap * sizeof(sb_cell));
|
|
2177
|
+
if (!grown) return 0;
|
|
2178
|
+
cs->c = grown; cs->cap = cap;
|
|
2179
|
+
}
|
|
2180
|
+
sb_cell* cell = &cs->c[cs->n++];
|
|
2181
|
+
cell->t = 0; cell->d = 0; cell->p = 0; cell->n = 0;
|
|
2182
|
+
return cell;
|
|
2183
|
+
}
|
|
2184
|
+
static char* sb_sql_run_x(const char* path, const char* sql, const char* params, sb_cells* cells) {
|
|
2126
2185
|
sb_buf b = { 0, 0, 0 };
|
|
2127
2186
|
int idx = sb_db_for(path);
|
|
2128
2187
|
if (idx < 0 && sb_db_capture_failed(path)) return sb_sql_error(0, SB_LTX_FAILED, 10, 0);
|
|
@@ -2151,8 +2210,30 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
|
|
|
2151
2210
|
sb_putjson_text(&b, name ? name : "", name ? strlen(name) : 0);
|
|
2152
2211
|
}
|
|
2153
2212
|
sb_puts(&b, "],\"rows\":[");
|
|
2213
|
+
// Cells only for a strict D1 statement: its BLOBs are byte arrays either way.
|
|
2214
|
+
const int native = cells && strict && d1;
|
|
2215
|
+
if (native) { cells->ncol = ncol; cells->used = 1; }
|
|
2154
2216
|
int rc, first = 1;
|
|
2155
2217
|
while ((rc = sqlite3_step(st)) == SB_SQLITE_ROW) {
|
|
2218
|
+
if (native) {
|
|
2219
|
+
for (int i = 0; i < ncol; i++) {
|
|
2220
|
+
sb_cell* cell = sb_cells_add(cells);
|
|
2221
|
+
if (!cell) { cells->used = -1; break; }
|
|
2222
|
+
int t = sqlite3_column_type(st, i);
|
|
2223
|
+
if (t == 5) continue;
|
|
2224
|
+
if (t == 1 || t == 2) { cell->t = 1; cell->d = sqlite3_column_double(st, i); continue; }
|
|
2225
|
+
const unsigned char* txt = t == 4 ? (const unsigned char*)sqlite3_column_blob(st, i) : sqlite3_column_text(st, i);
|
|
2226
|
+
int n = sqlite3_column_bytes(st, i);
|
|
2227
|
+
cell->t = t == 4 ? 3 : 2;
|
|
2228
|
+
cell->n = txt ? n : 0;
|
|
2229
|
+
cell->p = (char*)malloc((size_t)cell->n + 1);
|
|
2230
|
+
if (!cell->p) { cells->used = -1; break; }
|
|
2231
|
+
if (cell->n) memcpy(cell->p, txt, (size_t)cell->n);
|
|
2232
|
+
cell->p[cell->n] = 0;
|
|
2233
|
+
}
|
|
2234
|
+
if (cells->used < 0) break;
|
|
2235
|
+
continue;
|
|
2236
|
+
}
|
|
2156
2237
|
if (!first) sb_puts(&b, ",");
|
|
2157
2238
|
first = 0;
|
|
2158
2239
|
sb_puts(&b, "[");
|
|
@@ -2188,6 +2269,7 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
|
|
|
2188
2269
|
// Reset, don't finalize: the statement stays compiled in sb_stmt_cache. Reset
|
|
2189
2270
|
// now (not just on next reuse) so a SELECT stops holding its read transaction.
|
|
2190
2271
|
sqlite3_reset(st);
|
|
2272
|
+
if (cells && cells->used < 0) { free(b.p); sb_cells_free(cells); return sb_sql_error(db, "out of memory", 7, 0); }
|
|
2191
2273
|
// The statement's own error, kept before LTX capture runs: capture executes statements
|
|
2192
2274
|
// of its own, after which SQLite reports "not an error".
|
|
2193
2275
|
char failure[512] = { 0 };
|
|
@@ -2200,11 +2282,15 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
|
|
|
2200
2282
|
if (capture_failed) rc = 10;
|
|
2201
2283
|
if (rc != SB_SQLITE_DONE && rc != SB_SQLITE_ROW) {
|
|
2202
2284
|
free(b.p);
|
|
2285
|
+
if (cells) sb_cells_free(cells);
|
|
2203
2286
|
if (capture_failed) return sb_sql_error(db, SB_LTX_FAILED, 10, 0);
|
|
2204
2287
|
return sb_sql_error(db, failure, failure_code, 0);
|
|
2205
2288
|
}
|
|
2206
2289
|
return b.p;
|
|
2207
2290
|
}
|
|
2291
|
+
static char* sb_sql_run(const char* path, const char* sql, const char* params) {
|
|
2292
|
+
return sb_sql_run_x(path, sql, params, 0);
|
|
2293
|
+
}
|
|
2208
2294
|
|
|
2209
2295
|
// The storage quota (docs: an app's data directory is capped): bytes in regular files under
|
|
2210
2296
|
// dir, symlinks not followed. Bounded by depth; an unreadable entry counts as 0.
|
|
@@ -4131,6 +4217,85 @@ function __sbSqlRaw(path, sql, paramsJson) {
|
|
|
4131
4217
|
return res;
|
|
4132
4218
|
}
|
|
4133
4219
|
|
|
4220
|
+
// One strict D1 statement: [header JSON, rows]. The header is {"ok":true,"cols":[...],"changes":n,
|
|
4221
|
+
// "rowid":n} (or the error reply) and rows is an array of row arrays built here from the
|
|
4222
|
+
// cells, BLOBs as byte arrays, so no JSON for the rows is written or parsed. rows is
|
|
4223
|
+
// undefined when the reply is not a result.
|
|
4224
|
+
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
4225
|
+
function __sbSqlRowsRaw(path, sql, paramsJson) {
|
|
4226
|
+
let res = "";
|
|
4227
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
4228
|
+
Porffor.c`
|
|
4229
|
+
const char* __p; size_t __pl; char* __po = 0;
|
|
4230
|
+
porf_native_fetch_read_value(path, &__p, &__pl, &__po);
|
|
4231
|
+
char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
|
|
4232
|
+
if (__po) free(__po);
|
|
4233
|
+
|
|
4234
|
+
const char* __s; size_t __sl; char* __so = 0;
|
|
4235
|
+
porf_native_fetch_read_value(sql, &__s, &__sl, &__so);
|
|
4236
|
+
char* __sql = (char*)malloc(__sl + 1); memcpy(__sql, __s, __sl); __sql[__sl] = 0;
|
|
4237
|
+
if (__so) free(__so);
|
|
4238
|
+
|
|
4239
|
+
const char* __a; size_t __al; char* __ao = 0;
|
|
4240
|
+
porf_native_fetch_read_value(paramsJson, &__a, &__al, &__ao);
|
|
4241
|
+
char* __args = (char*)malloc(__al + 1); memcpy(__args, __a, __al); __args[__al] = 0;
|
|
4242
|
+
if (__ao) free(__ao);
|
|
4243
|
+
|
|
4244
|
+
sb_cells __cells = { 0, 0, 0, 0, 0 };
|
|
4245
|
+
char* __out = sb_sql_run_x(__path, __sql, __args, &__cells);
|
|
4246
|
+
free(__path); free(__sql); free(__args);
|
|
4247
|
+
const u32 __pair = porf_arr_new(2, 2);
|
|
4248
|
+
res = porf_box((f64)__pair, 72);
|
|
4249
|
+
if (!__out) __out = strdup("{\"ok\":false,\"error\":\"no result\"}");
|
|
4250
|
+
porf_arr_set(__pair, 0, porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195));
|
|
4251
|
+
free(__out);
|
|
4252
|
+
if (__cells.used == 1) {
|
|
4253
|
+
const size_t __ncol = (size_t)__cells.ncol;
|
|
4254
|
+
const size_t __nrow = __ncol ? __cells.n / __ncol : 0;
|
|
4255
|
+
const u32 __rows = porf_arr_new(0, (i32)(__nrow ? __nrow : 1));
|
|
4256
|
+
porf_arr_set(__pair, 1, porf_box((f64)__rows, 72));
|
|
4257
|
+
for (size_t __r = 0; __r < __nrow; __r++) {
|
|
4258
|
+
const u32 __row = porf_arr_new((i32)__ncol, (i32)__ncol);
|
|
4259
|
+
porf_arr_set(__rows, (u32)__r, porf_box((f64)__row, 72));
|
|
4260
|
+
for (size_t __c = 0; __c < __ncol; __c++) {
|
|
4261
|
+
const sb_cell* __cell = &__cells.c[__r * __ncol + __c];
|
|
4262
|
+
jsval __v = porf_box(0.0, 7);
|
|
4263
|
+
if (__cell->t == 1) __v = porf_box_num(__cell->d);
|
|
4264
|
+
else if (__cell->t == 2) {
|
|
4265
|
+
const size_t __n = (size_t)__cell->n;
|
|
4266
|
+
uint16_t* __u = (uint16_t*)malloc(__n * 2 + 2);
|
|
4267
|
+
if (__u) {
|
|
4268
|
+
int __wide = 0;
|
|
4269
|
+
const size_t __k = sb_text_units((const unsigned char*)__cell->p, __n, __u, &__wide);
|
|
4270
|
+
if (__wide) {
|
|
4271
|
+
const u32 __str = porf_native_fetch_alloc((u32)(4 + __k * 2), 67);
|
|
4272
|
+
*(u32*)(MEM + __str) = (u32)__k;
|
|
4273
|
+
memcpy(MEM + __str + 4, __u, __k * 2);
|
|
4274
|
+
__v = porf_box((f64)__str, 67);
|
|
4275
|
+
} else {
|
|
4276
|
+
unsigned char* __b = (unsigned char*)malloc(__k + 1);
|
|
4277
|
+
if (__b) {
|
|
4278
|
+
for (size_t __i = 0; __i < __k; __i++) __b[__i] = (unsigned char)__u[__i];
|
|
4279
|
+
__v = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__b, __k), 195);
|
|
4280
|
+
free(__b);
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
4283
|
+
free(__u);
|
|
4284
|
+
}
|
|
4285
|
+
} else if (__cell->t == 3) {
|
|
4286
|
+
const u32 __bytes = porf_arr_new((i32)__cell->n, (i32)__cell->n);
|
|
4287
|
+
for (int __i = 0; __i < __cell->n; __i++) porf_arr_set(__bytes, (u32)__i, porf_box_num((f64)(unsigned char)__cell->p[__i]));
|
|
4288
|
+
__v = porf_box((f64)__bytes, 72);
|
|
4289
|
+
}
|
|
4290
|
+
porf_arr_set(__row, (u32)__c, __v);
|
|
4291
|
+
}
|
|
4292
|
+
}
|
|
4293
|
+
}
|
|
4294
|
+
sb_cells_free(&__cells);
|
|
4295
|
+
`;
|
|
4296
|
+
return res;
|
|
4297
|
+
}
|
|
4298
|
+
|
|
4134
4299
|
// --- the op dispatch, in JS -------------------------------------------------
|
|
4135
4300
|
// Deliberately the same SQL and the same partition keys as broker.ts. When one
|
|
4136
4301
|
// changes the other has to, and the conformance suite is what says so.
|
|
@@ -4621,7 +4786,7 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
4621
4786
|
__sbD1Quota(path);
|
|
4622
4787
|
if (op === "d1.batch") return { ok: true, results: __sbD1Batch(path, msg.statements || [], strict) };
|
|
4623
4788
|
const one = __sbD1Run(path, msg.sql, msg.params, strict);
|
|
4624
|
-
return { ok: true, results: one.results, columns: one.columns, rows: one.rows, meta: one.meta, success: true };
|
|
4789
|
+
return { ok: true, results: one.results, columns: one.columns, rows: one.rows, native: one.native, meta: one.meta, success: true };
|
|
4625
4790
|
}
|
|
4626
4791
|
|
|
4627
4792
|
// Durable Objects storage.sql (compatibility date 2026-10-29): one database per object,
|
|
@@ -5168,6 +5333,14 @@ function __sbD1Bytes(cell) {
|
|
|
5168
5333
|
return out;
|
|
5169
5334
|
}
|
|
5170
5335
|
function __sbD1Run(path, sql, params, strict) {
|
|
5336
|
+
if (strict) {
|
|
5337
|
+
// Rows come back as arrays, built in C (see __sbSqlRowsRaw); the header is small JSON.
|
|
5338
|
+
const pair = __sbSqlRowsRaw(path, "/*sb-d1-strict*/" + String(sql), JSON.stringify(params || []));
|
|
5339
|
+
const head = JSON.parse(pair[0]);
|
|
5340
|
+
if (head.ok === false) throw __sbD1Error(head);
|
|
5341
|
+
const rows = pair[1];
|
|
5342
|
+
return { columns: head.cols, rows, native: true, meta: { changes: head.changes, last_row_id: head.rowid, rows_read: rows.length } };
|
|
5343
|
+
}
|
|
5171
5344
|
const r = JSON.parse(__sbSqlRaw(path, (strict ? "/*sb-d1-strict*/" : "") + String(sql), JSON.stringify(params || [])));
|
|
5172
5345
|
if (r.ok === false) throw strict ? __sbD1Error(r) : new Error("sqlite: " + r.error);
|
|
5173
5346
|
const meta = { changes: r.changes, last_row_id: r.rowid, rows_read: r.rows.length };
|
|
@@ -5323,7 +5496,7 @@ function __sbLtxConfigure() {
|
|
|
5323
5496
|
}
|
|
5324
5497
|
|
|
5325
5498
|
function __sbCall(reqJson) {
|
|
5326
|
-
if (
|
|
5499
|
+
if (__sbCompatDate() >= "2026-10-22") __sbLtxConfigure();
|
|
5327
5500
|
try {
|
|
5328
5501
|
return JSON.stringify(__sbEmbeddedDispatch(JSON.parse(reqJson)));
|
|
5329
5502
|
} catch (err) {
|
|
@@ -5331,6 +5504,12 @@ function __sbCall(reqJson) {
|
|
|
5331
5504
|
}
|
|
5332
5505
|
}
|
|
5333
5506
|
|
|
5507
|
+
// The same dispatch without the JSON text either side: D1 calls take this (see __sbRpcDirect).
|
|
5508
|
+
__sbCallDirect = function (req) {
|
|
5509
|
+
if (__sbCompatDate() >= "2026-10-22") __sbLtxConfigure();
|
|
5510
|
+
return __sbEmbeddedDispatch(req);
|
|
5511
|
+
};
|
|
5512
|
+
|
|
5334
5513
|
// --- local triggers ---------------------------------------------------------
|
|
5335
5514
|
// A deployed sprout is driven by the broker: it POSTs x-sb-trigger for cron
|
|
5336
5515
|
// ticks, queue batches and DO alarms. An embedded binary has no broker, so the
|
|
@@ -5710,8 +5889,8 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
|
|
|
5710
5889
|
try {
|
|
5711
5890
|
const receiver = __sbReceiver(handlers, ctx);
|
|
5712
5891
|
const event = { cron: crons[i], scheduledTime: now.getTime(), noRetry() {} };
|
|
5713
|
-
const res =
|
|
5714
|
-
? receiver.scheduled(event,
|
|
5892
|
+
const res = __sbCompatDate() >= "2026-09-28"
|
|
5893
|
+
? receiver.scheduled(event, __sbEnvValue(), ctx)
|
|
5715
5894
|
: receiver.scheduled(event, ctx);
|
|
5716
5895
|
if (res && __sbIsFn(res.then)) await res;
|
|
5717
5896
|
} 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
|
+
}
|
package/test/native-d1.ts
CHANGED
|
@@ -51,6 +51,33 @@ int main(void) {
|
|
|
51
51
|
char* params = strchr(sql, '\\t'); if (!params) return 2; *params++ = 0;
|
|
52
52
|
if (!strncmp(line, ":db ", 4)) { int r = sql[0] == 'p' ? sb_db_page_size(line + 4) : sb_db_quota_freeze(line + 4, sql[0] == 'c' ? -1 : sql[0] == 'f'); printf("{\\"n\\":%d}\\n", r); fflush(stdout); continue; }
|
|
53
53
|
if (!strncmp(line, ":bytes ", 7)) { line[strcspn(line, "\\t")] = 0; printf("{\\"n\\":%lld}\\n", sb_dir_bytes(line + 7, 0)); fflush(stdout); continue; }
|
|
54
|
+
// ":cells PATH": the same statement through sb_sql_run_x with cells, printed as JSON the test
|
|
55
|
+
// can compare with sb_sql_run's rows: TEXT as \\uXXXX units (sb_text_units), BLOB as {"$blob":hex}.
|
|
56
|
+
if (!strncmp(line, ":cells ", 7)) {
|
|
57
|
+
sb_cells cs = { 0, 0, 0, 0, 0 };
|
|
58
|
+
char* head = sb_sql_run_x(line + 7, sql, params, &cs);
|
|
59
|
+
printf("{\\"head\\":%s,\\"rows\\":[", head);
|
|
60
|
+
size_t nrow = cs.ncol ? cs.n / (size_t)cs.ncol : 0;
|
|
61
|
+
for (size_t r = 0; r < nrow; r++) {
|
|
62
|
+
printf(r ? ",[" : "[");
|
|
63
|
+
for (int c = 0; c < cs.ncol; c++) {
|
|
64
|
+
sb_cell* x = &cs.c[r * (size_t)cs.ncol + (size_t)c];
|
|
65
|
+
if (c) printf(",");
|
|
66
|
+
if (x->t == 0) printf("null");
|
|
67
|
+
else if (x->t == 1) printf("%.17g", x->d);
|
|
68
|
+
else if (x->t == 2) {
|
|
69
|
+
uint16_t* u = (uint16_t*)malloc((size_t)x->n * 2 + 2); int wide = 0;
|
|
70
|
+
size_t k = sb_text_units((const unsigned char*)x->p, (size_t)x->n, u, &wide);
|
|
71
|
+
printf("\\"");
|
|
72
|
+
for (size_t i = 0; i < k; i++) printf("\\\\u%04x", u[i]);
|
|
73
|
+
printf("\\"");
|
|
74
|
+
free(u);
|
|
75
|
+
} else { printf("{\\"$blob\\":\\""); for (int i = 0; i < x->n; i++) printf("%02x", (unsigned char)x->p[i]); printf("\\"}"); }
|
|
76
|
+
}
|
|
77
|
+
printf("]");
|
|
78
|
+
}
|
|
79
|
+
printf("]}\\n"); fflush(stdout); sb_cells_free(&cs); free(head); continue;
|
|
80
|
+
}
|
|
54
81
|
// ":script PATH" runs SQL as a script (sb_sql_script, what exec() uses).
|
|
55
82
|
char* out = !strncmp(line, ":script ", 8) ? sb_sql_script(line + 8, sql) : sb_sql_run(line, sql, params); puts(out); fflush(stdout); free(out);
|
|
56
83
|
}
|
|
@@ -91,6 +118,59 @@ int main(void) {
|
|
|
91
118
|
);
|
|
92
119
|
assert.equal(other.rows[0][0], String(other.rows[0][0]), "outside D1 a BLOB is still one char per byte, not hex");
|
|
93
120
|
|
|
121
|
+
// Strict rows handed over as cells (the JS arrays are built from them in C) hold exactly what
|
|
122
|
+
// the JSON rows hold: NULL, integers, floats, ASCII, Latin-1, BMP and astral text, every kind
|
|
123
|
+
// of invalid UTF-8, a BLOB, and no rows at all.
|
|
124
|
+
const cellRun = (sql: string, params: unknown[] = []) => {
|
|
125
|
+
const input = `:cells ${d1}\t/*sb-d1-strict*/${sql}\t${JSON.stringify(params)}\n${d1}\t/*sb-d1-strict*/${sql}\t${JSON.stringify(params)}\n`;
|
|
126
|
+
const out = Bun.spawnSync([binary], { stdin: Buffer.from(input) });
|
|
127
|
+
assert.equal(out.exitCode, 0, out.stderr.toString());
|
|
128
|
+
const [cells, json] = out.stdout
|
|
129
|
+
.toString()
|
|
130
|
+
.trim()
|
|
131
|
+
.split("\n")
|
|
132
|
+
.map((l) => JSON.parse(l));
|
|
133
|
+
return { cells, json };
|
|
134
|
+
};
|
|
135
|
+
const bad = [
|
|
136
|
+
"x'80'",
|
|
137
|
+
"x'c0af'",
|
|
138
|
+
"x'c3'",
|
|
139
|
+
"x'e282'",
|
|
140
|
+
"x'f09f9880'",
|
|
141
|
+
"x'f0'",
|
|
142
|
+
"x'eda080'",
|
|
143
|
+
"x'f4908080'",
|
|
144
|
+
"x'e0809a'",
|
|
145
|
+
"x'41c342'",
|
|
146
|
+
"x'ffff'",
|
|
147
|
+
"x'c3a9e282'",
|
|
148
|
+
];
|
|
149
|
+
const texts = [
|
|
150
|
+
"''",
|
|
151
|
+
"'plain ascii'",
|
|
152
|
+
"'caf\u00e9'",
|
|
153
|
+
"'\u4e1c\u4eac'",
|
|
154
|
+
"'x\u{1F680}y'",
|
|
155
|
+
"'\u00ff\u0100'",
|
|
156
|
+
"char(113, 34, 92, 10, 9, 1, 127)",
|
|
157
|
+
...bad.map((b) => `CAST(${b} AS TEXT)`),
|
|
158
|
+
];
|
|
159
|
+
for (const t of texts) {
|
|
160
|
+
const { cells, json } = cellRun(`SELECT ${t} AS t, 7 AS i, 2.5 AS f, NULL AS n, x'00ff7f' AS b, ${t} || 'z' AS u`);
|
|
161
|
+
assert.equal(cells.head.ok, true);
|
|
162
|
+
assert.deepEqual(cells.rows, json.rows, `cells equal the JSON rows for ${t}`);
|
|
163
|
+
assert.deepEqual(cells.head.cols, json.cols);
|
|
164
|
+
assert.equal(cells.head.changes, json.changes);
|
|
165
|
+
}
|
|
166
|
+
const empty = cellRun("SELECT a FROM t WHERE 0");
|
|
167
|
+
assert.deepEqual(empty.cells.rows, []);
|
|
168
|
+
assert.deepEqual(empty.json.rows, []);
|
|
169
|
+
const wide = cellRun("SELECT * FROM t");
|
|
170
|
+
assert.deepEqual(wide.cells.rows, wide.json.rows, "a table with a BLOB and NULLs");
|
|
171
|
+
const failed = cellRun("SELECT nope FROM nowhere");
|
|
172
|
+
assert.equal(failed.cells.head.ok, false);
|
|
173
|
+
|
|
94
174
|
// An object that is not a blob binds NULL and the parse carries on (a "}" in a string included).
|
|
95
175
|
const [, kept] = run([
|
|
96
176
|
[d1, "INSERT INTO t VALUES (?1, ?2, ?3)", [{ a: "}{" }, 5, "x"]],
|
|
@@ -172,12 +252,23 @@ int main(void) {
|
|
|
172
252
|
};
|
|
173
253
|
// oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
|
|
174
254
|
const scriptRaw = (path: string, text: string) => sqlRaw(":script " + path, text, "[]");
|
|
255
|
+
// __sbSqlRowsRaw as the transport's C block gives it: [header JSON, rows], BLOBs as byte arrays.
|
|
256
|
+
const rowsOf =
|
|
257
|
+
(raw: (path: string, text: string, params: string) => string) => (path: string, text: string, params: string) => {
|
|
258
|
+
const r = JSON.parse(raw(":cells " + path, text, params));
|
|
259
|
+
if (r.head.ok === false) return [JSON.stringify(r.head), undefined];
|
|
260
|
+
const rows = r.rows.map((row: unknown[]) =>
|
|
261
|
+
row.map((c) => (c instanceof Object && "$blob" in c ? [...Buffer.from(String(c.$blob), "hex")] : c)),
|
|
262
|
+
);
|
|
263
|
+
return [JSON.stringify(r.head), rows];
|
|
264
|
+
};
|
|
175
265
|
const ops = new Function(
|
|
176
266
|
"__sbSqlRaw",
|
|
177
267
|
"__sbSql",
|
|
178
268
|
"__sbSqlScriptRaw",
|
|
269
|
+
"__sbSqlRowsRaw",
|
|
179
270
|
`${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch, __sbD1Open, __sbD1ExecLines };`,
|
|
180
|
-
)(sqlRaw, sql, scriptRaw);
|
|
271
|
+
)(sqlRaw, sql, scriptRaw, rowsOf(sqlRaw));
|
|
181
272
|
// The storage quota, lifted from "The storage quota" to __sbStore, for DIR and LIMIT.
|
|
182
273
|
const quotaStart = transport.indexOf("// The storage quota: an app's data directory");
|
|
183
274
|
const quotaEnd = transport.indexOf("function __sbStore() {");
|
|
@@ -233,8 +324,9 @@ int main(void) {
|
|
|
233
324
|
"__sbSqlRaw",
|
|
234
325
|
"__sbSql",
|
|
235
326
|
"__sbSqlScriptRaw",
|
|
327
|
+
"__sbSqlRowsRaw",
|
|
236
328
|
`${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch, __sbD1Open, __sbD1ExecLines };`,
|
|
237
|
-
)(raw, run, (path: string, text: string) => raw(":script " + path, text, "[]"));
|
|
329
|
+
)(raw, run, (path: string, text: string) => raw(":script " + path, text, "[]"), rowsOf(raw));
|
|
238
330
|
const lift = (dir: string, limit: number) =>
|
|
239
331
|
// oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
|
|
240
332
|
new Function(
|
|
@@ -312,7 +404,7 @@ int main(void) {
|
|
|
312
404
|
"D1_ERROR: FOREIGN KEY constraint failed: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_FOREIGNKEY)",
|
|
313
405
|
);
|
|
314
406
|
const strictRow = ops.__sbD1Run(db2, "SELECT 1 AS a, ?1 AS a", [{ $blob: "0a" }], true);
|
|
315
|
-
assert.deepEqual([strictRow.columns, strictRow.rows], [["a", "a"], [[1,
|
|
407
|
+
assert.deepEqual([strictRow.columns, strictRow.rows], [["a", "a"], [[1, [10]]]]);
|
|
316
408
|
assert.throws(
|
|
317
409
|
() => ops.__sbD1Run(db2, "SELEC 1", [], true),
|
|
318
410
|
(e: Error) => e.message === 'D1_ERROR: near "SELEC": syntax error at offset 0: SQLITE_ERROR',
|
|
@@ -413,6 +505,7 @@ int main(void) {
|
|
|
413
505
|
"__sbSqlRaw",
|
|
414
506
|
"__sbSql",
|
|
415
507
|
"__sbSqlScriptRaw",
|
|
508
|
+
"__sbSqlRowsRaw",
|
|
416
509
|
`${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch };`,
|
|
417
510
|
)(
|
|
418
511
|
(path: string, text: string, params: string) => {
|
|
@@ -421,6 +514,7 @@ int main(void) {
|
|
|
421
514
|
},
|
|
422
515
|
sql,
|
|
423
516
|
scriptRaw,
|
|
517
|
+
rowsOf(sqlRaw),
|
|
424
518
|
);
|
|
425
519
|
const LTX_FAILED =
|
|
426
520
|
"D1_ERROR: LTX capture failed: the transaction may be committed locally but is not durable in the replication log; writes are refused until restart: SQLITE_IOERR";
|
|
@@ -0,0 +1,131 @@
|
|
|
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
|
+
// JSON.stringify of strings as the spec defines it (Bun is the reference): quotes and backslash,
|
|
9
|
+
// every control character, U+2028 and U+2029 (not escaped), DEL, Latin-1 and wider text, astral
|
|
10
|
+
// characters, and lone or misordered surrogates (escaped as \\udXXX). Each input is built from
|
|
11
|
+
// code units in the handler, so the compiled string has whatever layout concatenation gives it.
|
|
12
|
+
const units = (s: string) => Array.from({ length: s.length }, (_, i) => s.charCodeAt(i));
|
|
13
|
+
const everyControl = Array.from({ length: 0x20 }, (_, i) => String.fromCharCode(i)).join("");
|
|
14
|
+
const INPUTS: string[] = [
|
|
15
|
+
"",
|
|
16
|
+
"abc",
|
|
17
|
+
'a"b\\c"',
|
|
18
|
+
everyControl,
|
|
19
|
+
"x" + everyControl + "y",
|
|
20
|
+
"\u007f\u0080\u00ff",
|
|
21
|
+
"caf\u00e9",
|
|
22
|
+
"line\u2028sep\u2029end",
|
|
23
|
+
"\u0100",
|
|
24
|
+
"\u4e1c\u4eac",
|
|
25
|
+
"x\u{1F680}y",
|
|
26
|
+
"\u{1F680}".repeat(40),
|
|
27
|
+
"\ud800",
|
|
28
|
+
"\udc00",
|
|
29
|
+
"\ud800x",
|
|
30
|
+
"x\udc00",
|
|
31
|
+
"\udc00\ud800",
|
|
32
|
+
"\ud800\ud800\udc00",
|
|
33
|
+
"a\ud83dz",
|
|
34
|
+
"\ud83d\ude80\ud83d",
|
|
35
|
+
"tab\there\nnewline\r\f\b",
|
|
36
|
+
"plain ascii text that is long enough to run the loop for a while. ".repeat(60),
|
|
37
|
+
'mix \u00e9\u4e1c\u{1F680}"\\\n\ud800 '.repeat(50),
|
|
38
|
+
];
|
|
39
|
+
const handler = `export default {fetch() {
|
|
40
|
+
const inputs = ${JSON.stringify(INPUTS.map(units))};
|
|
41
|
+
const out = [];
|
|
42
|
+
for (let n = 0; n < inputs.length; n++) {
|
|
43
|
+
let s = "";
|
|
44
|
+
for (let i = 0; i < inputs[n].length; i++) s += String.fromCharCode(inputs[n][i]);
|
|
45
|
+
out.push(JSON.stringify(s));
|
|
46
|
+
out.push(JSON.stringify({ [s]: s, list: [s, 1, null], nested: { k: s } }));
|
|
47
|
+
}
|
|
48
|
+
return new Response(out.join("\\u0001"));
|
|
49
|
+
}};`;
|
|
50
|
+
const want = INPUTS.flatMap((s) => [
|
|
51
|
+
JSON.stringify(s),
|
|
52
|
+
JSON.stringify({ [s]: s, list: [s, 1, null], nested: { k: s } }),
|
|
53
|
+
]);
|
|
54
|
+
function freePort(): number {
|
|
55
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
56
|
+
const port = listener.port;
|
|
57
|
+
listener.stop(true);
|
|
58
|
+
return port;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-json-strings-"));
|
|
62
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
63
|
+
try {
|
|
64
|
+
const porffor = await ensurePorffor();
|
|
65
|
+
const node = Bun.which("node");
|
|
66
|
+
if (!node) throw new Error("native streams test needs node on PATH");
|
|
67
|
+
|
|
68
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
69
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
70
|
+
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
71
|
+
const generated = join(workdir, "json-strings.generated.js");
|
|
72
|
+
const binary = join(workdir, "json-strings-native");
|
|
73
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
|
|
74
|
+
await rm(binary, { force: true });
|
|
75
|
+
|
|
76
|
+
const compile = Bun.spawn(
|
|
77
|
+
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
78
|
+
{
|
|
79
|
+
cwd: workdir,
|
|
80
|
+
env: process.env,
|
|
81
|
+
stdout: "pipe",
|
|
82
|
+
stderr: "pipe",
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
86
|
+
compile.exited,
|
|
87
|
+
new Response(compile.stdout).text(),
|
|
88
|
+
new Response(compile.stderr).text(),
|
|
89
|
+
]);
|
|
90
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
91
|
+
|
|
92
|
+
const port = freePort();
|
|
93
|
+
server = Bun.spawn([binary], {
|
|
94
|
+
cwd: workdir,
|
|
95
|
+
env: { ...process.env, PORT: String(port) },
|
|
96
|
+
stdout: "ignore",
|
|
97
|
+
stderr: "inherit",
|
|
98
|
+
});
|
|
99
|
+
let ready = false;
|
|
100
|
+
for (let attempt = 0; attempt < 50 && !ready; attempt++) {
|
|
101
|
+
try {
|
|
102
|
+
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
|
|
103
|
+
ready = true;
|
|
104
|
+
} catch {
|
|
105
|
+
if (server.exitCode !== null) break;
|
|
106
|
+
await Bun.sleep(100);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
assert(ready, "compiled handler did not start");
|
|
110
|
+
const got = (await (await fetch(`http://127.0.0.1:${port}/`)).text()).split("\u0001");
|
|
111
|
+
assert.equal(got.length, want.length, "one result per case");
|
|
112
|
+
const diffs = want.flatMap((w, i) =>
|
|
113
|
+
got[i] === w
|
|
114
|
+
? []
|
|
115
|
+
: [
|
|
116
|
+
`case ${i >> 1} (${i & 1 ? "object" : "string"})\n want ${w.slice(0, 200)}\n got ${got[i].slice(0, 200)}`,
|
|
117
|
+
],
|
|
118
|
+
);
|
|
119
|
+
assert.deepEqual(
|
|
120
|
+
diffs,
|
|
121
|
+
[],
|
|
122
|
+
`JSON.stringify differs from the spec in ${diffs.length} of ${want.length} cases:\n${diffs.join("\n")}`,
|
|
123
|
+
);
|
|
124
|
+
console.log(`Native JSON.stringify of strings is byte-identical to the spec in all ${want.length} cases`);
|
|
125
|
+
} finally {
|
|
126
|
+
if (server) {
|
|
127
|
+
server.kill(9);
|
|
128
|
+
await server.exited;
|
|
129
|
+
}
|
|
130
|
+
await rm(workdir, { recursive: true, force: true });
|
|
131
|
+
}
|