@sproutboat/toolchain 0.4.13 → 0.4.15
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 +12 -0
- package/PORFFOR_ALPHA10.md +9 -0
- package/PORFFOR_SCORECARD.md +4 -4
- package/package.json +1 -1
- package/scorecards/alpha-10-08ac7ee.json +58 -0
- package/src/acquire.test.ts +5 -2
- package/src/acquire.ts +1 -1
- package/src/patch.test.ts +7 -0
- package/src/patch.ts +21 -0
- package/src/pin.ts +4 -4
- package/src/unicode-native.test.ts +113 -0
- package/src/unicode.test.ts +42 -0
- package/src/unicode.ts +233 -0
- package/vendor/porffor-08ac7ee.tar.gz +0 -0
- package/vendor/porffor-de4eb58.tar.gz +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @sproutboat/toolchain
|
|
2
2
|
|
|
3
|
+
## 0.4.15
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c9f84ac: Raise Porffor's per-coroutine fiber stack from 256 KiB to 8 MiB. A large async handler compiled at -O0 (`sproutboat dev`) overflowed it within a few nested awaits and died with SIGBUS on its first request (baronunread/sproutboat#178). Only touched pages cost memory.
|
|
8
|
+
|
|
9
|
+
## 0.4.14
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
3
15
|
## 0.4.13
|
|
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.
|
package/PORFFOR_SCORECARD.md
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
# Porffor pin scorecard
|
|
2
2
|
|
|
3
|
-
Pin: alpha-
|
|
3
|
+
Pin: alpha-10 (08ac7ee1077c05da2bec18dcca15197051e87b62). Compatibility report: 2026-09-28T07:08:44.821Z.
|
|
4
4
|
|
|
5
5
|
| Measure | Result |
|
|
6
6
|
| --- | ---: |
|
|
7
7
|
| Fixtures compiled | 32/32 |
|
|
8
8
|
| Behavior matches | 30/32 |
|
|
9
|
-
| Median / p95 compile, ms |
|
|
9
|
+
| Median / p95 compile, ms | 4468 / 7436 |
|
|
10
10
|
| Median / p95 binary, bytes | 983392 / 1016464 |
|
|
11
11
|
| Patch markers | 27 |
|
|
12
12
|
| Upstream files written | 6 |
|
|
13
13
|
| Fresh patch application | pass |
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Previous snapshot: alpha-9-de4eb58.json. Matches 30/32 to 30/32; patch markers 27 to 27.
|
|
16
16
|
|
|
17
17
|
## Patch inventory
|
|
18
18
|
|
|
@@ -23,7 +23,7 @@ This is the first recorded pin. Compare the next pin against this snapshot.
|
|
|
23
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
24
|
- `compiler/builtins_precompiled.js`: regenerated builtin table
|
|
25
25
|
|
|
26
|
-
Patch source SHA-256: `
|
|
26
|
+
Patch source SHA-256: `edf60b7104e2d0b8efb64e38459fd2d5616ccdf24995ad1d4ca07fa232db4eaf`.
|
|
27
27
|
|
|
28
28
|
## Known behavior gaps
|
|
29
29
|
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/acquire.test.ts
CHANGED
|
@@ -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" +
|
|
@@ -27,7 +27,8 @@ async function fixture(): Promise<{ archive: string; sha256: string }> {
|
|
|
27
27
|
" return 0;\n" +
|
|
28
28
|
" }\n" +
|
|
29
29
|
" return -1;\n" +
|
|
30
|
-
"}\n"
|
|
30
|
+
"}\n" +
|
|
31
|
+
"#define PORF_CORO_STACK_SIZE (256u * 1024u)\n",
|
|
31
32
|
"compiler/index.js": " ...darwinReleaseCompileArgs,\n uSocketsArchive,\n '-lm'\n",
|
|
32
33
|
"compiler/precompile.js":
|
|
33
34
|
"const fs = require('node:fs');\n" +
|
|
@@ -113,6 +114,8 @@ async function fixture(): Promise<{ archive: string; sha256: string }> {
|
|
|
113
114
|
"\n" +
|
|
114
115
|
" if (body_owned) free(body_owned);\n" +
|
|
115
116
|
"}\n",
|
|
117
|
+
"compiler/builtins/json.ts": "// sb_json_utf16_v1\n",
|
|
118
|
+
"runtime/fetch-globals.js": "// sb_text_encoder_scalar_v1\n",
|
|
116
119
|
"compiler/builtins/promise.ts":
|
|
117
120
|
"export const __Porffor_promise_resolve = (value: any, promise: any): void => {\n" +
|
|
118
121
|
" 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-
|
|
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
|
package/src/patch.test.ts
CHANGED
|
@@ -35,6 +35,9 @@ int porf_native_fetch_read_value(jsval value, const char** out_buf, size_t* out_
|
|
|
35
35
|
|
|
36
36
|
return -1;
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
#define PORF_CORO_STACK_SIZE (256u * 1024u)
|
|
40
|
+
#define PORF_CORO_MAX 16384
|
|
38
41
|
`;
|
|
39
42
|
|
|
40
43
|
// Porffor's compiler/uwebsockets.js, trimmed to the parts the patch touches:
|
|
@@ -289,6 +292,10 @@ test("#165: render.js routes console output to stderr, unbuffered, idempotently"
|
|
|
289
292
|
"int porf_native_fetch_read_value(jsval value, const char** out_buf, size_t* out_len, char** out_owned) {",
|
|
290
293
|
);
|
|
291
294
|
|
|
295
|
+
// #178: fiber stacks big enough for -O0 frames in a few nested awaits.
|
|
296
|
+
expect(once).toContain("#define PORF_CORO_STACK_SIZE (8u * 1024u * 1024u)");
|
|
297
|
+
expect(once).not.toContain("(256u * 1024u)");
|
|
298
|
+
|
|
292
299
|
await patchRenderJs(root);
|
|
293
300
|
expect(await readFile(join(root, "compiler/render.js"), "utf8")).toBe(once);
|
|
294
301
|
} finally {
|
package/src/patch.ts
CHANGED
|
@@ -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
|
|
|
@@ -130,6 +131,24 @@ const READ_RAW_INJECT =
|
|
|
130
131
|
READ_RAW_ANCHOR;
|
|
131
132
|
const READ_RAW_MARKER = "porf_native_fetch_read_raw_bytes(jsval value";
|
|
132
133
|
|
|
134
|
+
/**
|
|
135
|
+
* baronunread/sproutboat#178: every async function runs on its own fiber
|
|
136
|
+
* stack, and alpha-10 fixes those at 256 KiB. A large handler compiled at -O0
|
|
137
|
+
* (what `sproutboat dev` builds) spends ~40 KiB of stack per call into the
|
|
138
|
+
* biggest generated functions, so a few nested awaits run off the end into
|
|
139
|
+
* the guard page and the sprout dies with SIGBUS on its first request, no log.
|
|
140
|
+
* -O3 frames are smaller, which only raises the threshold: a deep enough
|
|
141
|
+
* async chain hits it in a release build too.
|
|
142
|
+
*
|
|
143
|
+
* Match a thread's usual 8 MiB. The reservation is MAP_NORESERVE with a guard
|
|
144
|
+
* page, so only touched pages cost memory and a shallow handler pays nothing.
|
|
145
|
+
*/
|
|
146
|
+
const CORO_STACK_ANCHOR = "#define PORF_CORO_STACK_SIZE (256u * 1024u)";
|
|
147
|
+
const CORO_STACK_INJECT =
|
|
148
|
+
"// sproutboat #178: 256 KiB overflows -O0 frames in a few nested awaits.\n" +
|
|
149
|
+
"#define PORF_CORO_STACK_SIZE (8u * 1024u * 1024u)";
|
|
150
|
+
const CORO_STACK_MARKER = "sproutboat #178";
|
|
151
|
+
|
|
133
152
|
/**
|
|
134
153
|
* render.js full-block replacements: `[marker, anchor, inject, what]`, each a
|
|
135
154
|
* complete swap of the matched region (unlike `RENDER_EDITS`, which only ever
|
|
@@ -138,6 +157,7 @@ const READ_RAW_MARKER = "porf_native_fetch_read_raw_bytes(jsval value";
|
|
|
138
157
|
const RENDER_REPLACEMENTS = [
|
|
139
158
|
[BYTESTRING_MARKER, BYTESTRING_ANCHOR, BYTESTRING_INJECT, "bytestring UTF-8 encoding (#172)"],
|
|
140
159
|
[READ_RAW_MARKER, READ_RAW_ANCHOR, READ_RAW_INJECT, "raw bytestring passthrough function (#176)"],
|
|
160
|
+
[CORO_STACK_MARKER, CORO_STACK_ANCHOR, CORO_STACK_INJECT, "coroutine stack size (#178)"],
|
|
141
161
|
] as const;
|
|
142
162
|
|
|
143
163
|
// #15 — no `--port` flag: Porffor's native-fetch entry point calls
|
|
@@ -1032,5 +1052,6 @@ export async function ensurePorfforPatched(root: string): Promise<void> {
|
|
|
1032
1052
|
await patchRenderJs(root);
|
|
1033
1053
|
await patchDateParser(root);
|
|
1034
1054
|
await patchTypedArrayFrom(root);
|
|
1055
|
+
await patchUnicode(root);
|
|
1035
1056
|
done.add(root);
|
|
1036
1057
|
}
|
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-
|
|
15
|
-
export const PORFFOR_COMMIT_FULL = "
|
|
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
|
-
"
|
|
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-
|
|
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
|