@sproutboat/runtime 0.21.1 → 0.23.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,43 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.23.0
4
+
5
+ ### Minor Changes
6
+
7
+ - df612fc: `fetch(Request)`, `fetch(Request, init)` and `new Request(request, init)` behave as in workerd from compatibility date `2026-10-23`. This covers outgoing `fetch()`, service-binding `fetch()` and Durable Object stubs, on the broker and embedded transports alike. Every behaviour was checked against workerd 2026-09-27 by the platform's service-binding suite.
8
+
9
+ From `2026-10-23`:
10
+
11
+ - `fetch(input, init)` is `fetch(new Request(input, init))`, as the Fetch spec says. The Request's method, headers, body, redirect mode and signal apply unless `init` replaces them, and `init.headers` replaces all of the Request's headers.
12
+ - A Request given as `init` brings its method, headers and body.
13
+ - A body can be used once. `fetch(request)`, `new Request(request)` and reading it through `request.body` all use it, after which `bodyUsed` is true. Sending or copying a used Request throws `TypeError: Cannot reconstruct a Request with a used body.`, and reading it again throws workerd's "Body has already been used..." message. With a body in `init`, the source Request is left alone.
14
+ - A GET or HEAD Request with any body throws, even `""` or one inherited from another Request. An incoming GET or HEAD has `request.body === null`.
15
+ - Methods are workerd's: known names in any case are uppercased, and anything else throws `Invalid HTTP method string`. That includes `CONNECT` and custom names.
16
+ - `redirect` must be `"follow"` or `"manual"`. As in workerd, `"error"` throws when the Request is made.
17
+ - The headers of an incoming request, a fetched or service-binding response, and `Response.redirect()` are immutable: `set`, `append` and `delete` throw `TypeError: Can't modify immutable headers.` Copies are mutable, so `new Request(request)`, `new Response(body, response)` and `new Headers(h)` can still be changed.
18
+ - `AbortSignal.abort()`'s default reason reads "The operation was aborted", as in workerd.
19
+
20
+ With an earlier `compatibility_date` these keep their old behaviour: a copy shares the body, any method name and redirect mode passes, and headers stay mutable. To get the new behaviour, move `compatibility_date` to `2026-10-23` and rebuild.
21
+
22
+ These are fixed at every date, since no app can depend on the old behaviour:
23
+
24
+ - `Request.signal` exists. A Request made with `signal` passes it on to copies and to `fetch()`, which aborts with it.
25
+ - A `ReadableStream` body in an outgoing `fetch()` is sent as its bytes. Before, it went out as the text `[object ReadableStream]`.
26
+ - `request.clone()` and `response.clone()` tee a stream body, so both copies can read it. Before, the second reader failed.
27
+ - A copy of an incoming request keeps its raw bytes. Its `text()` decodes UTF-8, and `fetch()` sends the bytes unchanged.
28
+
29
+ ## 0.22.0
30
+
31
+ ### Minor Changes
32
+
33
+ - 3f95234: Add experimental bounded local LTX replication to the embedded SQLite transport.
34
+ Opt in with compatibility date 2026-10-22 and SB_LTX_ENABLE=1. A single writer
35
+ owns the database, commits are captured durably before checkpoint, and the WAL
36
+ has an enforced byte ceiling. Default behavior and the broker are unchanged.
37
+ Verified clean-checkpoint restarts reuse their generation without rewriting a base.
38
+
39
+ Developed with AI assistance from OpenAI Codex (GPT-6.1).
40
+
3
41
  ## 0.21.1
4
42
 
5
43
  ### Patch Changes
@@ -0,0 +1,124 @@
1
+ # Experimental local LTX replication
2
+
3
+ AI assistance: OpenAI Codex (GPT-6.1).
4
+
5
+ This feature is for an embedded D1 database owned by one native executable.
6
+ Enable it with compatibility date `2026-10-22` or later, `SB_LTX_ENABLE=1`,
7
+ and `SB_LTX_DATABASE=DB` (the exact D1 binding name). Earlier compatibility dates,
8
+ unset flags, other bindings and the broker keep their existing behavior.
9
+ No Node or Bun runtime is needed by the executable.
10
+
11
+ ## Capture and bounds
12
+
13
+ SQLite uses WAL, synchronous FULL, no auto-checkpoint, and exclusive locking.
14
+ An advisory lock also rejects another copy of the executable using the same
15
+ path. Other SQLite connections, including readers, are excluded while it runs.
16
+ Do not use this feature with the SO_REUSEPORT multi-process deployment recipe.
17
+ The raw page-read descriptor stays open for the SQLite connection's lifetime:
18
+ closing any descriptor for the database inode would release process-level POSIX
19
+ locks, even if SQLite's own descriptor remained open.
20
+
21
+ The commit hook validates the WAL header, salts and rolling checksum through the
22
+ commit frame. It sorts a bounded array of `(pgno, frame)` entries and emits the
23
+ newest version of each page in ascending order. The prior database checksum is
24
+ updated using the old page version from the main file or the captured WAL prefix.
25
+ Truncation removes the checksums of discarded pages. No full database buffer or
26
+ in-memory page-index map is retained.
27
+
28
+ Each commit creates one LTX v3 file (magic `LTX1`), with a single logical TXID,
29
+ pre-apply checksum, committed database size, salts, and WAL frame range. TXIDs
30
+ start at one inside each generation; they are backup sequence numbers, not an
31
+ application transaction ID. The base contains exact checkpointed live pages
32
+ under a read transaction. It does not use VACUUM INTO. Page compression currently
33
+ uses interoperable literal-only LZ4 blocks; these are fast and have bounded memory,
34
+ but do not reduce file size. The prototype's JS word-based LZ4 compressor is a
35
+ separate path, not a claim that this C writer compresses data.
36
+
37
+ `SB_LTX_MAX_WAL_BYTES` defaults to 16 MiB, with an allowed range of 128 KiB to
38
+ 1 GiB. A VFS wrapper rejects a WAL write or truncate that would cross the ceiling
39
+ with SQLITE_FULL, including inside a single huge transaction. That transaction
40
+ rolls back. Reduce transaction size to fit the ceiling; this implementation does
41
+ not split a transaction. Frame metadata is bounded by that same ceiling, about
42
+ 8 bytes per WAL frame. SQLite's normal page cache remains capped at 2 MiB.
43
+
44
+ After each statement resets, checkpoint TRUNCATE runs. Multi-statement `exec`
45
+ steps and finalizes each statement separately under this opt-in, so every commit
46
+ gets a checkpoint opportunity. Inside an explicit open transaction, BUSY/LOCKED
47
+ can defer checkpointing; the VFS ceiling still applies. SQL that changes the WAL,
48
+ locking, synchronization or page-size invariants, or attaches another database,
49
+ is rejected by an authorizer. `DB.backup()` uses VACUUM INTO and is not supported
50
+ for this opted-in binding. Use the base LTX file for replication.
51
+
52
+ ## Files and durability
53
+
54
+ Files live under `<database-path>.ltx/epoch-XXXXXX/`. Completed names are
55
+ `<minTXID as 16 hex digits>-<maxTXID as 16 hex digits>.ltx`.
56
+ The encoder streams one page at a time and spools index records with `tmpfile()`.
57
+
58
+ For every LTX file:
59
+
60
+ 1. Write a `.tmp` file, flush it and fsync it.
61
+ 2. Publish the completed filename with an atomic, no-replace hard link, unlink
62
+ the temporary name, and fsync the generation directory. A finished file is
63
+ never replaced. This has rename-style publication with no overwrite race.
64
+ 3. Write a versioned, checksummed position record to `position.tmp`, fsync it,
65
+ rename it to `position`, and fsync the root directory. The record includes
66
+ generation, TXID, salts, next WAL byte offset, database checksum, WAL checksum
67
+ state, page size and committed page count.
68
+ 4. Only then checkpoint the source database. After checkpoint TRUNCATE succeeds,
69
+ atomically persist a second position record marking the database checkpointed.
70
+ The same file/directory fsync sequence applies to that record. A crash between
71
+ checkpoint and publication of the marker retains the conservative fallback.
72
+
73
+ Capture, fsync and publication failures latch the database closed to subsequent
74
+ operations until restart. The SQL commit has already happened when a hook error
75
+ is returned. A failed request can therefore represent a committed transaction;
76
+ applications must use idempotent transaction identifiers for retries.
77
+
78
+ ## Qualification status
79
+
80
+ Capture is tested against the exact inline C through `bun run test:native-ltx`,
81
+ including independent BigInt CRC and index verification, a 102-file chain,
82
+ byte-exact restore, integrity checking, oversized transaction rollback, database truncation, explicit rollback and late position-write failure latching.
83
+ The workspace prototype additionally tests the compiled Porffor executable,
84
+ including external writer exclusion and invariant PRAGMA rejection.
85
+ Restart validation now checks the position CRC, completed LTX checksum, WAL
86
+ header/salts, commit frame and cumulative frame checksum. A matching prefix
87
+ resumes in the same generation; any later complete commits are captured before
88
+ serving. Incomplete/torn trailing WAL frames are excluded. An orphan finished
89
+ file ahead of position forces a new generation, preserving the old files.
90
+ Missing, truncated, reset or mismatched WAL takes a fresh page-exact base when
91
+ it contradicts the recorded state. Position format LTXPOS2 distinguishes W
92
+ (captured WAL prefix) from C (checkpoint complete, expected absent/empty WAL).
93
+ A C record also requires a valid completed LTX, unchanged file size and matching
94
+ full database page checksum before the generation is reused. Clean restarts do
95
+ not create a base or rewrite the position. Any unexpected nonempty WAL, offline
96
+ database modification or record mismatch retains the fresh-base fallback.
97
+ Older LTXPOS1 records are conservatively replaced by a new base on first start.
98
+ All state and file descriptors are still created lazily on first DB open.
99
+
100
+ Verification is a bounded streaming read, but it is O(database size), not constant
101
+ time. It also checks the last LTX file. Five native restarts each at 4.2 MB and
102
+ 51.1 MB retained the same generation and unchanged base inode/mtime and position.
103
+ The 51.1 MB verification took 399 to 446 ms on macOS arm64 (plus about 13 ms to
104
+ health readiness); this is not a 9 ms cold-start claim for large databases.
105
+
106
+ The regression SIGKILLs at ten durability boundaries, including both sides of
107
+ clean-marker publication, and verifies matching-prefix resume and fallback cases.
108
+ It also recovers a committed tail beyond the durable cursor. The full Porffor executable passed 200 seeded random SIGKILL iterations on macOS
109
+ arm64: 1,675 acknowledged writes retained, 88 same-generation resumes and 112
110
+ fresh-base recoveries. Every finished chain passed file/database CRC verification,
111
+ integrity_check, deterministic source comparison at the last durable TXID, and
112
+ comparison after restart. The test also recovered commits whose HTTP response
113
+ was interrupted. Evidence and the runnable harness are in the linked prototype
114
+ workspace; the self-contained durability-boundary regression runs in this repo's
115
+ native CI. These are process-crash tests, not power-cut or storage-failure tests.
116
+ Linux CI exercises the same C implementation; a randomized full executable Linux
117
+ campaign and a power-loss campaign are not yet qualified.
118
+
119
+ No uploader is included. Finished local files are retained without a retention
120
+ policy; disk usage of the backup chain is not bounded by the WAL ceiling.
121
+
122
+ Format and API references: [SQLite WAL](https://www.sqlite.org/fileformat2.html#walformat),
123
+ [commit hook](https://www.sqlite.org/c3ref/wal_hook.html),
124
+ [checkpoint](https://www.sqlite.org/c3ref/wal_checkpoint_v2.html).
package/README.md CHANGED
@@ -78,3 +78,10 @@ generation with its matching size and etag.
78
78
  Unfinished uploads expire after seven days. The broker removes expired parts
79
79
  when another multipart upload starts; a standalone binary removes them when it
80
80
  initializes its embedded store.
81
+
82
+ ## Experimental SQLite replication
83
+
84
+ The embedded transport can capture bounded, durable local LTX files for one D1
85
+ binding. It requires explicit opt-in and compatibility date 2026-10-22 or later.
86
+ See [LTX replication](LTX_REPLICATION.md) for bounds, recovery, file durability,
87
+ qualification evidence and current limitations. No remote uploader is included.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.21.1",
3
+ "version": "0.23.0",
4
4
  "license": "MIT",
5
- "type": "module",
6
5
  "repository": {
7
6
  "type": "git",
8
7
  "url": "git+https://github.com/baronunread/sproutboat-packages.git",
9
8
  "directory": "packages/runtime"
10
9
  },
10
+ "type": "module",
11
11
  "main": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
13
  "scripts": {
@@ -3315,6 +3315,15 @@ for (const E of [Error, TypeError, RangeError, ReferenceError, SyntaxError, Eval
3315
3315
  // Object.fromEntries(headers) work. Porffor's Headers has entries() but no iterator.
3316
3316
  if (Headers.prototype[Symbol.iterator] == null) Headers.prototype[Symbol.iterator] = Headers.prototype.entries;
3317
3317
 
3318
+ // workerd's immutable headers, from compatibility date 2026-10-23: an incoming request's,
3319
+ // a fetched or service-binding response's, and Response.redirect()'s refuse append, set
3320
+ // and delete with a TypeError. A copy (new Headers(h), new Response(body, res)) is
3321
+ // mutable. The runtime's own writes after the handler lift the guard first.
3322
+ function __sbFreezeHeaders(message) {
3323
+ if (globalThis.__sbCompat >= "2026-10-23") message.headers._immutable = true;
3324
+ return message;
3325
+ }
3326
+
3318
3327
  // Porffor alpha-10 has no static Response.redirect().
3319
3328
  if (Response.redirect == null) {
3320
3329
  Response.redirect = function (url, status = 302) {
@@ -3322,7 +3331,7 @@ if (Response.redirect == null) {
3322
3331
  throw new RangeError("invalid redirect status");
3323
3332
  }
3324
3333
  // Serialized like the URL parser does (a bare origin gains its "/"); invalid URLs throw.
3325
- return new Response(null, { status, headers: { location: new URL(String(url)).href } });
3334
+ return __sbFreezeHeaders(new Response(null, { status, headers: { location: new URL(String(url)).href } }));
3326
3335
  };
3327
3336
  }
3328
3337
 
@@ -3739,7 +3748,9 @@ if (typeof globalThis.EventTarget !== "function") {
3739
3748
  __sbAbort(reason) {
3740
3749
  if (this.aborted) return;
3741
3750
  this.aborted = true;
3742
- this.reason = reason === undefined ? namedError("AbortError", "This operation was aborted") : reason;
3751
+ // workerd's default reason reads "The operation was aborted" (from 2026-10-23 here).
3752
+ const message = globalThis.__sbCompat >= "2026-10-23" ? "The operation was aborted" : "This operation was aborted";
3753
+ this.reason = reason === undefined ? namedError("AbortError", message) : reason;
3743
3754
  const event = new Event("abort");
3744
3755
  if (typeof this.onabort === "function") this.onabort.call(this, event);
3745
3756
  this.dispatchEvent(event);
@@ -5229,7 +5240,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5229
5240
  fetch(input, init) {
5230
5241
  if (globalThis.__sbCompat >= "2026-10-22") return __sbServiceFetch(binding, input, init);
5231
5242
  const parts = __sbFetchParts(input, init);
5232
- const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(parts.body) };
5243
+ const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbPartsBody(parts) };
5233
5244
  return __sbUpstreamResponse(globalThis.__sbFetchUpstream("service.fetch", msg));
5234
5245
  },
5235
5246
  };
@@ -5251,9 +5262,9 @@ async function __sbServiceFetch(binding, input, init) {
5251
5262
  const parts = __sbFetchParts(input, init);
5252
5263
  if (parts.signal) parts.signal.throwIfAborted();
5253
5264
  // A stream body is read to the end first: the wire carries a whole body.
5254
- let body = __sbUnwrapBody(parts.body);
5255
- if (body instanceof ReadableStream) body = await __sbCollectBody(body);
5256
- const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(body) };
5265
+ const body = __sbUnwrapBody(parts.body);
5266
+ const wire = body instanceof ReadableStream ? __sbRequestBody(await __sbCollectBody(body)) : __sbPartsBody({ ...parts, body });
5267
+ const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...wire };
5257
5268
  const response = await __sbFetchFollowing("service.fetch", msg, "sproutboat service " + binding, parts.redirect, parts.signal);
5258
5269
  // The callee threw: as in workerd, its error is thrown here.
5259
5270
  const uncaught = response.headers.get("x-sb-uncaught");
@@ -5436,7 +5447,29 @@ async function __sbRpcDispatch(handlers, receiver, method, request) {
5436
5447
 
5437
5448
  // fetch(input, init) as Fetch reads it: a Request's method, headers, body, redirect mode
5438
5449
  // and signal apply unless init overrides them; a URL object stands for its href.
5450
+ // From compatibility date 2026-10-23 it is Fetch's own first step, `new Request(input,
5451
+ // init)`, as in workerd: the input Request's body is used up (a second fetch of it
5452
+ // throws), a Request given as init brings its body, GET and HEAD refuse a body, and the
5453
+ // method and redirect mode are checked. Before it, the parts are read off the input.
5439
5454
  function __sbFetchParts(input, init) {
5455
+ if (globalThis.__sbCompat >= "2026-10-23") {
5456
+ const request = new Request(input, init);
5457
+ const headers = [];
5458
+ request.headers.forEach((v, k) => headers.push([k, v]));
5459
+ const body = request._bodyStream != null ? request._bodyStream : request._bodyInit;
5460
+ // A stream the runtime made over a value is sent as that value, unread: it is used
5461
+ // all the same, so a Request that gave it (as input or as init) reads as used.
5462
+ if (body instanceof ReadableStream && body._sbBody !== undefined) body._disturbed = true;
5463
+ return {
5464
+ url: request.url,
5465
+ method: request.method,
5466
+ headers,
5467
+ body: body === undefined ? null : body,
5468
+ latin1: request._bodyLatin1 === true,
5469
+ redirect: request.redirect,
5470
+ signal: request._signal || null,
5471
+ };
5472
+ }
5440
5473
  const req = input != null && !__sbIsStr(input) && input.url != null ? input : null;
5441
5474
  const url = req ? String(req.url) : input != null && input.href != null ? String(input.href) : String(input);
5442
5475
  const opts = init || {};
@@ -5449,8 +5482,17 @@ function __sbFetchParts(input, init) {
5449
5482
  const body = opts.body !== undefined && !(opts instanceof Request) ? opts.body : req && __sbRawBody(req) !== "" ? __sbRawBody(req) : null;
5450
5483
  const method = opts.method || (req && req.method) || "GET";
5451
5484
  const redirect = opts.redirect || (req && req.redirect) || "follow";
5452
- const signal = opts.signal || (req && req.signal) || null;
5453
- return { url, method, headers, body, redirect, signal };
5485
+ const signal = opts.signal || (req && req._signal) || null;
5486
+ // An incoming request's body is its raw bytes, one character each: sent as bytes.
5487
+ const latin1 = body != null && opts.body === undefined && req !== null && req._bodyLatin1 === true;
5488
+ return { url, method, headers, body, latin1, redirect, signal };
5489
+ }
5490
+
5491
+ // The wire fields of a fetch body (see __sbRequestBody). Raw ingress bytes go as bytes,
5492
+ // not as text encoded again.
5493
+ function __sbPartsBody(parts) {
5494
+ if (parts.latin1 === true && __sbIsStr(parts.body)) return { body: parts.body, bodyBinary: true };
5495
+ return __sbRequestBody(parts.body);
5454
5496
  }
5455
5497
 
5456
5498
  // An upstream reply (fetch or service.fetch) -> a Response. Headers are appended,
@@ -5469,7 +5511,7 @@ function __sbUpstreamResponse(r) {
5469
5511
  if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
5470
5512
  const response = __sbRawBodyResponse(r.body, { status: r.status || 502, headers: respHeaders });
5471
5513
  if (!typed) response.headers.delete("content-type");
5472
- return response;
5514
+ return __sbFreezeHeaders(response);
5473
5515
  }
5474
5516
 
5475
5517
  // A streamed upstream response (from compatibility date 2026-10-22, with the other
@@ -5520,7 +5562,7 @@ function __sbUpstreamStreamed(op, msg, label, signal) {
5520
5562
  __sbCancelAsync(id);
5521
5563
  },
5522
5564
  });
5523
- resolve(new Response(body, { status: reply.status || 502, headers }));
5565
+ resolve(__sbFreezeHeaders(new Response(body, { status: reply.status || 502, headers })));
5524
5566
  return;
5525
5567
  }
5526
5568
  const frame = JSON.parse(raw);
@@ -5645,9 +5687,23 @@ function __sbUpstream(op, msg, label, signal, always) {
5645
5687
  // prelude must not replace fetch just by being evaluated.
5646
5688
  globalThis.__sbInstallFetch = function () {
5647
5689
  globalThis.fetch = function (input, init) {
5648
- const parts = __sbFetchParts(input, init);
5649
- const msg = { url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(parts.body) };
5650
- return __sbFetchFollowing("fetch", msg, "sproutboat fetch", parts.redirect, parts.signal);
5690
+ let parts;
5691
+ try {
5692
+ parts = __sbFetchParts(input, init);
5693
+ } catch (error) {
5694
+ // From 2026-10-23 an invalid request rejects, as in workerd; before it, it threw.
5695
+ if (globalThis.__sbCompat >= "2026-10-23") return Promise.reject(error);
5696
+ throw error;
5697
+ }
5698
+ const send = (body) => {
5699
+ const msg = { url: parts.url, method: parts.method, headers: parts.headers, ...body };
5700
+ return __sbFetchFollowing("fetch", msg, "sproutboat fetch", parts.redirect, parts.signal);
5701
+ };
5702
+ // A stream body is read to the end first, as for a service binding: the wire carries a
5703
+ // whole body. It was sent as the text "[object ReadableStream]".
5704
+ const stream = __sbUnwrapBody(parts.body);
5705
+ if (stream instanceof ReadableStream) return __sbCollectBody(stream).then((bytes) => send(__sbRequestBody(bytes)));
5706
+ return send(__sbPartsBody(parts));
5651
5707
  };
5652
5708
  };
5653
5709
 
@@ -5691,7 +5747,11 @@ function __sbMakeDONamespace(binding, className) {
5691
5747
  return {
5692
5748
  fetch(input, init) {
5693
5749
  let req;
5694
- if (__sbIsObj(input) && __sbIsStr(input.url) && !init) {
5750
+ // From 2026-10-23 the object gets the Request Fetch makes of input and init, as in
5751
+ // workerd: method, headers and body survive, and the input's body is used up.
5752
+ if (globalThis.__sbCompat >= "2026-10-23") {
5753
+ req = new Request(input, init);
5754
+ } else if (__sbIsObj(input) && __sbIsStr(input.url) && !init) {
5695
5755
  req = input;
5696
5756
  } else {
5697
5757
  const url = __sbIsStr(input) ? input : String((input && input.url) || "https://do/");
@@ -6101,6 +6161,8 @@ function __sbStreamingSupported() {
6101
6161
  function __sbStreamResponse(response) {
6102
6162
  const stream = __sbRawBody(response);
6103
6163
  const id = __sbStreamOp("o", "0", "");
6164
+ // A fetched response returned as it is has immutable headers; this mark is ours.
6165
+ response.headers._immutable = false;
6104
6166
  response.headers.set("x-sb-stream", id);
6105
6167
  response.body = "";
6106
6168
  (async () => {
@@ -6227,6 +6289,12 @@ async function __sbQueueWithDrain(result, tasks) {
6227
6289
  function __sbDecodeIngress(request) {
6228
6290
  const raw = __sbRawBody(request);
6229
6291
  if (raw == null) return;
6292
+ // A GET or HEAD has no body, as in workerd (from 2026-10-23): request.body is null, and
6293
+ // new Request(request) copies none. Before it, the body is the empty string.
6294
+ if (raw === "" && (request.method === "GET" || request.method === "HEAD") && globalThis.__sbCompat >= "2026-10-23") {
6295
+ request.body = null;
6296
+ return;
6297
+ }
6230
6298
  // Raw bytes, one character each: arrayBuffer(), blob() and body read them as bytes.
6231
6299
  request._bodyLatin1 = true;
6232
6300
  let decoded = null;
@@ -6258,6 +6326,8 @@ globalThis.__sbEntry = function (handlers, request) {
6258
6326
  const __tlsCipher = __sbTrustedProxyHeader(request, "x-sb-tls-cipher");
6259
6327
  if (__tlsCipher) __cf.tlsCipher = __tlsCipher;
6260
6328
  request.cf = __cf;
6329
+ // The edge's headers are gone: from 2026-10-23 the handler cannot change the rest.
6330
+ __sbFreezeHeaders(request);
6261
6331
  // #28 — per-invocation CPU time. One fetch turn per process (serial), so the
6262
6332
  // process CPU delta across the handler is this invocation's CPU.
6263
6333
  // ponytail: serial-turn assumption; revisit if the profile ever allows