@sproutboat/runtime 0.34.4 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.35.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7f5e87b: A standalone binary now opens its SQLite databases in `PRAGMA locking_mode=EXCLUSIVE` by default, with the page cache at 2 MB (`cache_size=-2000`, unchanged). The lock is taken once and held, so a transaction no longer takes and releases file locks around itself, and the WAL index lives in heap memory instead of a shared `-shm` file. The cost: no other process can open the database while the binary runs, so the `sqlite3` shell, `sproutboat kv/d1 --local --data-dir` against a running binary, and the several-copies-behind-`SO_REUSEPORT` recipe do not work on a live binary. Set `SB_SQLITE_EXCLUSIVE=0` to opt out and keep shared locking. A database under LTX replication is never opened exclusive.
8
+
9
+ The default belongs to the standalone binary only: the toolchain's native server shim sets `SB_SQLITE_EXCLUSIVE=1` at startup unless the variable is already set. The embedded transport itself reads only `SB_SQLITE_EXCLUSIVE` (`1` is exclusive, anything else is shared), so sproutd and the broker keep shared locking unless an operator sets it.
10
+
11
+ `SB_SQLITE_CACHE_MB=<n>` (1 to 4096) sets each database's page cache in MiB; unset it stays at 2 MB.
12
+
13
+ ### Patch Changes
14
+
15
+ - 2c9d885: A strict D1 query in a standalone binary does less around SQLite. It skips the generic dispatch (a request object, a walk down the op list, a result object), a statement is a class instead of an object with five closures, `raw()` and `first()` no longer build the `meta` they throw away (two clock reads), the transaction-keyword check reads character codes instead of compiling a regular expression on every query, and the column names, change count and row id come back as JavaScript values built in C instead of as JSON text that is written, parsed and discarded. Replies, errors and `meta` are unchanged.
16
+ - 004b8e7: A request's fixed cost in a native sprout drops five items. The CPU stamp and the streamed-body probe come back as numbers instead of formatted and parsed text (a `printf` and a `parseFloat` each, per request). The ingress reads of `x-sb-trigger` and `x-sb-remote-addr` scan the stored header names instead of validating and lowercasing a name for each, and the three `x-sb-` names the runtime strips are looked for in one pass. `SB_EDGE_TOKEN` is read once. `Headers` checks a name and a value in C and only falls back to its regular expression for a name it cannot judge there, so `get`, `has`, `set`, `append` and `new Response(body, { headers })` stop compiling that expression on every call. Behavior is unchanged.
17
+ - 4213ed7: A standalone binary stops mapping and unmapping 88 KiB for every statement that builds a temporary table. SQLite allocates 20 pages in one block for each page cache it creates, and `INSERT ... RETURNING` (and any sort that spills to a temporary table) creates one, so each such statement cost an `mmap` and an `munmap`. The runtime now sets the initial page-cache allocation to 0 before SQLite starts, so a cache takes its pages one at a time as it grows. Query results and the main database's cache size are unchanged.
18
+
19
+ ## 0.34.5
20
+
21
+ ### Patch Changes
22
+
23
+ - e62b600: The client address is folded to plain IPv4 once per distinct address instead of on every request: a request read `x-sb-remote-addr` and, for an IPv4-mapped IPv6 peer, lowercased, split and hex-parsed it each time. The last address and its result are kept; a different address takes the same path as before.
24
+ - 0b12b94: A strict D1 query hands its rows to JavaScript with fewer copies: the text of every cell goes into one arena instead of one `malloc` per cell, and a cell that is plain ASCII becomes its JS string in one copy (it used to go through a UTF-16 buffer, an 8-bit buffer and a third copy, three `malloc`s each). Non-ASCII text still takes the old path, so the strings are unchanged.
25
+
3
26
  ## 0.34.4
4
27
 
5
28
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.34.4",
3
+ "version": "0.35.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -64,7 +64,9 @@ for (const date of ["2026-09-28", "2026-10-24"]) {
64
64
  test(`${date}: batch() is one transaction`, async () => {
65
65
  const DB = fresh(date);
66
66
  await DB.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)");
67
- expect(await failure(() => DB.batch([DB.prepare("INSERT INTO t VALUES (1)"), DB.prepare("INSERT INTO t VALUES (1)")]))).toMatch(/UNIQUE/);
67
+ expect(
68
+ await failure(() => DB.batch([DB.prepare("INSERT INTO t VALUES (1)"), DB.prepare("INSERT INTO t VALUES (1)")])),
69
+ ).toMatch(/UNIQUE/);
68
70
  expect(await DB.prepare("SELECT count(*) AS n FROM t").first("n")).toBe(0);
69
71
  });
70
72
  }
@@ -85,13 +87,23 @@ test("before 2026-10-24: synchronous, lenient, no foreign keys, BLOBs as one cha
85
87
 
86
88
  test("from 2026-10-24: D1's parameter rules", async () => {
87
89
  const DB = fresh("2026-10-24");
88
- expect(caught(() => DB.prepare("SELECT ?1").bind(undefined))).toBe("D1_TYPE_ERROR: Type 'undefined' not supported for value 'undefined'");
89
- expect(caught(() => DB.prepare("SELECT ?1").bind({ a: 1 }))).toBe("D1_TYPE_ERROR: Type 'object' not supported for value '[object Object]'");
90
- expect(caught(() => DB.prepare("SELECT ?1").bind(10n))).toBe("D1_TYPE_ERROR: Type 'bigint' not supported for value '10'");
90
+ expect(caught(() => DB.prepare("SELECT ?1").bind(undefined))).toBe(
91
+ "D1_TYPE_ERROR: Type 'undefined' not supported for value 'undefined'",
92
+ );
93
+ expect(caught(() => DB.prepare("SELECT ?1").bind({ a: 1 }))).toBe(
94
+ "D1_TYPE_ERROR: Type 'object' not supported for value '[object Object]'",
95
+ );
96
+ expect(caught(() => DB.prepare("SELECT ?1").bind(10n))).toBe(
97
+ "D1_TYPE_ERROR: Type 'bigint' not supported for value '10'",
98
+ );
91
99
  expect(await failure(() => DB.prepare("SELECT ?1, ?2").bind(1).all())).toBe(WRONG_COUNT);
92
100
  expect(await failure(() => DB.prepare("SELECT ?1").bind(1, 2).all())).toBe(WRONG_COUNT);
93
101
  expect(await failure(() => DB.prepare("SELECT ?1").all())).toBe(WRONG_COUNT);
94
- expect(await DB.prepare("SELECT ?1 AS a, ?1 AS b, ?2 AS c").bind("x", true).first()).toEqual({ a: "x", b: "x", c: 1 });
102
+ expect(await DB.prepare("SELECT ?1 AS a, ?1 AS b, ?2 AS c").bind("x", true).first()).toEqual({
103
+ a: "x",
104
+ b: "x",
105
+ c: 1,
106
+ });
95
107
  });
96
108
 
97
109
  test("from 2026-10-24: foreign keys, BLOBs as byte arrays, D1's error text", async () => {
@@ -104,18 +116,25 @@ test("from 2026-10-24: foreign keys, BLOBs as byte arrays, D1's error text", asy
104
116
  expect(await failure(() => DB.prepare("INSERT INTO p VALUES (1)").run())).toBe(
105
117
  "D1_ERROR: UNIQUE constraint failed: p.id: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_PRIMARYKEY)",
106
118
  );
107
- expect(await failure(() => DB.prepare("SELECT * FROM nope").all())).toBe("D1_ERROR: no such table: nope: SQLITE_ERROR");
119
+ expect(await failure(() => DB.prepare("SELECT * FROM nope").all())).toBe(
120
+ "D1_ERROR: no such table: nope: SQLITE_ERROR",
121
+ );
108
122
  expect(await DB.prepare("SELECT x'00ff' AS b").first("b")).toEqual([0, 255]);
109
123
  });
110
124
 
111
125
  test("from 2026-10-24: every query method returns a Promise", async () => {
112
126
  const DB = fresh("2026-10-24");
113
127
  const s = DB.prepare("SELECT 41 AS a");
114
- for (const p of [s.all(), s.run(), s.first(), s.raw(), DB.batch([s]), DB.exec("SELECT 1"), DB.dump().catch(() => 0)]) expect(p).toBeInstanceOf(Promise);
128
+ for (const p of [s.all(), s.run(), s.first(), s.raw(), DB.batch([s]), DB.exec("SELECT 1"), DB.dump().catch(() => 0)])
129
+ expect(p).toBeInstanceOf(Promise);
115
130
  expect(await s.first("a").then((a) => a + 1)).toBe(42);
116
131
  expect(await Promise.all([s.first("a"), DB.prepare("SELECT 2 AS a").first("a")])).toEqual([41, 2]);
117
132
  // Errors reject rather than throw: .catch() sees them.
118
- expect(await DB.prepare("SELEC 1").all().catch((e) => e.message)).toMatch(/^D1_ERROR: near "SELEC": syntax error/);
133
+ expect(
134
+ await DB.prepare("SELEC 1")
135
+ .all()
136
+ .catch((e) => e.message),
137
+ ).toMatch(/^D1_ERROR: near "SELEC": syntax error/);
119
138
  });
120
139
 
121
140
  test("from 2026-10-24: results, meta, raw() and first() as in workerd", async () => {
@@ -123,16 +142,42 @@ test("from 2026-10-24: results, meta, raw() and first() as in workerd", async ()
123
142
  await DB.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
124
143
  const inserted = await DB.prepare("INSERT INTO t (name) VALUES (?), (?)").bind("one", "two").run();
125
144
  expect(Object.keys(inserted).sort()).toEqual(["meta", "results", "success"]);
126
- expect(Object.keys(inserted.meta).sort()).toEqual(["changed_db", "changes", "duration", "last_row_id", "rows_read", "rows_written", "served_by", "size_after"]);
127
- expect([inserted.meta.changes, inserted.meta.last_row_id, inserted.meta.changed_db, inserted.meta.rows_written]).toEqual([2, 2, true, 2]);
145
+ expect(Object.keys(inserted.meta).sort()).toEqual([
146
+ "changed_db",
147
+ "changes",
148
+ "duration",
149
+ "last_row_id",
150
+ "rows_read",
151
+ "rows_written",
152
+ "served_by",
153
+ "size_after",
154
+ ]);
155
+ expect([
156
+ inserted.meta.changes,
157
+ inserted.meta.last_row_id,
158
+ inserted.meta.changed_db,
159
+ inserted.meta.rows_written,
160
+ ]).toEqual([2, 2, true, 2]);
128
161
  const read = await DB.prepare("SELECT id, name FROM t ORDER BY id").all();
129
162
  expect([read.meta.changes, read.meta.changed_db, read.meta.rows_written]).toEqual([0, false, 0]);
130
- expect(await DB.prepare("SELECT id, name FROM t ORDER BY id").raw()).toEqual([[1, "one"], [2, "two"]]);
131
- expect(await DB.prepare("SELECT id, name FROM t ORDER BY id").raw({ columnNames: true })).toEqual([["id", "name"], [1, "one"], [2, "two"]]);
163
+ expect(await DB.prepare("SELECT id, name FROM t ORDER BY id").raw()).toEqual([
164
+ [1, "one"],
165
+ [2, "two"],
166
+ ]);
167
+ expect(await DB.prepare("SELECT id, name FROM t ORDER BY id").raw({ columnNames: true })).toEqual([
168
+ ["id", "name"],
169
+ [1, "one"],
170
+ [2, "two"],
171
+ ]);
132
172
  expect(await DB.prepare("SELECT id FROM t WHERE id > 9").raw({ columnNames: true })).toEqual([["id"]]);
133
- expect(await DB.prepare("SELECT 1 AS a, 2 AS a").raw({ columnNames: true })).toEqual([["a", "a"], [1, 2]]);
173
+ expect(await DB.prepare("SELECT 1 AS a, 2 AS a").raw({ columnNames: true })).toEqual([
174
+ ["a", "a"],
175
+ [1, 2],
176
+ ]);
134
177
  expect((await DB.prepare("SELECT 1 AS a, 2 AS a").all()).results).toEqual([{ a: 2 }]);
135
- expect(await failure(() => DB.prepare("SELECT id FROM t").first("nope"))).toBe("D1_COLUMN_NOTFOUND: Column not found (nope)");
178
+ expect(await failure(() => DB.prepare("SELECT id FROM t").first("nope"))).toBe(
179
+ "D1_COLUMN_NOTFOUND: Column not found (nope)",
180
+ );
136
181
  expect(await DB.prepare("SELECT id FROM t WHERE id > 9").first("nope")).toBeNull();
137
182
  });
138
183
 
@@ -140,15 +185,74 @@ test("from 2026-10-24: empty SQL, transactions, exec() and sessions as in worker
140
185
  const DB = fresh("2026-10-24");
141
186
  expect(await failure(() => DB.prepare("").all())).toBe(NO_STATEMENTS);
142
187
  expect(await failure(() => DB.batch([]))).toBe(NO_STATEMENTS);
143
- expect(await failure(() => DB.prepare("BEGIN TRANSACTION").run())).toMatch(/^D1_ERROR: To execute a transaction, please use the state\.storage\.transaction\(\)/);
188
+ expect(await failure(() => DB.prepare("BEGIN TRANSACTION").run())).toMatch(
189
+ /^D1_ERROR: To execute a transaction, please use the state\.storage\.transaction\(\)/,
190
+ );
144
191
  expect(await failure(() => DB.prepare("savepoint x").run())).toMatch(/^D1_ERROR: To execute a transaction/);
145
192
  const done = await DB.exec("CREATE TABLE t (id INTEGER)\nINSERT INTO t VALUES (1);\n\nINSERT INTO t VALUES (2);\n");
146
193
  expect([done.count, Number.isFinite(done.duration)]).toEqual([3, true]);
147
194
  expect(await DB.exec("SELECT 1").then((r) => r.count)).toBe(1);
148
- expect(await failure(() => DB.exec("INSERT INTO t VALUES (3);\nSELEC 2"))).toMatch(/^D1_EXEC_ERROR: Error in line 2: SELEC 2: near "SELEC": syntax error/);
195
+ expect(await failure(() => DB.exec("INSERT INTO t VALUES (3);\nSELEC 2"))).toMatch(
196
+ /^D1_EXEC_ERROR: Error in line 2: SELEC 2: near "SELEC": syntax error/,
197
+ );
149
198
  expect(await DB.prepare("SELECT count(*) AS n FROM t").first("n")).toBe(2);
150
199
  const session = DB.withSession("first-unconstrained");
151
200
  expect(await session.prepare("SELECT count(*) AS n FROM t").first("n")).toBe(2);
152
201
  expect(session.getBookmark()).toBeNull();
153
202
  expect(await session.batch([session.prepare("SELECT 1 AS a")]).then((r) => r[0].results)).toEqual([{ a: 1 }]);
154
203
  });
204
+
205
+ // __sbD1IsTransaction replaced `/^(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)`.
206
+ test("a statement is a transaction keyword exactly as the regular expression it replaced said", () => {
207
+ // oxlint-disable-next-line no-function-constructor -- the prelude is prepended as text in production too
208
+ const { isTx } = new Function("Porffor", `${source}\nreturn { isTx: __sbD1IsTransaction };`)({ c: () => "" });
209
+ const old = (text) => /^(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text);
210
+ const cases = [
211
+ "BEGIN",
212
+ "begin",
213
+ "Begin TRANSACTION",
214
+ "begin;",
215
+ "BEGIN\n",
216
+ "commit",
217
+ "COMMIT;",
218
+ "end",
219
+ "END TRANSACTION",
220
+ "rollback to x",
221
+ "ROLLBACK",
222
+ "savepoint x",
223
+ "SAVEPOINT\tx",
224
+ "release x",
225
+ "RELEASE",
226
+ "beginning",
227
+ "begin_x",
228
+ "begin1",
229
+ "ending",
230
+ "endx",
231
+ "ender",
232
+ "committed",
233
+ "rollbacks",
234
+ "savepoints",
235
+ "released",
236
+ "select 1",
237
+ "SELECT begin FROM t",
238
+ "insert into t values (1)",
239
+ "be",
240
+ "en",
241
+ "e",
242
+ "",
243
+ "BEGINX",
244
+ "begin-",
245
+ "end(",
246
+ "end.",
247
+ "release@",
248
+ "commité",
249
+ "xbegin",
250
+ " begin",
251
+ "savepoint",
252
+ "savepointx",
253
+ "rollback_",
254
+ "ROLLBACKĀ",
255
+ "bégin",
256
+ ];
257
+ for (const text of cases) expect([text, isTx(text)]).toEqual([text, old(text)]);
258
+ });
@@ -3092,18 +3092,34 @@ __sbStartupMark();
3092
3092
  // string via the same primitives as __sbEnv / __sbRandomBytes (proven working),
3093
3093
  // then parsed — Porffor's number boxing for a bare inline-C assignment is not
3094
3094
  // relied on. `__sbEntry` samples it around the handler for per-invocation CPU.
3095
+ // The number comes back as a number: no text and no parse around it.
3095
3096
  function __sbCpuMs() {
3096
- let res = "";
3097
+ let ms = 0;
3097
3098
  // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
3098
3099
  Porffor.c`
3099
3100
  struct timespec __ts;
3100
3101
  clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &__ts);
3101
- double __ms = (double)__ts.tv_sec * 1000.0 + (double)__ts.tv_nsec / 1000000.0;
3102
+ ms = (f64)((double)__ts.tv_sec * 1000.0 + (double)__ts.tv_nsec / 1000000.0);
3103
+ `;
3104
+ return ms;
3105
+ }
3106
+
3107
+ // The `x-sb-cpu-ms` value for the CPU time since `t0`, as three decimals, floored at zero: one
3108
+ // clock read and one format call in C, where it was a read, a format, a parse and a toFixed.
3109
+ // oxlint-disable-next-line no-unused-vars -- `t0` is read inside the RawC block below, not by JS.
3110
+ function __sbCpuStamp(t0) {
3111
+ let res = "0.000";
3112
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
3113
+ Porffor.c`
3114
+ struct timespec __ts;
3115
+ clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &__ts);
3116
+ double __d = ((double)__ts.tv_sec * 1000.0 + (double)__ts.tv_nsec / 1000000.0) - (t0.type == 1 ? (double)t0.val : 0.0);
3117
+ if (!(__d >= 0)) __d = 0;
3102
3118
  char __b[32];
3103
- int __n = snprintf(__b, sizeof(__b), "%.3f", __ms);
3119
+ int __n = snprintf(__b, sizeof(__b), "%.3f", __d);
3104
3120
  if (__n > 0) res = porf_box((f64)porf_native_fetch_alloc_bytestring(__b, (size_t)__n), 195);
3105
3121
  `;
3106
- return res === "" ? 0 : parseFloat(res);
3122
+ return res;
3107
3123
  }
3108
3124
 
3109
3125
  // #28 — stamp `x-sb-cpu-ms` onto a handler Response. Porffor alpha-10's
@@ -3114,7 +3130,6 @@ function __sbCpuMs() {
3114
3130
  // http-sync-v0) with no Set-Cookie to comma-fold; anything else is returned
3115
3131
  // untouched and the edge simply omits cpuMs for that request.
3116
3132
  function __sbTagCpu(res, t0) {
3117
- const cpu = __sbCpuMs() - t0;
3118
3133
  try {
3119
3134
  // A string body and no Set-Cookie, as before. The native writer reads Headers' flat `_entries`,
3120
3135
  // so the stamp is appended to the handler's own Response instead of building a second one (a
@@ -3122,7 +3137,7 @@ function __sbTagCpu(res, t0) {
3122
3137
  // the handler set itself is replaced by the measured one, as before.
3123
3138
  const headers = res && res.headers;
3124
3139
  if (headers && __sbIsStr(__sbRawBody(res)) && !headers.has("set-cookie")) {
3125
- const stamp = (cpu >= 0 ? cpu : 0).toFixed(3);
3140
+ const stamp = __sbCpuStamp(t0);
3126
3141
  const entries = headers._entries;
3127
3142
  let set = false;
3128
3143
  for (let i = 0; i < entries.length; i += 2) {
@@ -5295,6 +5310,11 @@ function __sbD1Param(value, strict) {
5295
5310
  // 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.)
5296
5311
  // oxlint-disable-next-line no-unassigned-vars -- the embedded transport assigns it; the broker transport does not.
5297
5312
  var __sbCallDirect;
5313
+ // The embedded transport also answers a strict D1 query without the generic dispatch around it
5314
+ // (a request object, a walk down the op list, a result object): (db, sql, params) -> the reply
5315
+ // __sbD1Run builds. Unset elsewhere, and the query goes through __sbD1Rpc as before.
5316
+ // oxlint-disable-next-line no-unassigned-vars -- the embedded transport assigns it; the broker transport does not.
5317
+ var __sbD1QueryDirect;
5298
5318
  function __sbRpcDirect(op, extra) {
5299
5319
  if (!__sbCallDirect) return __sbRpc(op, extra);
5300
5320
  const req = { v: 1, id: ++__sbReqId, op };
@@ -5355,10 +5375,30 @@ function __sbD1Fail(message) {
5355
5375
  // transaction statements are refused, exec() runs line by line, and withSession() exists.
5356
5376
  var __sbD1NoTransactions =
5357
5377
  "D1_ERROR: To execute a transaction, please use the state.storage.transaction() or state.storage.transactionSync() APIs instead of the SQL BEGIN TRANSACTION or SAVEPOINT statements. The JavaScript API is safer because it will automatically roll back on exceptions, and because it interacts correctly with Durable Objects' automatic atomic write coalescing.";
5378
+ // A statement's first word is one of the transaction keywords: read by character code, where the
5379
+ // regular expression this replaced was compiled on every query. The word ends at the first character
5380
+ // that is not a letter, digit or underscore (the regular expression's \b after a keyword).
5381
+ function __sbD1IsTransaction(text) {
5382
+ let end = 0;
5383
+ while (end < text.length && end < 10) {
5384
+ const c = text.charCodeAt(end) | 32;
5385
+ if (c < 97 || c > 122) break;
5386
+ end++;
5387
+ }
5388
+ if (end < 3 || end > 9) return false;
5389
+ if (end < text.length) {
5390
+ const c = text.charCodeAt(end);
5391
+ if ((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || c === 95 || (c >= 97 && c <= 122)) return false;
5392
+ }
5393
+ const word = text.slice(0, end).toLowerCase();
5394
+ return (
5395
+ word === "begin" || word === "commit" || word === "end" || word === "rollback" || word === "savepoint" || word === "release"
5396
+ );
5397
+ }
5358
5398
  function __sbD1Check(sql) {
5359
5399
  const text = String(sql).trim();
5360
5400
  if (!text) throw __sbD1Fail("D1_ERROR: No SQL statements detected.");
5361
- if (/^(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) throw __sbD1Fail(__sbD1NoTransactions);
5401
+ if (__sbD1IsTransaction(text)) throw __sbD1Fail(__sbD1NoTransactions);
5362
5402
  }
5363
5403
  function __sbD1Meta(meta, started) {
5364
5404
  const changes = Number(meta.changes) || 0;
@@ -5394,70 +5434,91 @@ function __sbD1Promise(run) {
5394
5434
  return Promise.reject(err);
5395
5435
  }
5396
5436
  }
5397
- function __sbMakeD1(dbName) {
5398
- // The synchronous core: the answer to one statement as columns, rows and meta.
5399
- function query(sql, params, strict) {
5400
- if (strict) __sbD1Check(sql);
5401
- const started = Date.now();
5402
- const r = __sbD1Rpc("d1.query", { db: dbName, sql, params }, strict);
5403
- if (!strict) return { results: r.results || [], success: true, meta: r.meta || {} };
5404
- const table = __sbD1Table(r);
5405
- return { columns: table.columns, rows: table.rows, meta: __sbD1Meta(r.meta || {}, started) };
5406
- }
5407
- function stmt(sql, params) {
5408
- const s = {
5409
- __sql: sql,
5410
- __params: params,
5411
- bind() {
5412
- const strict = __sbD1Strict();
5413
- const list = [];
5414
- for (let i = 0; i < arguments.length; i++) list.push(__sbD1Param(arguments[i], strict));
5415
- return stmt(sql, list);
5416
- },
5417
- all() {
5418
- if (!__sbD1Strict()) return query(sql, params, false);
5419
- return __sbD1Promise(() => {
5420
- const t = query(sql, params, true);
5421
- return { success: true, meta: t.meta, results: __sbD1Objects(t.columns, t.rows) };
5422
- });
5423
- },
5424
- run() {
5425
- return s.all();
5426
- },
5427
- raw(options) {
5428
- if (!__sbD1Strict()) {
5429
- const rows = query(sql, params, false).results;
5430
- const out = [];
5431
- for (let i = 0; i < rows.length; i++) {
5432
- const cols = [];
5433
- for (const k in rows[i]) cols.push(rows[i][k]);
5434
- out.push(cols);
5435
- }
5436
- return out;
5437
- }
5438
- return __sbD1Promise(() => {
5439
- const t = query(sql, params, true);
5440
- return options && options.columnNames ? [t.columns].concat(t.rows) : t.rows;
5441
- });
5442
- },
5443
- first(column) {
5444
- if (!__sbD1Strict()) {
5445
- const rows = query(sql, params, false).results;
5446
- if (rows.length === 0) return null;
5447
- return column == null ? rows[0] : rows[0][column];
5448
- }
5449
- return __sbD1Promise(() => {
5450
- const t = query(sql, params, true);
5451
- if (column == null) return t.rows.length ? __sbD1Objects(t.columns, [t.rows[0]])[0] : null;
5452
- const at = t.columns.indexOf(String(column));
5453
- if (!t.rows.length) return null;
5454
- if (at < 0) throw __sbD1Fail("D1_COLUMN_NOTFOUND: Column not found (" + column + ")");
5455
- return t.rows[0][t.columns.lastIndexOf(String(column))];
5456
- });
5457
- },
5458
- };
5459
- return s;
5437
+ // A strict query's reply from the embedded transport's direct lane, or through the generic
5438
+ // dispatch. The errors read the same either way: "D1_ERROR: ..." for SQLite's, and the
5439
+ // transport's own prefix for anything else.
5440
+ function __sbD1Direct(name, sql, params) {
5441
+ try {
5442
+ return __sbD1QueryDirect(name, sql, params);
5443
+ } catch (err) {
5444
+ const message = "sproutboat d1.query: " + (String((err && err.message) || err) || "failed");
5445
+ const at = message.indexOf("D1_");
5446
+ throw new Error(at > 0 ? message.slice(at) : message);
5460
5447
  }
5448
+ }
5449
+ // The synchronous core: the answer to one statement as columns, rows and meta. meta is only built
5450
+ // when the caller will hand it out (all() and run()): raw() and first() drop it, and building it
5451
+ // reads the clock twice.
5452
+ function __sbD1Query(dbName, sql, params, strict, wantMeta) {
5453
+ if (strict) __sbD1Check(sql);
5454
+ const started = !strict || wantMeta ? Date.now() : 0;
5455
+ const r = strict && __sbD1QueryDirect ? __sbD1Direct(dbName, sql, params) : __sbD1Rpc("d1.query", { db: dbName, sql, params }, strict);
5456
+ if (!strict) return { results: r.results || [], success: true, meta: r.meta || {} };
5457
+ const table = __sbD1Table(r);
5458
+ return { columns: table.columns, rows: table.rows, meta: wantMeta ? __sbD1Meta(r.meta || {}, started) : null };
5459
+ }
5460
+ // A prepared statement. A class, so a prepare() or bind() does not allocate five closures.
5461
+ class __SbD1Statement {
5462
+ constructor(dbName, sql, params) {
5463
+ this.__name = dbName;
5464
+ this.__sql = sql;
5465
+ this.__params = params;
5466
+ }
5467
+ bind() {
5468
+ const strict = __sbD1Strict();
5469
+ const list = [];
5470
+ for (let i = 0; i < arguments.length; i++) list.push(__sbD1Param(arguments[i], strict));
5471
+ return new __SbD1Statement(this.__name, this.__sql, list);
5472
+ }
5473
+ all() {
5474
+ if (!__sbD1Strict()) return __sbD1Query(this.__name, this.__sql, this.__params, false, true);
5475
+ try {
5476
+ const t = __sbD1Query(this.__name, this.__sql, this.__params, true, true);
5477
+ return Promise.resolve({ success: true, meta: t.meta, results: __sbD1Objects(t.columns, t.rows) });
5478
+ } catch (err) {
5479
+ return Promise.reject(err);
5480
+ }
5481
+ }
5482
+ run() {
5483
+ return this.all();
5484
+ }
5485
+ raw(options) {
5486
+ if (!__sbD1Strict()) {
5487
+ const rows = __sbD1Query(this.__name, this.__sql, this.__params, false, true).results;
5488
+ const out = [];
5489
+ for (let i = 0; i < rows.length; i++) {
5490
+ const cols = [];
5491
+ for (const k in rows[i]) cols.push(rows[i][k]);
5492
+ out.push(cols);
5493
+ }
5494
+ return out;
5495
+ }
5496
+ try {
5497
+ const t = __sbD1Query(this.__name, this.__sql, this.__params, true, false);
5498
+ return Promise.resolve(options && options.columnNames ? [t.columns].concat(t.rows) : t.rows);
5499
+ } catch (err) {
5500
+ return Promise.reject(err);
5501
+ }
5502
+ }
5503
+ first(column) {
5504
+ if (!__sbD1Strict()) {
5505
+ const rows = __sbD1Query(this.__name, this.__sql, this.__params, false, true).results;
5506
+ if (rows.length === 0) return null;
5507
+ return column == null ? rows[0] : rows[0][column];
5508
+ }
5509
+ try {
5510
+ const t = __sbD1Query(this.__name, this.__sql, this.__params, true, false);
5511
+ if (column == null) return Promise.resolve(t.rows.length ? __sbD1Objects(t.columns, [t.rows[0]])[0] : null);
5512
+ const at = t.columns.indexOf(String(column));
5513
+ if (!t.rows.length) return Promise.resolve(null);
5514
+ if (at < 0) throw __sbD1Fail("D1_COLUMN_NOTFOUND: Column not found (" + column + ")");
5515
+ return Promise.resolve(t.rows[0][t.columns.lastIndexOf(String(column))]);
5516
+ } catch (err) {
5517
+ return Promise.reject(err);
5518
+ }
5519
+ }
5520
+ }
5521
+ function __sbMakeD1(dbName) {
5461
5522
  function batch(statements) {
5462
5523
  const strict = __sbD1Strict();
5463
5524
  const list = [];
@@ -5495,7 +5556,7 @@ function __sbMakeD1(dbName) {
5495
5556
  }
5496
5557
  const db = {
5497
5558
  prepare(sql) {
5498
- return stmt(String(sql), []);
5559
+ return new __SbD1Statement(dbName, String(sql), []);
5499
5560
  },
5500
5561
  batch(statements) {
5501
5562
  if (!__sbD1Strict()) return batch(statements);
@@ -7959,8 +8020,19 @@ function __sbParseHex(s) {
7959
8020
  // uWS hands back IPv4-mapped IPv6 for IPv4 clients on a dual-stack socket
7960
8021
  // (`::ffff:1.2.3.4`, or fully expanded `0:0:0:0:0:ffff:0102:0304`). Fold those
7961
8022
  // to the dotted IPv4 so CIDR matching and `clientIp` see a plain address.
8023
+ // The last address folded and its result: a client keeps the same address from one request to the
8024
+ // next, and folding an IPv6 text costs a lowercase, a split and eight hex parses.
8025
+ let __sbIpRaw = "";
8026
+ let __sbIpFolded = "";
7962
8027
  function __sbNormalizeIp(ip) {
7963
8028
  const s = String(ip || "");
8029
+ if (s === __sbIpRaw) return __sbIpFolded;
8030
+ const folded = __sbFoldIp(s);
8031
+ __sbIpRaw = s;
8032
+ __sbIpFolded = folded;
8033
+ return folded;
8034
+ }
8035
+ function __sbFoldIp(s) {
7964
8036
  if (s.indexOf(":") === -1) return s;
7965
8037
  const low = s.toLowerCase();
7966
8038
  const dotted = low.indexOf("::ffff:");
@@ -9100,7 +9172,7 @@ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top) {
9100
9172
  } catch (error) {
9101
9173
  // A Durable Object's stub.fetch() from 2026-10-29: the caller gets the exception.
9102
9174
  if (rethrow) {
9103
- __sbDrainWaitUntil(tasks);
9175
+ if (tasks.length) __sbDrainWaitUntil(tasks);
9104
9176
  throw error;
9105
9177
  }
9106
9178
  // #179 — a rejected handler promise must not take the process down with it.
@@ -9108,8 +9180,8 @@ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top) {
9108
9180
  }
9109
9181
  // The response goes out now and ctx.waitUntil work keeps running after it, as in
9110
9182
  // workerd; it was awaited first, so a 1.5 s background task delayed the response by
9111
- // 1.5 s. The 25 s cap still bounds it.
9112
- __sbDrainWaitUntil(tasks);
9183
+ // 1.5 s. The 25 s cap still bounds it. Not called for no tasks: an async call is a coroutine and a promise.
9184
+ if (tasks.length) __sbDrainWaitUntil(tasks);
9113
9185
  return r;
9114
9186
  }
9115
9187
 
@@ -9140,7 +9212,7 @@ function __sbErrorResponse(error, bindingCall) {
9140
9212
  // sees it. Returns whether the edge vouched for this one.
9141
9213
  function __sbBindingCallIngress(request) {
9142
9214
  const headers = request.headers;
9143
- const want = __sbEnv("SB_EDGE_TOKEN");
9215
+ const want = __sbEdgeToken();
9144
9216
  const vouched = !!want && headers.get("x-sb-binding-call") === "1" && __sbEqCt(headers.get("x-sb-edge-token") || "", want);
9145
9217
  if (vouched) {
9146
9218
  const url = headers.get("x-sb-url");
@@ -9148,12 +9220,46 @@ function __sbBindingCallIngress(request) {
9148
9220
  if (url && URL.canParse(url) && __sbCompatDate() >= "2026-10-22") request.url = url;
9149
9221
  headers.delete("x-sb-remote-addr");
9150
9222
  }
9151
- for (const name of ["x-sb-binding-call", "x-sb-url", "x-sb-edge-token"]) {
9152
- if (headers.get(name) != null) headers.delete(name);
9223
+ // Nearly every request carries none of the three: one look at the stored names tells, where
9224
+ // three lookups (each validating and lowercasing its name) and an array of them did.
9225
+ const stored = headers._entries;
9226
+ let present = stored === undefined;
9227
+ for (let i = 0; !present && i < stored.length; i += 2) {
9228
+ const key = stored[i];
9229
+ if (key === "x-sb-binding-call" || key === "x-sb-url" || key === "x-sb-edge-token") present = true;
9230
+ }
9231
+ if (present) {
9232
+ for (const name of ["x-sb-binding-call", "x-sb-url", "x-sb-edge-token"]) {
9233
+ if (headers.get(name) != null) headers.delete(name);
9234
+ }
9153
9235
  }
9154
9236
  return vouched;
9155
9237
  }
9156
9238
 
9239
+ // SB_EDGE_TOKEN is fixed for the life of a process, like SB_TRUSTED_PROXIES above: read once.
9240
+ let __sbEdgeTokenCache;
9241
+ function __sbEdgeToken() {
9242
+ if (__sbEdgeTokenCache === undefined) __sbEdgeTokenCache = __sbEnv("SB_EDGE_TOKEN");
9243
+ return __sbEdgeTokenCache;
9244
+ }
9245
+
9246
+ // One header of a request the native server built, by a name that is already lowercase and valid:
9247
+ // its stored names are scanned directly, where get() validates and lowercases the name first. A
9248
+ // name stored twice, or a headers object that is not the runtime's, goes through get().
9249
+ function __sbHeader(headers, name) {
9250
+ const stored = headers._entries;
9251
+ if (stored === undefined) return headers.get(name);
9252
+ let found = null;
9253
+ let count = 0;
9254
+ for (let i = 0; i < stored.length; i += 2) {
9255
+ if (stored[i] === name) {
9256
+ found = stored[i + 1];
9257
+ count++;
9258
+ }
9259
+ }
9260
+ return count > 1 ? headers.get(name) : found;
9261
+ }
9262
+
9157
9263
  function __sbLogUncaught(where, error) {
9158
9264
  try {
9159
9265
  let text = "";
@@ -9317,12 +9423,24 @@ function __sbIngressStream(request, id) {
9317
9423
  request.bytes = async () => await all();
9318
9424
  }
9319
9425
 
9426
+ // The id of the current request's streamed body, 0 when it has none (op "t" of __sbReqBodyOp),
9427
+ // as a number: every request asks, and the text round trip was a format and a parse.
9428
+ function __sbReqBodyTake() {
9429
+ let id = 0;
9430
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
9431
+ Porffor.c`
9432
+ extern unsigned int sb_reqbody_take_current(void);
9433
+ id = (f64)sb_reqbody_take_current();
9434
+ `;
9435
+ return id;
9436
+ }
9437
+
9320
9438
  // Native HTTP ingress stores the body as one character per raw byte. Keep
9321
9439
  // that representation for binary consumers, but decode text lazily before
9322
9440
  // handing the request to user code. Request.text() in Porffor otherwise treats
9323
9441
  // the raw bytes as already-decoded characters and corrupts Unicode/HMAC input.
9324
9442
  function __sbDecodeIngress(request) {
9325
- const streamed = __sbCompatDate() >= "2026-10-27" ? Number(__sbReqBodyOp("t", 0, "")) : 0;
9443
+ const streamed = __sbCompatDate() >= "2026-10-27" ? __sbReqBodyTake() : 0;
9326
9444
  if (streamed > 0) {
9327
9445
  __sbIngressStream(request, streamed);
9328
9446
  return;
@@ -9358,7 +9476,7 @@ function __sbTrustedProxiesOnce() {
9358
9476
 
9359
9477
  globalThis.__sbEntry = function (handlers, request) {
9360
9478
  __sbDecodeIngress(request);
9361
- const trigger = request.headers.get("x-sb-trigger");
9479
+ const trigger = __sbHeader(request.headers, "x-sb-trigger");
9362
9480
  if (!trigger) {
9363
9481
  const bindingCall = __sbBindingCallIngress(request);
9364
9482
  // #163/#128 — expose the request metadata the edge already has, the
@@ -9368,7 +9486,7 @@ globalThis.__sbEntry = function (handlers, request) {
9368
9486
  const __cf = request.cf || {};
9369
9487
  if (__sbTrustedProxiesOnce().length === 0) {
9370
9488
  // No trusted proxy (the usual case): the peer is the client and no forwarded header counts.
9371
- __cf.clientIp = __sbNormalizeIp(request.headers.get("x-sb-remote-addr") || "");
9489
+ __cf.clientIp = __sbNormalizeIp(__sbHeader(request.headers, "x-sb-remote-addr") || "");
9372
9490
  } else {
9373
9491
  __cf.clientIp = __sbClientIp(request);
9374
9492
  const __httpProtocol = __sbTrustedProxyHeader(request, "x-sb-http-protocol");