@sproutboat/toolchain 0.4.13 → 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 +6 -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 +3 -1
- package/src/acquire.ts +1 -1
- package/src/patch.ts +2 -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,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 0.4.13
|
|
4
10
|
|
|
5
11
|
### 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" +
|
|
@@ -113,6 +113,8 @@ async function fixture(): Promise<{ archive: string; sha256: string }> {
|
|
|
113
113
|
"\n" +
|
|
114
114
|
" if (body_owned) free(body_owned);\n" +
|
|
115
115
|
"}\n",
|
|
116
|
+
"compiler/builtins/json.ts": "// sb_json_utf16_v1\n",
|
|
117
|
+
"runtime/fetch-globals.js": "// sb_text_encoder_scalar_v1\n",
|
|
116
118
|
"compiler/builtins/promise.ts":
|
|
117
119
|
"export const __Porffor_promise_resolve = (value: any, promise: any): void => {\n" +
|
|
118
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-
|
|
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.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
|
|
|
@@ -1032,5 +1033,6 @@ export async function ensurePorfforPatched(root: string): Promise<void> {
|
|
|
1032
1033
|
await patchRenderJs(root);
|
|
1033
1034
|
await patchDateParser(root);
|
|
1034
1035
|
await patchTypedArrayFrom(root);
|
|
1036
|
+
await patchUnicode(root);
|
|
1035
1037
|
done.add(root);
|
|
1036
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-
|
|
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
|