@sproutboat/runtime 0.11.0 → 0.12.1

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,25 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.12.1
4
+
5
+ ### Patch Changes
6
+
7
+ - ed9640a: Point Porffor patch diagnostics, runtime comments, and historical notes to the
8
+ shared upstream notes in the Sproutboat platform repository.
9
+
10
+ ## 0.12.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 4a7fe62: Cache API: `caches.default` / `caches.open(name)` (baronunread/sproutboat#59), with `match()`/`put()`/`delete()` backed by both transports (broker's `cache.*` ops and the standalone embedded transport, so behavior matches whichever way the app deploys). Keyed on method+URL for v1 (no `Vary` support yet). `put()` throws for a non-GET request, matching the spec; a 206 status or an explicit `no-store`/`private` `Cache-Control` is a silent no-op rather than an error, matching Workers' own `caches.default`. TTL derives from `s-maxage` (preferred) or `max-age`; entries with neither have no expiry.
15
+
16
+ Deliberately synchronous, not Promise-returning: every other buffered-body method this runtime already ships (`text()`/`json()`/`arrayBuffer()`/`blob()`/`formData()`) is sync too, and this runtime's own coroutine machinery has a known, still-open memory-corruption issue tied specifically to nested `async function` calls accumulating across many requests (baronunread/sproutboat#168) — no reason to add more async surface here. `await caches.default.match(...)` still reads correctly: awaiting a non-promise value resolves to it immediately.
17
+ - 96f97e2: `Request`/`Response` gain `formData()` (baronunread/sproutboat#60), parsing the buffered body synchronously — `application/x-www-form-urlencoded` via the existing `URLSearchParams` shim, `multipart/form-data` by hand (RFC 7578 boundary parsing, file fields come back as `Blob`s). Also adds `bytes()` (the `Uint8Array` shorthand over `arrayBuffer()`) to both classes. Everything is feature-detected, so this is a no-op the moment Porffor ships either natively.
18
+
19
+ ### Patch Changes
20
+
21
+ - 81dc1f7: Preserve raw bytes in `crypto.subtle.digest`, HMAC signing, and `crypto.scryptVerify`. The runtime's byte conversion already produces UTF-8 bytes for text and copies BufferSource inputs exactly, so the native bridge must pass those bytes through without encoding them again. This fixes repeated HMAC signing with digest bytes above `0x7f`.
22
+
3
23
  ## 0.11.0
4
24
 
5
25
  ### Minor Changes
@@ -118,11 +138,11 @@
118
138
 
119
139
  Verified end-to-end against both transports (kitchen-sink's full conformance suite, 24/24 standalone + 27/27 broker) and with new unit tests for file-backed persistence, the `:memory:` fallback, and a real binary round-trip over the v1 wire protocol.
120
140
 
121
- **Known limitation found while verifying this, not fixed here:** a binary `put()` body (bytes ≥ 0x80) is corrupted on the way *in*, before it ever reaches storage — `porf_native_fetch_read_value` (the function every inline-C string read in the runtime goes through) always UTF-8-encodes a Latin-1-range string on read, which is correct for genuine text but wrong for opaque bytes coming off a raw HTTP body. This predates this change (the old SQLite-blob path had the identical corruption) and is not something the storage backend can fix — it needs an input-side counterpart to `#176`'s `x-sb-raw-body` marker, or real `ArrayBuffer`/`Uint8Array` body support (the upstream ask already filed as Draft G in `patches/UPSTREAM.md`).
141
+ **Known limitation found while verifying this, not fixed here:** a binary `put()` body (bytes ≥ 0x80) is corrupted on the way *in*, before it ever reaches storage: `porf_native_fetch_read_value` (the function every inline-C string read in the runtime goes through) always UTF-8-encodes a Latin-1-range string on read, which is correct for genuine text but wrong for opaque bytes coming off a raw HTTP body. This predates this change (the old SQLite-blob path had the identical corruption) and is not something the storage backend can fix. It needs an input-side counterpart to `#176`'s `x-sb-raw-body` marker, or real `ArrayBuffer`/`Uint8Array` body support (the upstream ask was filed as Porffor #386 and later retracted; see `https://github.com/baronunread/sproutboat/blob/main/patches/upstream/request-body-report-386.md`).
122
142
 
123
143
  ### Patch Changes
124
144
 
125
- - 188d8e4: Fix (baronunread/sproutboat#184): a `put()` body (R2, both transports) with bytes ≥ 0x80 was corrupted on the way *in*, before storage — `porf_native_fetch_read_value` (the one function every inline-C string read in the runtime funnels through) always UTF-8-encodes a Latin-1-range value, correct for genuine text, wrong for opaque bytes off a raw HTTP upload. A 256-byte binary upload landed as 384 stored bytes.
145
+ - 188d8e4: Fix (baronunread/sproutboat#184): a `put()` body (R2, both transports) with bytes ≥ 0x80 was corrupted on the way *in*, before storage: `porf_native_fetch_read_value` (the one function every inline-C string read in the runtime funnels through) always UTF-8-encodes a Latin-1-range value, correct for genuine text, wrong for opaque bytes off a raw HTTP upload. A 256-byte binary upload landed as 384 stored bytes.
126
146
 
127
147
  `__sbR2PutRaw` (embedded) and `__sbCallBin` (broker, the only caller that ever passes it a real body) now try `porf_native_fetch_read_raw_bytes` first, falling back to the normal encoding path only for a genuine multi-byte JS string (which isn't a "bytestring" and so isn't eligible for the raw read anyway). This predates the #56 storage change — the old SQLite-blob `put()` called the identical `read_value`, so it was equally corrupted; the existing R2 conformance check never caught it because it only ever exercises a plain ASCII string.
128
148
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,81 @@
1
+ // #60 — the FormData parsing helpers live inline in native-fetch-prelude.js
2
+ // (the prelude is prepended as text and cannot import), so this test lifts
3
+ // that block out and exercises it directly. Bun already provides real
4
+ // FormData/Blob/URLSearchParams globals, so the lifted parser functions run
5
+ // against those rather than the prelude's own guarded shims (which no-op
6
+ // here since Bun already has the globals they'd otherwise install) — the
7
+ // parsing logic itself is what's under test, and it's spec surface either way.
8
+ import { expect, test } from "bun:test";
9
+ import { readFileSync } from "node:fs";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const src = readFileSync(fileURLToPath(new URL("./native-fetch-prelude.js", import.meta.url)), "utf8");
13
+ const start = src.indexOf("function __sbParseUrlencodedFormData(");
14
+ const end = src.indexOf('if (!("formData" in Request.prototype))');
15
+ if (start === -1 || end === -1 || end < start) throw new Error("FormData parsing block not found in prelude");
16
+
17
+ // oxlint-disable-next-line no-function-constructor -- lifting a pure block out of the text-only prelude for test
18
+ const load = new Function(
19
+ `${src.slice(start, end)}\n return { __sbParseUrlencodedFormData, __sbParseMultipartFormData, __sbFormDataFromBody };`,
20
+ );
21
+ const { __sbParseUrlencodedFormData, __sbParseMultipartFormData, __sbFormDataFromBody } = load();
22
+
23
+ const headers = (contentType) => ({ get: (k) => (k.toLowerCase() === "content-type" ? contentType : null) });
24
+
25
+ test("urlencoded: decodes fields, including repeated keys and +/percent escapes", () => {
26
+ const fd = __sbParseUrlencodedFormData("name=Ren%C3%A9&tag=a&tag=b&note=hi+there");
27
+ expect(fd.get("name")).toBe("René");
28
+ expect(fd.getAll("tag")).toEqual(["a", "b"]);
29
+ expect(fd.get("note")).toBe("hi there");
30
+ });
31
+
32
+ test("urlencoded: empty body yields an empty FormData", () => {
33
+ const fd = __sbParseUrlencodedFormData("");
34
+ expect(fd.get("anything")).toBeNull();
35
+ });
36
+
37
+ test("multipart: parses a text field and a file field from a real boundary", () => {
38
+ const boundary = "----sbTestBoundary123";
39
+ const body =
40
+ `--${boundary}\r\n` +
41
+ `Content-Disposition: form-data; name="title"\r\n\r\n` +
42
+ `hello world\r\n` +
43
+ `--${boundary}\r\n` +
44
+ `Content-Disposition: form-data; name="upload"; filename="a.txt"\r\n` +
45
+ `Content-Type: text/plain\r\n\r\n` +
46
+ `file contents\r\n` +
47
+ `--${boundary}--\r\n`;
48
+ const fd = __sbParseMultipartFormData(body, boundary);
49
+ expect(fd.get("title")).toBe("hello world");
50
+ const file = fd.get("upload");
51
+ expect(file instanceof Blob).toBe(true);
52
+ });
53
+
54
+ test("multipart: a part with no name= is skipped rather than throwing", () => {
55
+ const boundary = "B";
56
+ const body = `--B\r\nContent-Disposition: form-data\r\n\r\nignored\r\n--B\r\nContent-Disposition: form-data; name="k"\r\n\r\nv\r\n--B--\r\n`;
57
+ const fd = __sbParseMultipartFormData(body, boundary);
58
+ expect(fd.get("k")).toBe("v");
59
+ expect(fd.has("")).toBe(false);
60
+ });
61
+
62
+ test("__sbFormDataFromBody: dispatches on content-type, quoted or bare boundary", () => {
63
+ const urlencoded = __sbFormDataFromBody(headers("application/x-www-form-urlencoded"), "a=1");
64
+ expect(urlencoded.get("a")).toBe("1");
65
+
66
+ const bareBoundary = __sbFormDataFromBody(
67
+ headers("multipart/form-data; boundary=XYZ"),
68
+ `--XYZ\r\nContent-Disposition: form-data; name="a"\r\n\r\n1\r\n--XYZ--\r\n`,
69
+ );
70
+ expect(bareBoundary.get("a")).toBe("1");
71
+
72
+ const quotedBoundary = __sbFormDataFromBody(
73
+ headers('multipart/form-data; boundary="XYZ"'),
74
+ `--XYZ\r\nContent-Disposition: form-data; name="a"\r\n\r\n1\r\n--XYZ--\r\n`,
75
+ );
76
+ expect(quotedBoundary.get("a")).toBe("1");
77
+ });
78
+
79
+ test("__sbFormDataFromBody: multipart with no boundary throws instead of silently misparsing", () => {
80
+ expect(() => __sbFormDataFromBody(headers("multipart/form-data"), "")).toThrow(/boundary/);
81
+ });
@@ -1,17 +1,20 @@
1
1
  // Prepended to every handler by tools/compile.ts, before Porffor's native-fetch
2
- // esbuild bundle. Porffor's runtime/fetch-globals.js (checked through alpha-4)
2
+ // bundled handler. Porffor's runtime/fetch-globals.js (checked through alpha-4)
3
3
  // gives URL (href/origin/pathname/search only) and Response without a static
4
4
  // json(). This adds, additively, the rest of the WHATWG surface Worker code
5
5
  // expects: URLSearchParams (read + write), URL.prototype.searchParams and the
6
6
  // protocol/host/hostname/port/hash accessors, static Response.json,
7
7
  // crypto.randomUUID / crypto.getRandomValues, crypto.subtle (digest + HMAC
8
- // sign/verify, #133), and structuredClone. Each is feature-detected; delete a
9
- // block once Porffor ships that global. crypto.scryptVerify (#153) is a
10
- // Sproutboat extension, not WHATWG, and stays.
11
- // Tracked upstream in patches/UPSTREAM.md.
8
+ // sign/verify, #133), structuredClone, and FormData + Request/Response
9
+ // formData()/bytes() (#60). Each is feature-detected; delete a block once
10
+ // Porffor ships that global. crypto.scryptVerify (#153) is a Sproutboat
11
+ // extension, not WHATWG, and stays.
12
+ // Tracked in the platform's upstream notes:
13
+ // https://github.com/baronunread/sproutboat/blob/main/patches/upstream/whatwg-fetch-globals.md
12
14
  //
13
15
  // Declared before it is referenced: a getter body that names a later top-level
14
- // class throws ReferenceError in Porffor (see patches/UPSTREAM.md draft B).
16
+ // class throws ReferenceError in Porffor. See:
17
+ // https://github.com/baronunread/sproutboat/blob/main/patches/upstream/class-declaration-scope.md
15
18
 
16
19
  // Duck-typing helpers, `typeof`-free (the repo's anti-slop lint bans `typeof`;
17
20
  // these express the same spec-mandated checks and are verified under Porffor
@@ -201,10 +204,192 @@ if (Response.json == null) {
201
204
  };
202
205
  }
203
206
 
207
+ // #59 — caches.default / caches.open(name), backed by the broker's cache.*
208
+ // ops (a sibling SQLite table, key on method+URL for v1 — no Vary yet).
209
+ // Deliberately NOT async: every other buffered-body method on this runtime
210
+ // (text/json/arrayBuffer/blob, formData above) is sync too, and #168's own
211
+ // investigation found real coroutine-state corruption tied specifically to
212
+ // nested `async function` calls accumulating across many requests — no
213
+ // reason to add more async surface to that here. `await`ing a non-promise
214
+ // still resolves immediately, so `await caches.default.match(...)` reads
215
+ // exactly like the spec's Promise-returning version to calling code.
216
+ if (globalThis.caches == null) {
217
+ function __sbCacheKey(request) {
218
+ return "GET " + (__sbIsStr(request) ? request : request.url);
219
+ }
220
+ class __SproutboatCache {
221
+ constructor(name) {
222
+ this._name = name;
223
+ }
224
+ match(request) {
225
+ const r = __sbRpc("cache.match", { name: this._name, key: __sbCacheKey(request) });
226
+ if (!r.found) return undefined;
227
+ return new Response(r.body, { status: r.status, headers: r.headers });
228
+ }
229
+ put(request, response) {
230
+ const method = __sbIsStr(request) ? "GET" : request.method;
231
+ if (method !== "GET") throw new TypeError("Cache.put: request method must be GET");
232
+ const headers = {};
233
+ response.headers.forEach((value, name) => {
234
+ headers[name] = value;
235
+ });
236
+ __sbRpc("cache.put", {
237
+ name: this._name,
238
+ key: __sbCacheKey(request),
239
+ status: response.status,
240
+ headers,
241
+ body: response.text(),
242
+ });
243
+ return undefined;
244
+ }
245
+ delete(request) {
246
+ return !!__sbRpc("cache.delete", { name: this._name, key: __sbCacheKey(request) }).deleted;
247
+ }
248
+ }
249
+ const __sbCacheInstances = new Map();
250
+ globalThis.caches = {
251
+ default: new __SproutboatCache("default"),
252
+ open(name) {
253
+ const key = String(name);
254
+ let instance = __sbCacheInstances.get(key);
255
+ if (!instance) {
256
+ instance = new __SproutboatCache(key);
257
+ __sbCacheInstances.set(key, instance);
258
+ }
259
+ return instance;
260
+ },
261
+ };
262
+ }
263
+
264
+ // #60 — Porffor's Request/Response give text()/json()/arrayBuffer()/blob()
265
+ // synchronously (buffered body, fine under http-sync-v0) but no FormData at
266
+ // all. Parses the same buffered body, synchronously, into a FormData-like
267
+ // object; url-encoded via the URLSearchParams shim above, multipart by hand
268
+ // (RFC 7578: boundary-delimited parts, each a header block then a blank line
269
+ // then the part body — one pass, no nested multipart).
270
+ if (globalThis.FormData == null) {
271
+ class __SproutboatFormData {
272
+ constructor() {
273
+ this._entries = [];
274
+ }
275
+ append(name, value, filename) {
276
+ this._entries.push([String(name), value, filename]);
277
+ }
278
+ set(name, value, filename) {
279
+ name = String(name);
280
+ let replaced = false;
281
+ this._entries = this._entries.filter((e) => {
282
+ if (e[0] !== name) return true;
283
+ if (replaced) return false;
284
+ e[1] = value;
285
+ e[2] = filename;
286
+ replaced = true;
287
+ return true;
288
+ });
289
+ if (!replaced) this.append(name, value, filename);
290
+ }
291
+ get(name) {
292
+ for (let i = 0; i < this._entries.length; i++) if (this._entries[i][0] === name) return this._entries[i][1];
293
+ return null;
294
+ }
295
+ getAll(name) {
296
+ const out = [];
297
+ for (let i = 0; i < this._entries.length; i++) if (this._entries[i][0] === name) out.push(this._entries[i][1]);
298
+ return out;
299
+ }
300
+ has(name) {
301
+ for (let i = 0; i < this._entries.length; i++) if (this._entries[i][0] === name) return true;
302
+ return false;
303
+ }
304
+ delete(name) {
305
+ this._entries = this._entries.filter((e) => e[0] !== name);
306
+ }
307
+ forEach(cb) {
308
+ for (let i = 0; i < this._entries.length; i++) cb(this._entries[i][1], this._entries[i][0], this);
309
+ }
310
+ keys() {
311
+ return this._entries.map((e) => e[0]);
312
+ }
313
+ values() {
314
+ return this._entries.map((e) => e[1]);
315
+ }
316
+ entries() {
317
+ return this._entries.map((e) => [e[0], e[1]]);
318
+ }
319
+ }
320
+ globalThis.FormData = __SproutboatFormData;
321
+ }
322
+
323
+ function __sbParseUrlencodedFormData(text) {
324
+ const fd = new FormData();
325
+ // forEach, not keys()/values() — those are spec'd as iterators, not
326
+ // arrays, and this shim's own URLSearchParams.keys()/values() return
327
+ // arrays as an implementation shortcut, so relying on either shape here
328
+ // would break against whichever kind actually ends up installed.
329
+ new URLSearchParams(text).forEach((value, key) => fd.append(key, value));
330
+ return fd;
331
+ }
332
+
333
+ function __sbParseMultipartFormData(text, boundary) {
334
+ const fd = new FormData();
335
+ const delim = "--" + boundary;
336
+ const segments = text.split(delim);
337
+ // segments[0] is the preamble, the last is the closing "--" epilogue.
338
+ for (let i = 1; i < segments.length - 1; i++) {
339
+ let part = segments[i];
340
+ if (part.slice(0, 2) === "\r\n") part = part.slice(2);
341
+ if (part.slice(-2) === "\r\n") part = part.slice(0, -2);
342
+ const headerEnd = part.indexOf("\r\n\r\n");
343
+ if (headerEnd === -1) continue;
344
+ const headerText = part.slice(0, headerEnd);
345
+ const body = part.slice(headerEnd + 4);
346
+ const nameMatch = /name="([^"]*)"/i.exec(headerText);
347
+ if (!nameMatch) continue;
348
+ const filenameMatch = /filename="([^"]*)"/i.exec(headerText);
349
+ if (filenameMatch) fd.append(nameMatch[1], new Blob([body]), filenameMatch[1]);
350
+ else fd.append(nameMatch[1], body);
351
+ }
352
+ return fd;
353
+ }
354
+
355
+ function __sbFormDataFromBody(headers, text) {
356
+ const contentType = headers.get("content-type") || "";
357
+ if (contentType.indexOf("multipart/form-data") !== -1) {
358
+ const boundaryMatch = /boundary=(?:"([^"]*)"|([^;]+))/i.exec(contentType);
359
+ const boundary = boundaryMatch ? (boundaryMatch[1] || boundaryMatch[2]).trim() : null;
360
+ if (!boundary) throw new TypeError("multipart/form-data body has no boundary");
361
+ return __sbParseMultipartFormData(text, boundary);
362
+ }
363
+ return __sbParseUrlencodedFormData(text);
364
+ }
365
+
366
+ if (!("formData" in Request.prototype)) {
367
+ Request.prototype.formData = function () {
368
+ return __sbFormDataFromBody(this.headers, this.text());
369
+ };
370
+ }
371
+ if (!("formData" in Response.prototype)) {
372
+ Response.prototype.formData = function () {
373
+ return __sbFormDataFromBody(this.headers, this.text());
374
+ };
375
+ }
376
+ // #60 — the newer Uint8Array shorthand, trivial on top of arrayBuffer().
377
+ if (!("bytes" in Request.prototype)) {
378
+ Request.prototype.bytes = function () {
379
+ return new Uint8Array(this.arrayBuffer());
380
+ };
381
+ }
382
+ if (!("bytes" in Response.prototype)) {
383
+ Response.prototype.bytes = function () {
384
+ return new Uint8Array(this.arrayBuffer());
385
+ };
386
+ }
387
+
204
388
  // Porffor's URL exposes href / origin / pathname / search only. Add the rest of
205
389
  // the WHATWG read surface, derived from `origin` (scheme://host[:port]).
206
390
  // `hash` is always '' server-side — browsers strip the fragment before the
207
- // request, so there is nothing to recover. Tracked upstream (patches/UPSTREAM.md).
391
+ // request, so there is nothing to recover. See:
392
+ // https://github.com/baronunread/sproutboat/blob/main/patches/upstream/whatwg-fetch-globals.md
208
393
  function __sbDefineURLAccessor(name, get) {
209
394
  if (!(name in URL.prototype)) Object.defineProperty(URL.prototype, name, { configurable: true, get });
210
395
  }
@@ -586,6 +771,7 @@ Porffor.c`
586
771
 
587
772
  u32 porf_native_fetch_alloc_bytestring(const char* input, size_t len);
588
773
  int porf_native_fetch_read_value(jsval value, const char** out_buf, size_t* out_len, char** out_owned);
774
+ int porf_native_fetch_read_raw_bytes(jsval value, const char** out_buf, size_t* out_len);
589
775
 
590
776
  // --- #133: SHA-2 + HMAC ---------------------------------------------------
591
777
  // Standalone links BearSSL, but a deployed sprout does not, and this inline C
@@ -917,8 +1103,8 @@ function __sbRandomBytes(nStr) {
917
1103
  return out;
918
1104
  }
919
1105
 
920
- // #133 — one-shot SHA-2 digest. `algoStr` is "256"|"384"|"512"; `data` is a
921
- // latin1 string (one char per byte). Returns the raw digest as a bytestring.
1106
+ // #133: one-shot SHA-2 digest. `algoStr` is "256"|"384"|"512"; `data` is a
1107
+ // bytestring (one char per byte). Returns the raw digest as a bytestring.
922
1108
  // oxlint-disable-next-line no-unused-vars -- read inside the RawC block, not by JS.
923
1109
  function __sbDigestRaw(algoStr, data) {
924
1110
  let out = "";
@@ -931,17 +1117,17 @@ function __sbDigestRaw(algoStr, data) {
931
1117
  if (__aso) free(__aso);
932
1118
  int __algo = atoi(__ab);
933
1119
 
934
- const char* __d; size_t __dl; char* __do = 0;
935
- porf_native_fetch_read_value(data, &__d, &__dl, &__do);
1120
+ const char* __d = 0; size_t __dl = 0;
1121
+ // __sbToBytes has already encoded text. The generic reader would encode high bytes again.
1122
+ int __raw = porf_native_fetch_read_raw_bytes(data, &__d, &__dl);
936
1123
  unsigned char __out[64];
937
- size_t __n = sb_digest(__algo, (const unsigned char*)__d, __dl, __out);
938
- if (__do) free(__do);
1124
+ size_t __n = __raw == 0 ? sb_digest(__algo, (const unsigned char*)__d, __dl, __out) : 0;
939
1125
  if (__n) out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__out, __n), 195);
940
1126
  `;
941
1127
  return out;
942
1128
  }
943
1129
 
944
- // #133 — HMAC-SHA-2. `algoStr` "256"|"384"|"512"; `key` and `data` latin1.
1130
+ // #133: HMAC-SHA-2. `algoStr` "256"|"384"|"512"; `key` and `data` are bytestrings.
945
1131
  // Returns the raw MAC as a bytestring.
946
1132
  // oxlint-disable-next-line no-unused-vars -- read inside the RawC block, not by JS.
947
1133
  function __sbHmacRaw(algoStr, key, data) {
@@ -955,17 +1141,15 @@ function __sbHmacRaw(algoStr, key, data) {
955
1141
  if (__aso) free(__aso);
956
1142
  int __algo = atoi(__ab);
957
1143
 
958
- const char* __k; size_t __kl; char* __ko = 0;
959
- porf_native_fetch_read_value(key, &__k, &__kl, &__ko);
960
- unsigned char* __kc = (unsigned char*)malloc(__kl ? __kl : 1);
1144
+ const char* __k = 0; size_t __kl = 0;
1145
+ int __key_raw = porf_native_fetch_read_raw_bytes(key, &__k, &__kl);
1146
+ unsigned char* __kc = __key_raw == 0 ? (unsigned char*)malloc(__kl ? __kl : 1) : 0;
961
1147
  if (__kc) memcpy(__kc, __k, __kl);
962
- if (__ko) free(__ko);
963
1148
 
964
- const char* __d; size_t __dl; char* __do = 0;
965
- porf_native_fetch_read_value(data, &__d, &__dl, &__do);
1149
+ const char* __d = 0; size_t __dl = 0;
1150
+ int __data_raw = porf_native_fetch_read_raw_bytes(data, &__d, &__dl);
966
1151
  unsigned char __out[64];
967
- size_t __n = __kc ? sb_hmac(__algo, __kc, __kl, (const unsigned char*)__d, __dl, __out) : 0;
968
- if (__do) free(__do);
1152
+ size_t __n = __kc && __data_raw == 0 ? sb_hmac(__algo, __kc, __kl, (const unsigned char*)__d, __dl, __out) : 0;
969
1153
  if (__kc) free(__kc);
970
1154
  if (__n) out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__out, __n), 195);
971
1155
  `;
@@ -979,17 +1163,15 @@ function __sbScryptRaw(pw, salt, nStr, rStr, pStr, dkLenStr) {
979
1163
  let out = "";
980
1164
  // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
981
1165
  Porffor.c`
982
- const char* __p; size_t __pl; char* __po = 0;
983
- porf_native_fetch_read_value(pw, &__p, &__pl, &__po);
984
- unsigned char* __pc = (unsigned char*)malloc(__pl ? __pl : 1);
1166
+ const char* __p = 0; size_t __pl = 0;
1167
+ int __pw_raw = porf_native_fetch_read_raw_bytes(pw, &__p, &__pl);
1168
+ unsigned char* __pc = __pw_raw == 0 ? (unsigned char*)malloc(__pl ? __pl : 1) : 0;
985
1169
  if (__pc) memcpy(__pc, __p, __pl);
986
- if (__po) free(__po);
987
1170
 
988
- const char* __s; size_t __sl; char* __so = 0;
989
- porf_native_fetch_read_value(salt, &__s, &__sl, &__so);
990
- unsigned char* __sc = (unsigned char*)malloc(__sl ? __sl : 1);
1171
+ const char* __s = 0; size_t __sl = 0;
1172
+ int __salt_raw = porf_native_fetch_read_raw_bytes(salt, &__s, &__sl);
1173
+ unsigned char* __sc = __salt_raw == 0 ? (unsigned char*)malloc(__sl ? __sl : 1) : 0;
991
1174
  if (__sc) memcpy(__sc, __s, __sl);
992
- if (__so) free(__so);
993
1175
 
994
1176
  long __N = 0, __r = 0, __pp = 0, __dk = 0;
995
1177
  { const char* q; size_t ql; char* qo = 0; char nb[24];
@@ -1637,7 +1819,8 @@ function __sbTriggerAuthed(request) {
1637
1819
  // #163 — the connection's remote address, for `request.cf.clientIp`.
1638
1820
  //
1639
1821
  // `x-sb-remote-addr` is the TCP peer, appended by the server (see
1640
- // patches/UPSTREAM.md #163) and stripped from anything a client sends, so it
1822
+ // https://github.com/baronunread/sproutboat/blob/main/patches/upstream/remote-address.md)
1823
+ // and stripped from anything a client sends, so it
1641
1824
  // cannot be forged. With no proxy in front, that is the client. Behind one, set
1642
1825
  // `SB_TRUSTED_PROXIES` to a comma-separated list of trusted CIDRs (or bare IPs):
1643
1826
  // when the peer is trusted, the client is the rightmost `x-forwarded-for` entry
@@ -1854,6 +1854,11 @@ function __sbEnsureSchema() {
1854
1854
  s,
1855
1855
  "CREATE TABLE IF NOT EXISTS ratelimit (name TEXT NOT NULL, key TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, PRIMARY KEY (name, key))",
1856
1856
  );
1857
+ // #59 — caches.default, ambient like the broker's copy of this table.
1858
+ __sbSql(
1859
+ s,
1860
+ "CREATE TABLE IF NOT EXISTS cache (name TEXT NOT NULL, cache_key TEXT NOT NULL, status INTEGER NOT NULL, headers_json TEXT NOT NULL, body TEXT NOT NULL, expires_at INTEGER, PRIMARY KEY (name, cache_key))",
1861
+ );
1857
1862
  }
1858
1863
 
1859
1864
  function __sbHex(n) {
@@ -2426,6 +2431,48 @@ function __sbEmbeddedDispatch(msg) {
2426
2431
  return { ok: true, success: count <= limit, resetAt: (windowStart + period) * 1000 };
2427
2432
  }
2428
2433
 
2434
+ if (op === "cache.match") {
2435
+ const name = msg.name || "default";
2436
+ const r = __sbSql(store, "SELECT status, headers_json, body, expires_at FROM cache WHERE name = ? AND cache_key = ?", [
2437
+ name,
2438
+ msg.key,
2439
+ ]);
2440
+ if (!r.rows.length) return { ok: true, found: false };
2441
+ const [status, headersJson, body, expiresAt] = r.rows[0];
2442
+ if (expiresAt != null && expiresAt <= Date.now()) {
2443
+ __sbSql(store, "DELETE FROM cache WHERE name = ? AND cache_key = ?", [name, msg.key]);
2444
+ return { ok: true, found: false };
2445
+ }
2446
+ return { ok: true, found: true, status, headers: JSON.parse(headersJson), body };
2447
+ }
2448
+ if (op === "cache.put") {
2449
+ const name = msg.name || "default";
2450
+ const status = Number(msg.status) || 200;
2451
+ const headers = msg.headers || {};
2452
+ const cacheControl = String(headers["cache-control"] || headers["Cache-Control"] || "").toLowerCase();
2453
+ // Mirrors Workers' own caches.default.put(): a 206 or an explicit
2454
+ // no-store/private is a silent no-op, not an error.
2455
+ if (status === 206 || cacheControl.indexOf("no-store") !== -1 || cacheControl.indexOf("private") !== -1) {
2456
+ return { ok: true, stored: false };
2457
+ }
2458
+ const sMaxAge = /s-maxage=(\d+)/.exec(cacheControl);
2459
+ const maxAge = /max-age=(\d+)/.exec(cacheControl);
2460
+ const ttlSeconds = sMaxAge ? Number(sMaxAge[1]) : maxAge ? Number(maxAge[1]) : null;
2461
+ const expiresAt = ttlSeconds != null ? Date.now() + ttlSeconds * 1000 : null;
2462
+ __sbSql(
2463
+ store,
2464
+ "INSERT INTO cache (name, cache_key, status, headers_json, body, expires_at) VALUES (?1,?2,?3,?4,?5,?6) " +
2465
+ "ON CONFLICT (name, cache_key) DO UPDATE SET status = ?3, headers_json = ?4, body = ?5, expires_at = ?6",
2466
+ [name, msg.key, status, JSON.stringify(headers), msg.body, expiresAt],
2467
+ );
2468
+ return { ok: true, stored: true };
2469
+ }
2470
+ if (op === "cache.delete") {
2471
+ const name = msg.name || "default";
2472
+ const r = __sbSql(store, "DELETE FROM cache WHERE name = ? AND cache_key = ?", [name, msg.key]);
2473
+ return { ok: true, deleted: r.changes > 0 };
2474
+ }
2475
+
2429
2476
  throw new Error("unknown op: " + op);
2430
2477
  }
2431
2478
 
@@ -0,0 +1,128 @@
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
+ // These values come from Bun WebCrypto and node:crypto, not from the runtime
9
+ // under test. A sign/verify round trip would miss a shared input corruption.
10
+ const expected = {
11
+ rounds: [
12
+ "e94df4f03406bc36299dd6e6961b22b50fc963fbbf8aeb773f8d7960a77e741e",
13
+ "1321aaf34d2ad833bd0c8af3ab6c3a91f2be090fd9631538c88ec3986b6eac28",
14
+ ],
15
+ digest: "89273d2f70b93285bb7ddb4bcee86a5347ca7159352e3cbdd20c23e9d1e507d3",
16
+ keyed: "ae70bbf366f791b87ae2c2642bf5bee0e7115f09ec9d15d407b8e5fb60ae933c",
17
+ scrypt: true,
18
+ };
19
+
20
+ const handler = `
21
+ import { keyBytes } from "./crypto-helper.js";
22
+ const hex = (buffer) => {
23
+ const bytes = new Uint8Array(buffer);
24
+ let out = "";
25
+ for (let i = 0; i < bytes.length; i++) out += (bytes[i] + 0x100).toString(16).slice(1);
26
+ return out;
27
+ };
28
+ export default {
29
+ async fetch() {
30
+ const key = await crypto.subtle.importKey(
31
+ "raw",
32
+ keyBytes(),
33
+ { name: "HMAC", hash: "SHA-256" },
34
+ false,
35
+ ["sign"],
36
+ );
37
+ let data = new TextEncoder().encode("h1-regression-password-0001");
38
+ const rounds = [];
39
+ for (let i = 0; i < 2; i++) {
40
+ data = new Uint8Array(await crypto.subtle.sign("HMAC", key, data));
41
+ rounds.push(hex(data));
42
+ }
43
+ const binary = new Uint8Array([0x00, 0x7f, 0x80, 0xff]);
44
+ const binaryKey = await crypto.subtle.importKey(
45
+ "raw", new Uint8Array([0x80, 0xff]), { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
46
+ );
47
+ const digest = hex(await crypto.subtle.digest("SHA-256", binary));
48
+ const keyed = hex(await crypto.subtle.sign("HMAC", binaryKey, binary));
49
+ const scrypt = crypto.scryptVerify(
50
+ "café",
51
+ binary,
52
+ "4459ed251d645d761e6d111db6bedeb64a8e3e8a9ad2b30dce4550ae9fc35049",
53
+ { N: 16, r: 1, p: 1 },
54
+ );
55
+ return Response.json({ rounds, digest, keyed, scrypt });
56
+ },
57
+ };
58
+ `;
59
+
60
+ function freePort(): number {
61
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
62
+ const port = listener.port;
63
+ listener.stop(true);
64
+ return port;
65
+ }
66
+
67
+ const workdir = await mkdtemp(join(tmpdir(), "sb-native-crypto-"));
68
+ let server: ReturnType<typeof Bun.spawn> | undefined;
69
+ try {
70
+ const porffor = await ensurePorffor({ cacheRoot: workdir });
71
+ const node = Bun.which("node");
72
+ if (!node) throw new Error("native crypto test needs node on PATH");
73
+
74
+ const [core, broker] = await Promise.all([
75
+ readFile(preludePath, "utf8"),
76
+ readFile(transportPath("broker"), "utf8"),
77
+ ]);
78
+ assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
79
+ const prelude = core.replace(TRANSPORT_MARKER, broker);
80
+ const generated = join(workdir, "crypto.generated.js");
81
+ const binary = join(workdir, "crypto-native");
82
+ // This is a direct ESM import into Porffor, with no bundler in front of it.
83
+ await writeFile(join(workdir, "crypto-helper.js"),
84
+ "export const keyBytes = () => Uint8Array.from({ length: 16 }, (_, index) => index + 1);\n");
85
+ await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
86
+ await rm(binary, { force: true });
87
+
88
+ const compile = Bun.spawn([node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"], {
89
+ cwd: workdir,
90
+ env: process.env,
91
+ stdout: "pipe",
92
+ stderr: "pipe",
93
+ });
94
+ const [exitCode, stdout, stderr] = await Promise.all([
95
+ compile.exited,
96
+ new Response(compile.stdout).text(),
97
+ new Response(compile.stderr).text(),
98
+ ]);
99
+ assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
100
+
101
+ const port = freePort();
102
+ server = Bun.spawn([binary], {
103
+ cwd: workdir,
104
+ env: { ...process.env, PORT: String(port) },
105
+ stdout: "ignore",
106
+ stderr: "inherit",
107
+ });
108
+ let response: Response | undefined;
109
+ for (let attempt = 0; attempt < 50; attempt++) {
110
+ try {
111
+ response = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
112
+ break;
113
+ } catch {
114
+ if (server.exitCode !== null) break;
115
+ await Bun.sleep(100);
116
+ }
117
+ }
118
+ assert(response, "compiled crypto handler did not start");
119
+ assert.equal(response.status, 200);
120
+ assert.deepEqual(await response.json(), expected);
121
+ console.log("Native crypto vectors match Bun WebCrypto and node:crypto");
122
+ } finally {
123
+ if (server) {
124
+ server.kill(9);
125
+ await server.exited;
126
+ }
127
+ await rm(workdir, { recursive: true, force: true });
128
+ }