@sproutboat/toolchain 0.4.12 → 0.4.14

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,17 @@
1
1
  # @sproutboat/toolchain
2
2
 
3
+ ## 0.4.14
4
+
5
+ ### Patch Changes
6
+
7
+ - 6c1f605: Pin and vendor Porffor alpha-10 (`08ac7ee`). Update the compiler patches for UTF-16 JSON strings, surrogate-pair UTF-8 encoding, and `TextEncoder.encodeInto` character boundaries. Verify native Unicode and date behavior against the new source.
8
+
9
+ ## 0.4.13
10
+
11
+ ### Patch Changes
12
+
13
+ - bb1b6a2: Parse ISO date timezone offsets correctly in native Porffor builds, including zero fields and fractional seconds.
14
+
3
15
  ## 0.4.12
4
16
 
5
17
  ### Patch Changes
@@ -0,0 +1,9 @@
1
+ # Porffor alpha-10 upgrade audit
2
+
3
+ The source pin moves from alpha-9 (`de4eb58`) to the [alpha-10 release](https://github.com/CanadaHonk/porffor/releases/tag/alpha-10), commit `08ac7ee1077c05da2bec18dcca15197051e87b62`, published September 27, 2026. The vendored codeload archive has SHA-256 `d49ce6724efde555b4cdeea0d2610baddd4310153956c79b352d86ba9cf0f60f`.
4
+
5
+ Alpha-10 changes coroutine and `finally` handling, fixes object handling for `null`, replaces array storage with C reuse, and removes threads. Its `compiler/uwebsockets.js` still pins `360c276d609d59af56ae6932adb95154ace9f15f`, so the CLI's vendored uWebSockets archive does not change. The existing Sproutboat patch set applies without changing its anchors or markers.
6
+
7
+ The native package suite passed 152 tests. The CLI passed 103 tests, 28 broker checks, 30 standalone checks, all 17 default examples, and the five focused Worker/handler fixtures when linked to the local runtime and toolchain. The platform's 32-handler comparison compiled all 32 and matched Bun on 30, unchanged from alpha-9. The remaining two failures are non-ISO date parsing in `15-date-iso.js` and `16-date-parts.js`. See [the scorecard](PORFFOR_SCORECARD.md) for the exact results and patch inventory.
8
+
9
+ The source upgrade is ready for a toolchain package release. Existing CLI and platform lockfiles still resolve the published alpha-9 toolchain until that package is released and dependencies are refreshed.
@@ -0,0 +1,35 @@
1
+ # Porffor pin scorecard
2
+
3
+ Pin: alpha-10 (08ac7ee1077c05da2bec18dcca15197051e87b62). Compatibility report: 2026-09-28T07:08:44.821Z.
4
+
5
+ | Measure | Result |
6
+ | --- | ---: |
7
+ | Fixtures compiled | 32/32 |
8
+ | Behavior matches | 30/32 |
9
+ | Median / p95 compile, ms | 4468 / 7436 |
10
+ | Median / p95 binary, bytes | 983392 / 1016464 |
11
+ | Patch markers | 27 |
12
+ | Upstream files written | 6 |
13
+ | Fresh patch application | pass |
14
+
15
+ Previous snapshot: alpha-9-de4eb58.json. Matches 30/32 to 30/32; patch markers 27 to 27.
16
+
17
+ ## Patch inventory
18
+
19
+ - `compiler/render.js`: PORT_MARKER, CONSOLE_MARKER, BYTESTRING_MARKER, READ_RAW_MARKER
20
+ - `compiler/index.js`: LINK_MARKER, CFLAGS_MARKER
21
+ - `compiler/builtins/typedarray.js`: TYPED_ARRAY_FROM_MARKER
22
+ - `compiler/builtins/date.ts`: DATE_PARSER_MARKER
23
+ - `compiler/uwebsockets.js`: BODY_MARKER, STATUS_SIG_MARKER, STATUS_303_MARKER, STATUS_DEFAULT_MARKER, HDR_SIG_MARKER, HDR_CALL_MARKER, HDR_CAP_MARKER, HDR_SKIP_MARKER, HDR_APPEND_MARKER, READ_RAW_DECL_MARKER, FORBIDDEN_HDR_MARKER, WRITE_RESPONSE_MARKER, R2_TRANSFER_MARKER, R2_TRANSFER_CALL_MARKER, R2_DOWNLOAD_CACHE_MARKER, R2_RANGE_CACHE_MARKER, R2_CONDITIONAL_CACHE_MARKER, R2_STREAMING_CACHE_MARKER, R2_RANGE_HEADER_CACHE_MARKER
24
+ - `compiler/builtins_precompiled.js`: regenerated builtin table
25
+
26
+ Patch source SHA-256: `edf60b7104e2d0b8efb64e38459fd2d5616ccdf24995ad1d4ca07fa232db4eaf`.
27
+
28
+ ## Known behavior gaps
29
+
30
+ - 15-date-iso.js: request 3 mismatch: expected {"status":200,"body":"2008-03-04T23:00:00.000Z","content-type":null}, got {"status":200,"body":"0003-05-08T00:00:00.000Z","content-type":null}
31
+ - 16-date-parts.js: request 3 mismatch: expected {"status":200,"body":"{\"year\":2008,\"month\":3,\"day\":4}","content-type":"application/json;charset=utf-8"}, got {"status":200,"body":"{\"year\":3,\"month\":5,\"day\":8}","content-type":"application/json;charset=utf-8"}
32
+
33
+ ## Review
34
+
35
+ Use this scorecard with the full compatibility report and issue #35's build telemetry. If behavior regresses or patch work grows, review the individual patches and measure an alternate backend with the same fixtures.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/toolchain",
3
- "version": "0.4.12",
3
+ "version": "0.4.14",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,58 @@
1
+ {
2
+ "pin": "alpha-10 (08ac7ee1077c05da2bec18dcca15197051e87b62)",
3
+ "reportGeneratedAt": "2026-09-28T07:08:44.821Z",
4
+ "fixtures": 32,
5
+ "compiled": 32,
6
+ "matched": 30,
7
+ "medianCompileMs": 4468,
8
+ "p95CompileMs": 7436,
9
+ "medianBinaryBytes": 983392,
10
+ "p95BinaryBytes": 1016464,
11
+ "patchSourceSha256": "edf60b7104e2d0b8efb64e38459fd2d5616ccdf24995ad1d4ca07fa232db4eaf",
12
+ "patchMarkerCount": 27,
13
+ "patchTargets": {
14
+ "compiler/render.js": [
15
+ "PORT_MARKER",
16
+ "CONSOLE_MARKER",
17
+ "BYTESTRING_MARKER",
18
+ "READ_RAW_MARKER"
19
+ ],
20
+ "compiler/index.js": [
21
+ "LINK_MARKER",
22
+ "CFLAGS_MARKER"
23
+ ],
24
+ "compiler/builtins/typedarray.js": [
25
+ "TYPED_ARRAY_FROM_MARKER"
26
+ ],
27
+ "compiler/builtins/date.ts": [
28
+ "DATE_PARSER_MARKER"
29
+ ],
30
+ "compiler/uwebsockets.js": [
31
+ "BODY_MARKER",
32
+ "STATUS_SIG_MARKER",
33
+ "STATUS_303_MARKER",
34
+ "STATUS_DEFAULT_MARKER",
35
+ "HDR_SIG_MARKER",
36
+ "HDR_CALL_MARKER",
37
+ "HDR_CAP_MARKER",
38
+ "HDR_SKIP_MARKER",
39
+ "HDR_APPEND_MARKER",
40
+ "READ_RAW_DECL_MARKER",
41
+ "FORBIDDEN_HDR_MARKER",
42
+ "WRITE_RESPONSE_MARKER",
43
+ "R2_TRANSFER_MARKER",
44
+ "R2_TRANSFER_CALL_MARKER",
45
+ "R2_DOWNLOAD_CACHE_MARKER",
46
+ "R2_RANGE_CACHE_MARKER",
47
+ "R2_CONDITIONAL_CACHE_MARKER",
48
+ "R2_STREAMING_CACHE_MARKER",
49
+ "R2_RANGE_HEADER_CACHE_MARKER"
50
+ ],
51
+ "compiler/builtins_precompiled.js": []
52
+ },
53
+ "patchApplication": "pass",
54
+ "mismatches": [
55
+ "15-date-iso.js: request 3 mismatch: expected {\"status\":200,\"body\":\"2008-03-04T23:00:00.000Z\",\"content-type\":null}, got {\"status\":200,\"body\":\"0003-05-08T00:00:00.000Z\",\"content-type\":null}",
56
+ "16-date-parts.js: request 3 mismatch: expected {\"status\":200,\"body\":\"{\\\"year\\\":2008,\\\"month\\\":3,\\\"day\\\":4}\",\"content-type\":\"application/json;charset=utf-8\"}, got {\"status\":200,\"body\":\"{\\\"year\\\":3,\\\"month\\\":5,\\\"day\\\":8}\",\"content-type\":\"application/json;charset=utf-8\"}"
57
+ ]
58
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "pin": "alpha-9 (de4eb588264885b3a1596f75010e371a2052033f)",
3
+ "reportGeneratedAt": "2026-09-26T00:32:42.739Z",
4
+ "fixtures": 32,
5
+ "compiled": 32,
6
+ "matched": 30,
7
+ "medianCompileMs": 3139,
8
+ "p95CompileMs": 3472,
9
+ "medianBinaryBytes": 983392,
10
+ "p95BinaryBytes": 1016464,
11
+ "patchSourceSha256": "69cf5d0bcf3f4d4c05b81df0ff04b83479af622539fa19566155531bf3ee469c",
12
+ "patchMarkerCount": 27,
13
+ "patchTargets": {
14
+ "compiler/render.js": [
15
+ "PORT_MARKER",
16
+ "CONSOLE_MARKER",
17
+ "BYTESTRING_MARKER",
18
+ "READ_RAW_MARKER"
19
+ ],
20
+ "compiler/index.js": [
21
+ "LINK_MARKER",
22
+ "CFLAGS_MARKER"
23
+ ],
24
+ "compiler/builtins/typedarray.js": [
25
+ "TYPED_ARRAY_FROM_MARKER"
26
+ ],
27
+ "compiler/builtins/date.ts": [
28
+ "DATE_PARSER_MARKER"
29
+ ],
30
+ "compiler/uwebsockets.js": [
31
+ "BODY_MARKER",
32
+ "STATUS_SIG_MARKER",
33
+ "STATUS_303_MARKER",
34
+ "STATUS_DEFAULT_MARKER",
35
+ "HDR_SIG_MARKER",
36
+ "HDR_CALL_MARKER",
37
+ "HDR_CAP_MARKER",
38
+ "HDR_SKIP_MARKER",
39
+ "HDR_APPEND_MARKER",
40
+ "READ_RAW_DECL_MARKER",
41
+ "FORBIDDEN_HDR_MARKER",
42
+ "WRITE_RESPONSE_MARKER",
43
+ "R2_TRANSFER_MARKER",
44
+ "R2_TRANSFER_CALL_MARKER",
45
+ "R2_DOWNLOAD_CACHE_MARKER",
46
+ "R2_RANGE_CACHE_MARKER",
47
+ "R2_CONDITIONAL_CACHE_MARKER",
48
+ "R2_STREAMING_CACHE_MARKER",
49
+ "R2_RANGE_HEADER_CACHE_MARKER"
50
+ ],
51
+ "compiler/builtins_precompiled.js": []
52
+ },
53
+ "patchApplication": "pass",
54
+ "mismatches": [
55
+ "15-date-iso.js: request 3 mismatch: expected {\"status\":200,\"body\":\"2008-03-04T23:00:00.000Z\",\"content-type\":null}, got {\"status\":200,\"body\":\"0003-05-08T00:00:00.000Z\",\"content-type\":null}",
56
+ "16-date-parts.js: request 3 mismatch: expected {\"status\":200,\"body\":\"{\\\"year\\\":2008,\\\"month\\\":3,\\\"day\\\":4}\",\"content-type\":\"application/json;charset=utf-8\"}, got {\"status\":200,\"body\":\"{\\\"year\\\":3,\\\"month\\\":5,\\\"day\\\":8}\",\"content-type\":\"application/json;charset=utf-8\"}"
57
+ ]
58
+ }
@@ -16,7 +16,7 @@ async function fixture(): Promise<{ archive: string; sha256: string }> {
16
16
  const source = join(root, `porffor-${PORFFOR_COMMIT_FULL}`);
17
17
  const files = {
18
18
  "runtime/index.js": "fixture:runtime/index.js\n",
19
- "compiler/render.js":
19
+ "compiler/render.js": "// sb_native_utf8_scalar_v1\n" +
20
20
  "void porf_native_fetch_runtime_init(void) {\n signal(SIGPIPE, SIG_IGN);\n porf_init(0, NULL);\n}\n" +
21
21
  "f64 porf_native_fetch_get_port(void) {\nfixture:compiler/render.js\n" +
22
22
  "int porf_native_fetch_read_value(jsval value, const char** out_buf, size_t* out_len, char** out_owned) {\n" +
@@ -34,6 +34,7 @@ async function fixture(): Promise<{ archive: string; sha256: string }> {
34
34
  "const path = require('node:path');\n" +
35
35
  "fs.writeFileSync(path.join(__dirname, 'builtins_precompiled.js'), fs.readFileSync(path.join(__dirname, 'builtins/typedarray.js')));\n",
36
36
  "compiler/builtins_precompiled.js": "fixture:precompiled\n",
37
+ "compiler/builtins/date.ts": "export const __ecma262_ParseDTSF = (string: string) => {\n let n: number = 0;\n let nInd: number = 0;\n\n const len: i32 = string.length;\n const endPtr: i32 = Porffor.IR.ptr(string) + len;\n let ptr: i32 = Porffor.IR.ptr(string);\n\n while (ptr <= endPtr) { // <= to include extra null byte to set last n\n const chr: i32 = Porffor.IR.loadU8(ptr++, 4);\n if (Porffor.fastAnd(chr >= 48, chr <= 57)) { // 0-9\n n *= 10;\n n += chr - 48;\n continue;\n }\n\n if (chr == 45) { // -\n if (Porffor.fastOr(ptr == Porffor.IR.ptr(string), nInd == 7)) n = -n;\n }\n\n if (n > 0) {\n if (nInd == 0) y = n;\n else if (nInd == 1) m = n - 1;\n else if (nInd == 2) dt = n;\n else if (nInd == 3) h = n;\n else if (nInd == 4) min = n;\n else if (nInd == 5) s = n;\n else if (nInd == 6) milli = n;\n else if (nInd == 7) tzHour = n;\n else if (nInd == 8) tzMin = n;\n\n n = 0;\n nInd++;\n }\n }\n\n h += tzHour;\n min += tzMin;\n};\n// RFC 7231 or Date.prototype.toString() parser\n",
37
38
  "compiler/builtins/typedarray.js":
38
39
  "export const __${name}_from = (arg: any, mapFn: any): ${name} => {\n" +
39
40
  " const arr: any[] = Porffor.array.new(4);\n" +
@@ -112,6 +113,8 @@ async function fixture(): Promise<{ archive: string; sha256: string }> {
112
113
  "\n" +
113
114
  " if (body_owned) free(body_owned);\n" +
114
115
  "}\n",
116
+ "compiler/builtins/json.ts": "// sb_json_utf16_v1\n",
117
+ "runtime/fetch-globals.js": "// sb_text_encoder_scalar_v1\n",
115
118
  "compiler/builtins/promise.ts":
116
119
  "export const __Porffor_promise_resolve = (value: any, promise: any): void => {\n" +
117
120
  " if (Porffor.type(value) == Porffor.TYPES.object) {\n" +
package/src/acquire.ts CHANGED
@@ -5,7 +5,7 @@ import { homedir } from "node:os";
5
5
  import { resolve } from "node:path";
6
6
  // @ts-expect-error Bun's file loader supplies a path; see the pinned-commit
7
7
  // check below for why this is safe to trust unconditionally.
8
- import vendoredPorfforArchive from "../vendor/porffor-de4eb58.tar.gz" with { type: "file" };
8
+ import vendoredPorfforArchive from "../vendor/porffor-08ac7ee.tar.gz" with { type: "file" };
9
9
  import { ensurePorfforPatched } from "./patch";
10
10
  import { PORFFOR_ARCHIVE_SHA256, PORFFOR_ARCHIVE_URL, PORFFOR_COMMIT_FULL } from "./pin";
11
11
  // Folded into the cache manifest below so a toolchain release that changes
@@ -38,6 +38,7 @@ const required = [
38
38
  "compiler/index.js",
39
39
  "compiler/uwebsockets.js",
40
40
  "compiler/precompile.js",
41
+ "compiler/builtins/date.ts",
41
42
  "compiler/builtins/typedarray.js",
42
43
  "compiler/builtins_precompiled.js",
43
44
  ];
@@ -0,0 +1,37 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { resolve } from "node:path";
5
+ import { ensurePorffor } from "./acquire";
6
+
7
+ test("native ISO dates honor signed offsets, zero fields, and fractional seconds", async () => {
8
+ const root = await mkdtemp(resolve(tmpdir(), "sb-date-native-"));
9
+ const samples = [
10
+ "2024-01-02T03:04:05Z",
11
+ "2024-01-02T03:04:05+02:00",
12
+ "2024-01-02T03:04:05-05:00",
13
+ "2024-01-02T03:04:05.250+01:00",
14
+ "2024-01-02T00:00:00Z",
15
+ "2024-01-02T03:04:05.2+02:30",
16
+ "2024-01-02T03:04:05.25-00:30",
17
+ ];
18
+ try {
19
+ const porffor = await ensurePorffor({ cacheRoot: resolve(root, "cache") });
20
+ const source = resolve(root, "date.js");
21
+ const binary = resolve(root, "date-native");
22
+ await writeFile(source, `for (const value of ${JSON.stringify(samples)}) console.log(new Date(value).toISOString());\n`);
23
+ const compile = Bun.spawnSync(["bun", resolve(porffor, "runtime/index.js"), "native", source, "-o", binary, "-s"], {
24
+ cwd: root,
25
+ stdout: "pipe",
26
+ stderr: "pipe",
27
+ });
28
+ expect(compile.exitCode, Buffer.from(compile.stderr).toString()).toBe(0);
29
+ const run = Bun.spawnSync([binary], { stdout: "pipe", stderr: "pipe" });
30
+ expect(run.exitCode, Buffer.from(run.stderr).toString()).toBe(0);
31
+ expect(Buffer.from(run.stdout).toString().trim().split("\n")).toEqual(
32
+ samples.map((value) => new Date(value).toISOString()),
33
+ );
34
+ } finally {
35
+ await rm(root, { recursive: true, force: true });
36
+ }
37
+ }, 30_000);
package/src/patch.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Idempotent, marker-guarded in-place edits to Porffor's generated C
3
3
  * (`compiler/render.js`, `compiler/index.js`, `compiler/uwebsockets.js`,
4
- * `compiler/builtins/typedarray.js`). Run
4
+ * `compiler/builtins/typedarray.js`, `compiler/builtins/date.ts`). Run
5
5
  * from the build path, not a `postinstall` hook: package managers block
6
6
  * dependency lifecycle scripts by default, so a published `postinstall` would
7
7
  * silently not run.
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { readFile, realpath, writeFile } from "node:fs/promises";
16
16
  import { resolve } from "node:path";
17
+ import { patchUnicode } from "./unicode";
17
18
 
18
19
  // --- compiler/render.js: the native-fetch server, rendered as C text ---
19
20
 
@@ -193,6 +194,93 @@ const TYPED_ARRAY_FROM_INJECT =
193
194
  " }\n\n arr.length = len;\n\n return new ${name}(arr);\n};";
194
195
  const TYPED_ARRAY_FROM_MARKER = "sproutboat: TypedArray.from accepts array-like objects";
195
196
 
197
+ // Porffor alpha 9 treats +02:00 as two milliseconds and adds offset hours
198
+ // instead of subtracting them. Count fields even when they contain zero, and
199
+ // apply the signed offset after parsing the local wall-clock fields.
200
+ const DATE_PARSER_MARKER = "sproutboat: ISO timezone offset";
201
+ const DATE_PARSER_ANCHOR = ` let n: number = 0;
202
+ let nInd: number = 0;
203
+
204
+ const len: i32 = string.length;
205
+ const endPtr: i32 = Porffor.IR.ptr(string) + len;
206
+ let ptr: i32 = Porffor.IR.ptr(string);
207
+
208
+ while (ptr <= endPtr) { // <= to include extra null byte to set last n
209
+ const chr: i32 = Porffor.IR.loadU8(ptr++, 4);
210
+ if (Porffor.fastAnd(chr >= 48, chr <= 57)) { // 0-9
211
+ n *= 10;
212
+ n += chr - 48;
213
+ continue;
214
+ }
215
+
216
+ if (chr == 45) { // -
217
+ if (Porffor.fastOr(ptr == Porffor.IR.ptr(string), nInd == 7)) n = -n;
218
+ }
219
+
220
+ if (n > 0) {
221
+ if (nInd == 0) y = n;
222
+ else if (nInd == 1) m = n - 1;
223
+ else if (nInd == 2) dt = n;
224
+ else if (nInd == 3) h = n;
225
+ else if (nInd == 4) min = n;
226
+ else if (nInd == 5) s = n;
227
+ else if (nInd == 6) milli = n;
228
+ else if (nInd == 7) tzHour = n;
229
+ else if (nInd == 8) tzMin = n;
230
+
231
+ n = 0;
232
+ nInd++;
233
+ }
234
+ }
235
+
236
+ h += tzHour;
237
+ min += tzMin;`;
238
+ const DATE_PARSER_INJECT = ` // sproutboat: ISO timezone offset
239
+ let n: number = 0;
240
+ let nInd: number = 0;
241
+ let digits: i32 = 0;
242
+ let offsetSign: i32 = 0;
243
+
244
+ const len: i32 = string.length;
245
+ const endPtr: i32 = Porffor.IR.ptr(string) + len;
246
+ let ptr: i32 = Porffor.IR.ptr(string);
247
+
248
+ while (ptr <= endPtr) {
249
+ const chr: i32 = Porffor.IR.loadU8(ptr++, 4);
250
+ if (Porffor.fastAnd(chr >= 48, chr <= 57)) {
251
+ n = n * 10 + chr - 48;
252
+ digits++;
253
+ continue;
254
+ }
255
+
256
+ if (digits > 0) {
257
+ if (nInd == 0) y = n;
258
+ else if (nInd == 1) m = n - 1;
259
+ else if (nInd == 2) dt = n;
260
+ else if (nInd == 3) h = n;
261
+ else if (nInd == 4) min = n;
262
+ else if (nInd == 5) s = n;
263
+ else if (nInd == 6) {
264
+ while (digits < 3) { n *= 10; digits++; }
265
+ while (digits > 3) { n = Math.trunc(n / 10); digits--; }
266
+ milli = n;
267
+ }
268
+ else if (nInd == 7) tzHour = n;
269
+ else if (nInd == 8) tzMin = n;
270
+ n = 0;
271
+ nInd++;
272
+ digits = 0;
273
+ }
274
+
275
+ if (Porffor.fastOr(chr == 43, chr == 45) && nInd >= 6) {
276
+ offsetSign = chr == 43 ? 1 : -1;
277
+ nInd = 7;
278
+ }
279
+ }
280
+
281
+ h -= offsetSign * tzHour;
282
+ min -= offsetSign * tzMin;`;
283
+
196
284
  /**
197
285
  * #56 — make the inbound request-body limit configurable.
198
286
  *
@@ -856,6 +944,19 @@ async function patchCompilerArgs(root: string): Promise<void> {
856
944
  if (changed) await writeFile(file, src);
857
945
  }
858
946
 
947
+ export async function patchDateParser(root: string): Promise<void> {
948
+ const file = resolve(root, "compiler/builtins/date.ts");
949
+ const src = await readFile(file, "utf8");
950
+ if (src.includes(DATE_PARSER_MARKER)) return;
951
+ const start = src.indexOf("export const __ecma262_ParseDTSF");
952
+ const end = src.indexOf("// RFC 7231 or Date.prototype.toString() parser", start);
953
+ if (start < 0 || end < 0) throw new Error(`could not find Porffor's ISO date parser in ${file}`);
954
+ const parser = src.slice(start, end);
955
+ if (!parser.includes(DATE_PARSER_ANCHOR))
956
+ throw new Error(`could not patch Porffor's ISO date parser: anchor not found in ${file}`);
957
+ await writeFile(file, src.slice(0, start) + parser.replace(DATE_PARSER_ANCHOR, DATE_PARSER_INJECT) + src.slice(end));
958
+ }
959
+
859
960
  export async function patchTypedArrayFrom(root: string): Promise<void> {
860
961
  const file = resolve(root, "compiler/builtins/typedarray.js");
861
962
  const src = await readFile(file, "utf8");
@@ -930,6 +1031,8 @@ export async function ensurePorfforPatched(root: string): Promise<void> {
930
1031
  await patchCompilerArgs(root);
931
1032
  await patchUwebsockets(root);
932
1033
  await patchRenderJs(root);
1034
+ await patchDateParser(root);
933
1035
  await patchTypedArrayFrom(root);
1036
+ await patchUnicode(root);
934
1037
  done.add(root);
935
1038
  }
package/src/pin.ts CHANGED
@@ -11,14 +11,14 @@
11
11
  * uWebSockets archive in `sproutboat-cli/vendor/` — then run both repos'
12
12
  * test + conformance suites. See sproutboat-cli/MIGRATION.md.
13
13
  */
14
- export const PORFFOR_CHANNEL = "alpha-9";
15
- export const PORFFOR_COMMIT_FULL = "de4eb588264885b3a1596f75010e371a2052033f";
14
+ export const PORFFOR_CHANNEL = "alpha-10";
15
+ export const PORFFOR_COMMIT_FULL = "08ac7ee1077c05da2bec18dcca15197051e87b62";
16
16
  export const PORFFOR_COMMIT = PORFFOR_COMMIT_FULL.slice(0, 7);
17
17
  export const PORFFOR_ARCHIVE_SHA256 =
18
- "1a62187a93356b36cb6ac703ce9770c8917e4fa65545fd7c33ee8a180ae5a4c9";
18
+ "d49ce6724efde555b4cdeea0d2610baddd4310153956c79b352d86ba9cf0f60f";
19
19
  export const PORFFOR_ARCHIVE_URL = `https://codeload.github.com/CanadaHonk/porffor/tar.gz/${PORFFOR_COMMIT_FULL}`;
20
20
 
21
- /** A compact identity string for a manifest / report (`alpha-9 (de4eb58)`). */
21
+ /** A compact identity string for a manifest / report (`alpha-10 (08ac7ee)`). */
22
22
  export function porfforVersion(): string {
23
23
  return process.env.PORFFOR_VERSION || `${PORFFOR_CHANNEL} (${PORFFOR_COMMIT})`;
24
24
  }
@@ -0,0 +1,113 @@
1
+ import { expect, test } from "bun:test";
2
+ import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ensurePorffor } from "./acquire";
6
+ import { ensurePorfforPatched } from "./patch";
7
+ import {
8
+ preludePath,
9
+ TRANSPORT_MARKER,
10
+ transportPath,
11
+ wrapNativeFetchHandler,
12
+ } from "../../runtime/src/wrap";
13
+
14
+ // Bun tests alone cannot expose Porffor's string representations or native
15
+ // wire encoding. Compile a real HTTP handler and compare it with Bun vectors.
16
+ test("native Unicode JSON and UTF-8 encoding preserve code points and buffer boundaries", async () => {
17
+ const work = await mkdtemp(join(tmpdir(), "sb-unicode-native-"));
18
+ let child: ReturnType<typeof Bun.spawn> | undefined;
19
+ try {
20
+ // Start from the verified pinned checkout, then patch only a private copy.
21
+ // SPROUTBOAT_PORFFOR_DIR can point at an already-isolated dev compiler.
22
+ const compiler = await ensurePorffor();
23
+ const isolated = join(work, "porffor");
24
+ await cp(compiler, isolated, { recursive: true });
25
+ await ensurePorfforPatched(isolated);
26
+ const inputs = ["ASCII", "caffè", "東京", "🚤", "x🚤y", "\ud800", "\udc00", "\ud800x"];
27
+ const source = join(work, "unicode.js");
28
+ const binary = join(work, "unicode-native");
29
+ const code = `export default { fetch() {
30
+ const values = ${JSON.stringify(inputs)};
31
+ const encoded = [];
32
+ const into = [];
33
+ for (const value of values) {
34
+ encoded.push(Array.from(new TextEncoder().encode(value)));
35
+ const rows = [];
36
+ for (let size = 0; size <= 6; size++) {
37
+ const dest = new Uint8Array(size);
38
+ const counts = new TextEncoder().encodeInto(value, dest);
39
+ rows.push({ counts, bytes: Array.from(dest) });
40
+ }
41
+ into.push(rows);
42
+ }
43
+ const nested = JSON.parse('{"東京":{"id":"🚤","plain":"caffè"}}');
44
+ const escaped = JSON.parse('"\\\\ud83d\\\\udea4"');
45
+ const long = "🚤".repeat(4096);
46
+ const growth = JSON.parse(JSON.stringify({ value: long })).value;
47
+ const negatives = [];
48
+ for (const invalid of ['{', '{} trailing', '"\\\\uZZZZ"', '[1,]']) {
49
+ let rejected = false;
50
+ try { JSON.parse(invalid); } catch { rejected = true; }
51
+ negatives.push(rejected);
52
+ }
53
+ const lone = JSON.parse(JSON.stringify({ value: String.fromCharCode(0xd800) }));
54
+ const quotedKey = JSON.parse(JSON.stringify({ ['quote"key']: "🚤" }));
55
+ return Response.json({ encoded, into, nested, escaped, growth, negatives, lone, quotedKey });
56
+ } };`;
57
+ const [prelude, transport] = await Promise.all([
58
+ readFile(preludePath, "utf8"),
59
+ readFile(transportPath("broker"), "utf8"),
60
+ ]);
61
+ await writeFile(
62
+ source,
63
+ wrapNativeFetchHandler(code, prelude.replace(TRANSPORT_MARKER, transport)),
64
+ );
65
+ const compile = Bun.spawnSync(
66
+ ["bun", join(isolated, "runtime/index.js"), "native", source, "-o", binary, "-s"],
67
+ { cwd: work, stdout: "pipe", stderr: "pipe" },
68
+ );
69
+ expect(compile.exitCode, compile.stderr.toString()).toBe(0);
70
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
71
+ const port = listener.port;
72
+ listener.stop(true);
73
+ child = Bun.spawn([binary], {
74
+ env: { ...process.env, PORT: String(port) },
75
+ stdout: "ignore",
76
+ stderr: "pipe",
77
+ });
78
+ let response: Response | undefined;
79
+ for (let attempt = 0; attempt < 100; attempt++) {
80
+ try {
81
+ response = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
82
+ break;
83
+ } catch {}
84
+ if (child.exitCode !== null) break;
85
+ await Bun.sleep(50);
86
+ }
87
+ expect(response).toBeDefined();
88
+ const actual = await response!.json();
89
+ expect(actual.encoded).toEqual(
90
+ inputs.map((input) => Array.from(new TextEncoder().encode(input))),
91
+ );
92
+ expect(actual.into).toEqual(
93
+ inputs.map((input) =>
94
+ Array.from({ length: 7 }, (_, size) => {
95
+ const bytes = new Uint8Array(size);
96
+ return { counts: new TextEncoder().encodeInto(input, bytes), bytes: Array.from(bytes) };
97
+ }),
98
+ ),
99
+ );
100
+ expect(actual.nested).toEqual({ 東京: { id: "🚤", plain: "caffè" } });
101
+ expect(actual.escaped).toBe("🚤");
102
+ expect(actual.growth).toBe("🚤".repeat(4096));
103
+ expect(actual.lone).toEqual({ value: "\ud800" });
104
+ expect(actual.quotedKey).toEqual({ 'quote"key': "🚤" });
105
+ expect(actual.negatives).toEqual([true, true, true, true]);
106
+ } finally {
107
+ if (child) {
108
+ child.kill(9);
109
+ await child.exited;
110
+ }
111
+ await rm(work, { recursive: true, force: true });
112
+ }
113
+ }, 60_000);
@@ -0,0 +1,42 @@
1
+ import { expect, test } from "bun:test";
2
+ import { unicodeEncoderSource } from "./unicode";
3
+ const source = unicodeEncoderSource("class TextEncoder { }\nclass TextDecoder { }");
4
+ // oxlint-disable-next-line no-function-constructor -- Test the replacement source inserted into Porffor's fetch globals.
5
+ const Encoder = new Function(`${source}\nreturn TextEncoder;`)();
6
+
7
+ test("UTF-8 encoding matches WebCrypto inputs for BMP, astral and lone surrogates", () => {
8
+ for (const input of [
9
+ "",
10
+ "ASCII",
11
+ "caffè",
12
+ "東京",
13
+ "🚤",
14
+ "x🚤y",
15
+ "\ud800",
16
+ "\udc00",
17
+ "\ud800x",
18
+ "🚤".repeat(1000),
19
+ ]) {
20
+ expect(Array.from(new Encoder().encode(input))).toEqual(
21
+ Array.from(new TextEncoder().encode(input)),
22
+ );
23
+ }
24
+ });
25
+
26
+ test("encodeInto stops before a split UTF-8 character and counts UTF-16 units", () => {
27
+ for (const input of ["🚤", "x🚤y", "éx", "\ud800x"]) {
28
+ for (let size = 0; size <= 10; size++) {
29
+ const actual = new Uint8Array(size);
30
+ const expected = new Uint8Array(size);
31
+ expect(new Encoder().encodeInto(input, actual)).toEqual(
32
+ new TextEncoder().encodeInto(input, expected),
33
+ );
34
+ expect(actual).toEqual(expected);
35
+ }
36
+ }
37
+ });
38
+
39
+ test("encoder patch is idempotent and refuses a changed upstream layout", () => {
40
+ expect(unicodeEncoderSource(source)).toBe(source);
41
+ expect(() => unicodeEncoderSource("unexpected upstream")).toThrow();
42
+ });
package/src/unicode.ts ADDED
@@ -0,0 +1,233 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, realpath, writeFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+
5
+ const JSON_MARKER = "sb_json_utf16_v1";
6
+ const ENCODER_MARKER = "sb_text_encoder_scalar_v1";
7
+ function replace(source: string, anchor: string, replacement: string): string {
8
+ if (!source.includes(anchor)) throw new Error(`Porffor Unicode patch anchor missing: ${anchor}`);
9
+ return source.replaceAll(anchor, replacement);
10
+ }
11
+
12
+ // Keep Porffor's parser/serializer, but store UTF-16 code units instead of
13
+ // truncating them into byte buffers. Dynamic inputs may be either string form.
14
+ export function unicodeJsonSource(source: string): string {
15
+ if (source.includes(JSON_MARKER)) return source;
16
+ const start = source.indexOf("export const __Porffor_bytestring_bufferStr");
17
+ const end = source.indexOf("export const __Porffor_json_canSerialize");
18
+ if (start < 0 || end < start) throw new Error("Porffor JSON buffer helpers not found");
19
+ source =
20
+ source.slice(0, start) +
21
+ `
22
+ // ${JSON_MARKER}
23
+ export const __Porffor_bytestring_bufferStr = (buffer: i32, str: any): i32 => {
24
+ const len: i32 = str.length;
25
+ if (buffer + 4 + len * 2 > __Porffor_json_bufLimit) return buffer + len * 2;
26
+ for (let i: i32 = 0; i < len; i++) Porffor.IR.storeU16(buffer + i * 2, 4, str.charCodeAt(i));
27
+ return buffer + len * 2;
28
+ };
29
+ export const __Porffor_bytestring_bufferChar = (buffer: i32, char: i32): i32 => {
30
+ if (buffer + 6 > __Porffor_json_bufLimit) return buffer + 2;
31
+ Porffor.IR.storeU16(buffer, 4, char);
32
+ return buffer + 2;
33
+ };
34
+ export const __Porffor_bytestring_buffer2Char = (buffer: i32, a: i32, b: i32): i32 => {
35
+ buffer = __Porffor_bytestring_bufferChar(buffer, a);
36
+ return __Porffor_bytestring_bufferChar(buffer, b);
37
+ };
38
+ export const __Porffor_json_appendChar = (out: string, char: i32): void => {
39
+ const len: i32 = out.length;
40
+ Porffor.IR.storeU16(Porffor.IR.ptr(out) + len * 2, 4, char);
41
+ out.length = len + 1;
42
+ };
43
+ ` +
44
+ source.slice(end);
45
+ source = replace(source, "text: bytestring", "text: any");
46
+ source = replace(source, "(_: bytestring)", "(_: any)");
47
+ source = replace(
48
+ source,
49
+ "const out: bytestring = Porffor.malloc(6 + (strEnd - pos));",
50
+ "const out: string = Porffor.malloc(4 + (strEnd - pos) * 2);",
51
+ );
52
+ source = replace(source, "Porffor.bytestring.appendChar(out,", "__Porffor_json_appendChar(out,");
53
+ source = replace(
54
+ source,
55
+ "Porffor.callThis(__ByteString_prototype_slice, text, start, pos)",
56
+ "text.slice(start, pos)",
57
+ );
58
+ source = replace(
59
+ source,
60
+ "let buffer: bytestring = Porffor.malloc(6 + cap);",
61
+ "let buffer: string = Porffor.malloc(4 + cap);",
62
+ );
63
+ source = replace(
64
+ source,
65
+ "buffer = Porffor.malloc(6 + cap);",
66
+ "buffer = Porffor.malloc(4 + cap);",
67
+ );
68
+ source = replace(
69
+ source,
70
+ "buffer.length = out - (buffer as i32);",
71
+ "buffer.length = (out - (buffer as i32)) / 2;",
72
+ );
73
+ source = replace(source, "(buffer - _buffer) > 1", "(buffer - _buffer) > 2");
74
+ source = replace(source, "Porffor.IR.storeU8(buffer, 3,", "Porffor.IR.storeU16(buffer, 2,");
75
+ source = replace(source, "for (const key: bytestring in", "for (const key: any in");
76
+ // Serialize object keys through the string escaping path too.
77
+ source = replace(
78
+ source,
79
+ ` buffer = __Porffor_bytestring_bufferChar(buffer, 34); // "
80
+ buffer = __Porffor_bytestring_bufferStr(buffer, key);
81
+ buffer = __Porffor_bytestring_bufferChar(buffer, 34); // "`,
82
+ " buffer = __Porffor_json_serialize(buffer, key, depth, undefined);",
83
+ );
84
+ source = replace(
85
+ source,
86
+ ` // todo: support non-bytestrings
87
+ buffer = __Porffor_bytestring_bufferChar(buffer, c);`,
88
+ ` // Well-formed JSON.stringify escapes lone surrogate code units.
89
+ if ((c >= 0xd800 && c <= 0xdbff && (i + 1 >= len || value.charCodeAt(i + 1) < 0xdc00 || value.charCodeAt(i + 1) > 0xdfff)) ||
90
+ (c >= 0xdc00 && c <= 0xdfff && (i == 0 || value.charCodeAt(i - 1) < 0xd800 || value.charCodeAt(i - 1) > 0xdbff))) {
91
+ buffer = __Porffor_bytestring_buffer2Char(buffer, 92, 117);
92
+ for (let shift: i32 = 12; shift >= 0; shift -= 4) {
93
+ const digit: i32 = (c >> shift) & 15;
94
+ buffer = __Porffor_bytestring_bufferChar(buffer, digit < 10 ? digit + 48 : digit + 87);
95
+ }
96
+ continue;
97
+ }
98
+ buffer = __Porffor_bytestring_bufferChar(buffer, c);`,
99
+ );
100
+ // JSON.parse must reject trailing non-whitespace, not just parse a prefix.
101
+ source = replace(
102
+ source,
103
+ "return __Porffor_json_parseValue(_, posPtr, _.length);",
104
+ `const result: any = __Porffor_json_parseValue(_, posPtr, _.length);
105
+ if (__Porffor_json_skipWhitespace(_, Porffor.IR.loadI32(posPtr, 0), _.length) != _.length) throw new SyntaxError('Unexpected trailing JSON input');
106
+ return result;`,
107
+ );
108
+ return source;
109
+ }
110
+
111
+ const ENCODER = `class TextEncoder {
112
+ // ${ENCODER_MARKER}
113
+ get encoding() { return 'utf-8'; }
114
+ encode(input = '') {
115
+ const text = String(input);
116
+ let length = 0;
117
+ for (let i = 0; i < text.length; i++) {
118
+ const c = text.charCodeAt(i);
119
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length && text.charCodeAt(i + 1) >= 0xdc00 && text.charCodeAt(i + 1) <= 0xdfff) { length += 4; i++; }
120
+ else length += c < 0x80 ? 1 : c < 0x800 ? 2 : 3;
121
+ }
122
+ const out = new Uint8Array(length);
123
+ this.encodeInto(text, out);
124
+ return out;
125
+ }
126
+ encodeInto(source, destination) {
127
+ const text = String(source);
128
+ let read = 0;
129
+ let written = 0;
130
+ while (read < text.length) {
131
+ let cp = text.charCodeAt(read);
132
+ let units = 1;
133
+ if (cp >= 0xd800 && cp <= 0xdbff && read + 1 < text.length && text.charCodeAt(read + 1) >= 0xdc00 && text.charCodeAt(read + 1) <= 0xdfff) {
134
+ cp = 0x10000 + ((cp - 0xd800) << 10) + text.charCodeAt(read + 1) - 0xdc00;
135
+ units = 2;
136
+ } else if (cp >= 0xd800 && cp <= 0xdfff) cp = 0xfffd;
137
+ const count = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
138
+ if (written + count > destination.length) break;
139
+ if (count === 1) destination[written++] = cp;
140
+ else if (count === 2) {
141
+ destination[written++] = 0xc0 | (cp >> 6);
142
+ destination[written++] = 0x80 | (cp & 0x3f);
143
+ } else if (count === 3) {
144
+ destination[written++] = 0xe0 | (cp >> 12);
145
+ destination[written++] = 0x80 | ((cp >> 6) & 0x3f);
146
+ destination[written++] = 0x80 | (cp & 0x3f);
147
+ } else {
148
+ destination[written++] = 0xf0 | (cp >> 18);
149
+ destination[written++] = 0x80 | ((cp >> 12) & 0x3f);
150
+ destination[written++] = 0x80 | ((cp >> 6) & 0x3f);
151
+ destination[written++] = 0x80 | (cp & 0x3f);
152
+ }
153
+ read += units;
154
+ }
155
+ return { read, written };
156
+ }
157
+ }
158
+
159
+ `;
160
+ export function unicodeEncoderSource(source: string): string {
161
+ if (source.includes(ENCODER_MARKER)) return source;
162
+ const start = source.indexOf("class TextEncoder {");
163
+ const end = source.indexOf("class TextDecoder {", start);
164
+ if (start < 0 || end < start) throw new Error("Porffor TextEncoder block not found");
165
+ return source.slice(0, start) + ENCODER + source.slice(end);
166
+ }
167
+
168
+ export function unicodeWireSource(source: string): string {
169
+ const marker = "sb_native_utf8_scalar_v1";
170
+ if (source.includes(marker)) return source;
171
+ const start = source.indexOf(" if (value.type == ${TYPES.string}) {");
172
+ const end = source.indexOf(" utf8[out_len_local]", start);
173
+ if (start < 0 || end < start) throw new Error("Porffor UTF-16 wire encoder not found");
174
+ let block = source.slice(start, end);
175
+ block = replace(
176
+ block,
177
+ " u16 c = chars[i];",
178
+ ` // ${marker}
179
+ u32 c = chars[i];
180
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < len && chars[i + 1] >= 0xdc00 && chars[i + 1] <= 0xdfff) {
181
+ c = 0x10000 + ((c - 0xd800) << 10) + chars[++i] - 0xdc00;
182
+ } else if (c >= 0xd800 && c <= 0xdfff) c = 0xfffd;`,
183
+ );
184
+ block = replace(
185
+ block,
186
+ ` } else {
187
+ utf8[out_len_local++] = (char)(0xe0 | (c >> 12));
188
+ utf8[out_len_local++] = (char)(0x80 | ((c >> 6) & 0x3f));
189
+ utf8[out_len_local++] = (char)(0x80 | (c & 0x3f));
190
+ }`,
191
+ ` } else if (c < 0x10000) {
192
+ utf8[out_len_local++] = (char)(0xe0 | (c >> 12));
193
+ utf8[out_len_local++] = (char)(0x80 | ((c >> 6) & 0x3f));
194
+ utf8[out_len_local++] = (char)(0x80 | (c & 0x3f));
195
+ } else {
196
+ utf8[out_len_local++] = (char)(0xf0 | (c >> 18));
197
+ utf8[out_len_local++] = (char)(0x80 | ((c >> 12) & 0x3f));
198
+ utf8[out_len_local++] = (char)(0x80 | ((c >> 6) & 0x3f));
199
+ utf8[out_len_local++] = (char)(0x80 | (c & 0x3f));
200
+ }`,
201
+ );
202
+ return source.slice(0, start) + block + source.slice(end);
203
+ }
204
+
205
+ export async function patchUnicode(root: string): Promise<void> {
206
+ const renderPath = resolve(root, "compiler/render.js");
207
+ await writeFile(renderPath, unicodeWireSource(await readFile(renderPath, "utf8")));
208
+ const jsonPath = resolve(root, "compiler/builtins/json.ts");
209
+ const globalsPath = resolve(root, "runtime/fetch-globals.js");
210
+ const json = unicodeJsonSource(await readFile(jsonPath, "utf8"));
211
+ const globals = unicodeEncoderSource(await readFile(globalsPath, "utf8"));
212
+ await writeFile(jsonPath, json);
213
+ await writeFile(globalsPath, globals);
214
+ // Source edits alone do not change the builtin table used by native builds.
215
+ const digest = createHash("sha256").update(json).digest("hex");
216
+ const stamp = resolve(root, "compiler/.sb-unicode-json");
217
+ let previous = "";
218
+ try {
219
+ previous = await readFile(stamp, "utf8");
220
+ } catch {
221
+ /* first build */
222
+ }
223
+ if (previous === digest) return;
224
+ const precompile = await realpath(resolve(root, "compiler/precompile.js"));
225
+ const child = Bun.spawn(["bun", precompile], { cwd: root, stdout: "pipe", stderr: "pipe" });
226
+ const [code, stdout, stderr] = await Promise.all([
227
+ child.exited,
228
+ new Response(child.stdout).text(),
229
+ new Response(child.stderr).text(),
230
+ ]);
231
+ if (code !== 0) throw new Error(`could not precompile Unicode JSON: ${stderr || stdout}`);
232
+ await writeFile(stamp, digest);
233
+ }
Binary file
Binary file