@sproutboat/toolchain 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Request and Response classes for Porffor's runtime/fetch-globals.js (patchFetchClasses),
3
+ * and the native response finalizer's byte-body handling (patchResponseBytes). Found by
4
+ * sproutd's W2 conformance suite against workerd:
5
+ * - Response had no statusText or type, no Response.error, accepted any status, and read
6
+ * byte bodies as text ("1,2,3"); Request kept relative and unnormalized URLs, allowed
7
+ * a body on GET, had no redirect, and set no content type for its body.
8
+ * - The finalizer sent String(response.body), so a Uint8Array body reached the client as
9
+ * "104,105" and an ArrayBuffer as "[object ArrayBuffer]".
10
+ * Field names (url, method, headers, body, status) stay Porffor's: the native ingress builds
11
+ * Requests without the constructor, and the native writer reads those fields, so defaults
12
+ * for fields it does not set (redirect, statusText, type) are prototype getters.
13
+ */
14
+
15
+ export const REQUEST_CLASS_SOURCE = String.raw`class Request {
16
+ // sproutboat: Fetch-spec Request and Response
17
+ // Body init as Fetch extracts it: URLSearchParams becomes form text, and a text body gets
18
+ // its default content type unless one was given. Statics, shared with Response: plain
19
+ // top-level functions in this file are not visible from class methods.
20
+ static _extractBody(body, headers) {
21
+ // A Blob's bytes, with its type as the content type unless one was given.
22
+ if (body instanceof Blob) {
23
+ if (body.type != '' && !headers.has('content-type')) headers.set('content-type', body.type);
24
+ return body._bytes.slice();
25
+ }
26
+ // FormData as multipart/form-data. The prelude defines FormData and URLSearchParams on
27
+ // globalThis after this file is compiled.
28
+ const Form = globalThis.FormData;
29
+ if (Form != null && body instanceof Form) {
30
+ const boundary = '----sproutboat' + Math.random().toString(16).slice(2) + Math.random().toString(16).slice(2);
31
+ const encoder = new TextEncoder();
32
+ const parts = [];
33
+ for (const entry of body._entries) {
34
+ const name = String(entry[0]).replace(/"/g, '%22');
35
+ const value = entry[1];
36
+ if (value instanceof Blob) {
37
+ const filename = String(value.name ?? 'blob').replace(/"/g, '%22');
38
+ parts.push(encoder.encode('--' + boundary + '\r\nContent-Disposition: form-data; name="' + name + '"; filename="' + filename + '"\r\nContent-Type: ' + (value.type || 'application/octet-stream') + '\r\n\r\n'));
39
+ parts.push(value._bytes);
40
+ parts.push(encoder.encode('\r\n'));
41
+ } else parts.push(encoder.encode('--' + boundary + '\r\nContent-Disposition: form-data; name="' + name + '"\r\n\r\n' + String(value) + '\r\n'));
42
+ }
43
+ parts.push(encoder.encode('--' + boundary + '--\r\n'));
44
+ if (!headers.has('content-type')) headers.set('content-type', 'multipart/form-data; boundary=' + boundary);
45
+ return new Blob(parts)._bytes;
46
+ }
47
+ const Params = globalThis.URLSearchParams;
48
+ if (Params != null && body instanceof Params) {
49
+ if (!headers.has('content-type')) headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
50
+ return body.toString();
51
+ }
52
+ if (typeof body === 'string' && !headers.has('content-type')) headers.set('content-type', 'text/plain;charset=UTF-8');
53
+ return body;
54
+ }
55
+
56
+ static _bodyBytes(body) {
57
+ if (body == null) return new Uint8Array(0);
58
+ if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0));
59
+ if (ArrayBuffer.isView(body)) return new Uint8Array(body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength));
60
+ const text = (Porffor.type(body) | 0b10000000) == Porffor.TYPES.bytestring ? body : String(body);
61
+ return new TextEncoder().encode(text);
62
+ }
63
+
64
+ // A body is read once: the first reader marks it used, a second throws, as in Fetch.
65
+ static _consume(message) {
66
+ if (message._bodyUsed === true) throw new TypeError('Body has already been used');
67
+ message._bodyUsed = true;
68
+ return message.body;
69
+ }
70
+
71
+ static _bodyText(body) {
72
+ if (body == null) return '';
73
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return new TextDecoder().decode(body);
74
+ return (Porffor.type(body) | 0b10000000) == Porffor.TYPES.bytestring ? body : String(body);
75
+ }
76
+
77
+ constructor(input, init = undefined) {
78
+ let url = input;
79
+ let method = 'GET';
80
+ let headers = undefined;
81
+ let body = null;
82
+ let redirect = undefined;
83
+
84
+ if (input instanceof Request) {
85
+ url = input.url;
86
+ method = input.method;
87
+ headers = input.headers;
88
+ body = input.body;
89
+ redirect = input.redirect;
90
+ } else if (input != null && Porffor.type(input) == Porffor.TYPES.object && input.href != null) url = input.href;
91
+
92
+ if (init != null) {
93
+ if (init.method != null) method = init.method;
94
+ if (init.headers != null) headers = init.headers;
95
+ if (init.body !== undefined) body = init.body;
96
+ if (init.redirect != null) redirect = init.redirect;
97
+ }
98
+
99
+ const raw = (Porffor.type(url) | 0b10000000) == Porffor.TYPES.bytestring ? url : String(url);
100
+ let href;
101
+ try {
102
+ href = new URL(raw).href;
103
+ } catch (e) {
104
+ throw new TypeError('Invalid URL: ' + raw);
105
+ }
106
+ this.url = href;
107
+ this.method = ((Porffor.type(method) | 0b10000000) == Porffor.TYPES.bytestring ? method : String(method)).toUpperCase();
108
+ if (init != null && init.body != null && init.body !== '' && (this.method == 'GET' || this.method == 'HEAD'))
109
+ throw new TypeError('Request with GET/HEAD method cannot have body.');
110
+ this.headers = new Headers(headers);
111
+ this.body = Request._extractBody(body, this.headers);
112
+ if (redirect != null) this._redirect = String(redirect);
113
+ }
114
+
115
+ get redirect() { return this._redirect ?? 'follow'; }
116
+ get bodyUsed() { return this._bodyUsed === true; }
117
+
118
+ arrayBuffer() { return Request._bodyBytes(Request._consume(this)).buffer; }
119
+ blob() { return new Blob([Request._bodyBytes(Request._consume(this))]); }
120
+ clone() {
121
+ if (this._bodyUsed === true) throw new TypeError('Body has already been used');
122
+ return new Request(this);
123
+ }
124
+ json() { return JSON.parse(Request._bodyText(Request._consume(this))); }
125
+ text() { return Request._bodyText(Request._consume(this)); }
126
+ }
127
+
128
+ `;
129
+
130
+ export const RESPONSE_CLASS_SOURCE = String.raw`class Response {
131
+ constructor(body = null, init = undefined) {
132
+ const status = init?.status ?? 200;
133
+ this.status = Number(status);
134
+ if (!(this.status >= 200 && this.status <= 599) || this.status != Math.floor(this.status))
135
+ throw new RangeError('Response status must be between 200 and 599, got ' + status);
136
+ if (body != null && (this.status == 101 || this.status == 204 || this.status == 205 || this.status == 304))
137
+ throw new TypeError('Response with null body status cannot have body');
138
+ if (init?.statusText != null) this._statusText = String(init.statusText);
139
+ this.headers = new Headers(init?.headers);
140
+ this.body = Request._extractBody(body, this.headers);
141
+ }
142
+
143
+ static error() {
144
+ const response = new Response(null, { status: 200 });
145
+ response.status = 0;
146
+ response._type = 'error';
147
+ return response;
148
+ }
149
+
150
+ get statusText() { return this._statusText ?? ''; }
151
+ get type() { return this._type ?? 'default'; }
152
+ get ok() { return this.status >= 200 && this.status < 300; }
153
+ get bodyUsed() { return this._bodyUsed === true; }
154
+
155
+ arrayBuffer() { return Request._bodyBytes(Request._consume(this)).buffer; }
156
+ blob() { return new Blob([Request._bodyBytes(Request._consume(this))]); }
157
+ clone() {
158
+ if (this._bodyUsed === true) throw new TypeError('Body has already been used');
159
+ const copy = new Response(null, { status: this.status == 0 ? 200 : this.status, statusText: this._statusText, headers: this.headers });
160
+ copy.body = this.body;
161
+ copy.status = this.status;
162
+ if (this._type != null) copy._type = this._type;
163
+ return copy;
164
+ }
165
+ json() { return JSON.parse(Request._bodyText(Request._consume(this))); }
166
+ text() { return Request._bodyText(Request._consume(this)); }
167
+ }
168
+ `;
169
+
170
+ // The finalizer's body line, and what replaces it: byte bodies go out as their bytes.
171
+ export const RESPONSE_BYTES_ANCHOR =
172
+ " const body = response.body == null ? '' : String(response.body);\n const headersEntries = response.headers._entries;\n";
173
+ export const RESPONSE_BYTES_INJECT =
174
+ " // sproutboat: byte bodies go out as their bytes. A Latin-1 string, one character per byte,\n" +
175
+ " // built in windows (concatenating one long string is quadratic), sent raw by the writer.\n" +
176
+ " let body = response.body;\n" +
177
+ " let headersEntries = response.headers._entries;\n" +
178
+ " if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {\n" +
179
+ " const view = body instanceof ArrayBuffer ? new Uint8Array(body) : new Uint8Array(body.buffer, body.byteOffset, body.byteLength);\n" +
180
+ " const chunks = [];\n" +
181
+ " let chunk = '';\n" +
182
+ " for (let i = 0; i < view.length; i++) {\n" +
183
+ " chunk += String.fromCharCode(view[i]);\n" +
184
+ " if (chunk.length == 512) { chunks.push(chunk); chunk = ''; }\n" +
185
+ " }\n" +
186
+ " chunks.push(chunk);\n" +
187
+ " body = chunks.join('');\n" +
188
+ " headersEntries = headersEntries.slice();\n" +
189
+ " headersEntries.push('x-sb-raw-body', '1');\n" +
190
+ " } else body = body == null ? '' : String(body);\n";
191
+
192
+ // Porffor's Blob kept one string (binary parts were stringified) and had no size, type or
193
+ // slice; File did not exist. Bytes inside, text() decoded as UTF-8, read methods as promises.
194
+ export const BLOB_CLASS_SOURCE = String.raw`class Blob {
195
+ // sproutboat: byte-backed Blob and File
196
+ constructor(parts = [], options = undefined) {
197
+ const chunks = [];
198
+ let size = 0;
199
+ for (const part of parts) {
200
+ let bytes;
201
+ if (part instanceof Blob) bytes = part._bytes;
202
+ else if (part instanceof ArrayBuffer) bytes = new Uint8Array(part);
203
+ else if (ArrayBuffer.isView(part)) bytes = new Uint8Array(part.buffer, part.byteOffset, part.byteLength);
204
+ else bytes = new TextEncoder().encode(String(part));
205
+ chunks.push(bytes);
206
+ size += bytes.length;
207
+ }
208
+ this._bytes = new Uint8Array(size);
209
+ let at = 0;
210
+ for (const chunk of chunks) {
211
+ this._bytes.set(chunk, at);
212
+ at += chunk.length;
213
+ }
214
+ const type = options == null || options.type == null ? '' : String(options.type);
215
+ this._type = /^[\x20-\x7e]*$/.test(type) ? type.toLowerCase() : '';
216
+ }
217
+
218
+ get size() { return this._bytes.length; }
219
+ get type() { return this._type; }
220
+
221
+ slice(start = 0, end = undefined, contentType = '') {
222
+ const n = this._bytes.length;
223
+ const from = start < 0 ? Math.max(n + start, 0) : Math.min(start, n);
224
+ const to = end === undefined ? n : end < 0 ? Math.max(n + end, 0) : Math.min(end, n);
225
+ const out = new Blob([], { type: contentType });
226
+ out._bytes = this._bytes.slice(from, Math.max(to, from));
227
+ return out;
228
+ }
229
+
230
+ arrayBuffer() { return Promise.resolve(this._bytes.slice().buffer); }
231
+ bytes() { return Promise.resolve(this._bytes.slice()); }
232
+ text() { return Promise.resolve(new TextDecoder().decode(this._bytes)); }
233
+ }
234
+
235
+ class File extends Blob {
236
+ constructor(bits, name, options = undefined) {
237
+ super(bits, options);
238
+ this.name = String(name);
239
+ this.lastModified = options != null && options.lastModified != null ? Number(options.lastModified) : Date.now();
240
+ }
241
+ }
242
+ `;
@@ -0,0 +1,16 @@
1
+ import { expect, test } from "bun:test";
2
+ import { readdir } from "node:fs/promises";
3
+ import { PATCH_FINGERPRINT, PATCH_SOURCES } from "./fingerprint";
4
+
5
+ // Not part of the patched compiler: acquisition, the barrel, generators and this module.
6
+ const NOT_PATCH_SOURCES = new Set(["acquire.ts", "index.ts", "fingerprint.ts"]);
7
+
8
+ test("the patch fingerprint covers every toolchain module that shapes the compiler", async () => {
9
+ const modules = (await readdir(import.meta.dir)).filter(
10
+ (name) => name.endsWith(".ts") && !name.endsWith(".test.ts") && !name.startsWith("gen-") && !NOT_PATCH_SOURCES.has(name),
11
+ );
12
+ expect(Object.keys(PATCH_SOURCES).sort()).toEqual(modules.sort());
13
+ const embedded = new Map<string, string>(Object.entries(PATCH_SOURCES));
14
+ for (const name of modules) expect(embedded.get(name)).toBe(await Bun.file(`${import.meta.dir}/${name}`).text());
15
+ expect(PATCH_FINGERPRINT).toMatch(/^[0-9a-f]{12}$/);
16
+ });
@@ -0,0 +1,48 @@
1
+ import { createHash } from "node:crypto";
2
+ // The patch chain's own source text, embedded by Bun's text loader (it survives
3
+ // `bun build --compile`, where these files do not exist on disk). TypeScript sees
4
+ // a module, not a string, hence the expect-errors.
5
+ // @ts-expect-error text import
6
+ import caseMap from "./case-map.ts" with { type: "text" };
7
+ // @ts-expect-error text import
8
+ import fetchClasses from "./fetch-classes.ts" with { type: "text" };
9
+ // @ts-expect-error text import
10
+ import headersClass from "./headers-class.ts" with { type: "text" };
11
+ // @ts-expect-error text import
12
+ import patch from "./patch.ts" with { type: "text" };
13
+ // @ts-expect-error text import
14
+ import pin from "./pin.ts" with { type: "text" };
15
+ // @ts-expect-error text import
16
+ import streaming from "./streaming.ts" with { type: "text" };
17
+ // @ts-expect-error text import
18
+ import textDecoder from "./text-decoder.ts" with { type: "text" };
19
+ // @ts-expect-error text import
20
+ import unicode from "./unicode.ts" with { type: "text" };
21
+ // @ts-expect-error text import
22
+ import urlClass from "./url-class.ts" with { type: "text" };
23
+
24
+ /** Every module that shapes the patched compiler. fingerprint.test.ts fails when one is missing. */
25
+ export const PATCH_SOURCES = {
26
+ "case-map.ts": caseMap,
27
+ "fetch-classes.ts": fetchClasses,
28
+ "headers-class.ts": headersClass,
29
+ "patch.ts": patch,
30
+ "pin.ts": pin,
31
+ "streaming.ts": streaming,
32
+ "text-decoder.ts": textDecoder,
33
+ "unicode.ts": unicode,
34
+ "url-class.ts": urlClass,
35
+ } satisfies Record<string, string>;
36
+
37
+ /**
38
+ * Identifies the patch chain, not just the toolchain release. A cache entry is keyed by it, so
39
+ * an edited patch gets a fresh, fully patched compiler instead of one a same-version build
40
+ * already marked complete (B19: the prelude began calling sb_stream_*, which only the new
41
+ * chain defines, and every build against an older entry failed to link).
42
+ */
43
+ export const PATCH_FINGERPRINT = (() => {
44
+ const hash = createHash("sha256");
45
+ for (const [name, source] of Object.entries(PATCH_SOURCES).sort(([a], [b]) => a.localeCompare(b)))
46
+ hash.update(`${name}\0${source}\0`);
47
+ return hash.digest("hex").slice(0, 12);
48
+ })();
@@ -0,0 +1,35 @@
1
+ export {};
2
+ // Simple (one code unit to one code unit) BMP case mappings as runs: [start, end, delta, step].
3
+ function runs(map: (c: string) => string) {
4
+ const pairs: [number, number][] = [];
5
+ for (let cp = 0x80; cp <= 0xffff; cp++) {
6
+ if (cp >= 0xd800 && cp <= 0xdfff) continue;
7
+ const ch = String.fromCharCode(cp), m = map(ch);
8
+ if (m.length === 1 && m !== ch) pairs.push([cp, m.charCodeAt(0) - cp]);
9
+ }
10
+ const out: number[][] = [];
11
+ for (const [cp, d] of pairs) {
12
+ const last = out[out.length - 1];
13
+ if (last && last[2] === d && (last[3] === 0 ? (cp - last[1] === 1 || cp - last[1] === 2) : cp - last[1] === last[3])) {
14
+ if (last[3] === 0) last[3] = cp - last[1];
15
+ last[1] = cp;
16
+ } else out.push([cp, cp, d, 0]);
17
+ }
18
+ return out.map((r) => [r[0], r[1], r[2], r[3] || 1]);
19
+ }
20
+ const lower = runs((c) => c.toLowerCase()), upper = runs((c) => c.toUpperCase());
21
+ console.log("lower runs", lower.length, "upper runs", upper.length);
22
+ const all = [...lower, ...upper];
23
+ // usage: bun gen-case-map.ts case-map.ts
24
+ console.log("delta range", Math.min(...all.map((r) => r[2])), Math.max(...all.map((r) => r[2])), "max end", Math.max(...all.map((r) => r[1]).map(Number)).toString(16));
25
+ const enc = (rs: number[][]) => rs.map(([s, e, d, st]) => [s, e, s + d, st].map((x) => "\\u" + x.toString(16).padStart(4, "0")).join("")).join("");
26
+ if (process.argv[2]) await Bun.write(process.argv[2], `/**
27
+ * Unicode simple case mappings for BMP code units (one unit to one unit), generated by
28
+ * gen-case-map.ts from ${"Bun " + Bun.version}'s String.prototype.toLowerCase/toUpperCase and checked
29
+ * in, so patched builds do not depend on the patching engine's Unicode version. Runs of four
30
+ * UTF-16 code units: start, end, the start's mapping, step (1, or 2 for alternating pairs);
31
+ * a unit in a run maps to that mapping plus its distance from the start.
32
+ */
33
+ export const CASE_LOWER_RUNS = "${enc(lower)}";
34
+ export const CASE_UPPER_RUNS = "${enc(upper)}";
35
+ `);
@@ -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
+ // baronunread/sproutboat#286: Map.groupBy exists, and private fields stay out of
8
+ // Object.keys, for-in, Object.entries and JSON.stringify. Compared against Bun.
9
+ const source = `const m = Map.groupBy([1, 2, 3, 4, NaN, NaN], (x) => (x !== x ? "nan" : x % 2 ? "odd" : "even"));
10
+ const key = {};
11
+ const byObject = Map.groupBy([1, 2, 3], (x) => (x > 1 ? key : 0));
12
+ class A { #s = 1; pub = 2; static #t = 3; static u = 4; get s() { return this.#s; } static get t() { return A.#t; } inc() { this.#s++; return this.#s; } }
13
+ const a = new A();
14
+ const forIn = []; for (const k in a) forIn.push(k);
15
+ console.log(JSON.stringify({
16
+ groups: [...m], size: m.size, isMap: m instanceof Map, byObject: [byObject.get(key), byObject.get(0)],
17
+ keys: Object.keys(a), json: JSON.stringify(a), values: [a.s, a.inc(), A.t], staticKeys: Object.keys(A),
18
+ forIn, entries: Object.entries(a),
19
+ }));`;
20
+
21
+ test("#286: Map.groupBy and private fields match Bun", async () => {
22
+ const dir = await mkdtemp(resolve(tmpdir(), "sb-286-"));
23
+ try {
24
+ const porffor = await ensurePorffor();
25
+ const input = resolve(dir, "case.js"), binary = resolve(dir, "case");
26
+ await writeFile(input, source);
27
+ const compile = Bun.spawnSync(["bun", resolve(porffor, "runtime/index.js"), "native", input, "-o", binary, "-s", "-O0"], { stdout: "pipe", stderr: "pipe" });
28
+ expect(compile.exitCode, new TextDecoder().decode(compile.stderr)).toBe(0);
29
+ const run = Bun.spawnSync([binary], { stdout: "pipe", stderr: "pipe", timeout: 10_000 });
30
+ expect(run.exitCode, new TextDecoder().decode(run.stderr)).toBe(0);
31
+ const reference = Bun.spawnSync(["bun", input], { stdout: "pipe" });
32
+ const strip = (bytes: Uint8Array) => new TextDecoder().decode(bytes).replaceAll("\x1b[33m", "").replaceAll("\x1b[0m", "").trim();
33
+ expect(JSON.parse(strip(run.stdout))).toEqual(JSON.parse(strip(reference.stdout)));
34
+ } finally {
35
+ await rm(dir, { recursive: true, force: true });
36
+ }
37
+ }, 60_000);
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The Headers class patchHeadersClass puts into Porffor's runtime/fetch-globals.js. Porffor's
3
+ * kept the first value for get() (append("a", "1"), append("a", "2") then get("a") gave
4
+ * "1"), iterated in insertion order, had no getSetCookie, and accepted any name or value
5
+ * unvalidated and untrimmed. Found by sproutd's W2 conformance suite against workerd.
6
+ *
7
+ * Storage stays Porffor's: `_entries` holds each appended name (lowercased) and value in
8
+ * order, flat, because the native response writer reads it, and each Set-Cookie must reach
9
+ * the wire as its own header. The Fetch spec's combined, sorted view is computed on read.
10
+ */
11
+ export const HEADERS_CLASS_SOURCE = String.raw`class Headers {
12
+ // sproutboat: Fetch-spec Headers
13
+ constructor(init = undefined) {
14
+ this._entries = Porffor.array.new(8);
15
+ if (init == null) return;
16
+ if (init instanceof Headers) {
17
+ for (let i = 0; i < init._entries.length; i += 2) this._push(init._entries[i], init._entries[i + 1]);
18
+ return;
19
+ }
20
+ if (typeof init != 'object') throw new TypeError('Headers: init must be an object, an array of pairs or Headers');
21
+ if (Array.isArray(init) || typeof init[Symbol.iterator] == 'function') {
22
+ for (const entry of init) {
23
+ if (!entry || entry.length != 2) throw new TypeError('Headers: each pair must have exactly two items');
24
+ this.append(entry[0], entry[1]);
25
+ }
26
+ return;
27
+ }
28
+ for (const key in init) this.append(key, init[key]);
29
+ }
30
+
31
+ static _name(name) {
32
+ const s = String(name);
33
+ if (!/^[!#$%&'*+\-.^_\x60|~0-9A-Za-z]+$/.test(s)) throw new TypeError('Invalid header name: ' + s);
34
+ return s.toLowerCase();
35
+ }
36
+
37
+ static _value(value) {
38
+ let s = String(value);
39
+ let start = 0, end = s.length;
40
+ while (start < end && (s.charCodeAt(start) == 32 || s.charCodeAt(start) == 9 || s.charCodeAt(start) == 10 || s.charCodeAt(start) == 13)) start++;
41
+ while (end > start && (s.charCodeAt(end - 1) == 32 || s.charCodeAt(end - 1) == 9 || s.charCodeAt(end - 1) == 10 || s.charCodeAt(end - 1) == 13)) end--;
42
+ s = s.slice(start, end);
43
+ for (let i = 0; i < s.length; i++) {
44
+ const c = s.charCodeAt(i);
45
+ if (c == 0 || c == 10 || c == 13) throw new TypeError('Invalid header value');
46
+ }
47
+ return s;
48
+ }
49
+
50
+ _push(name, value) {
51
+ Porffor.array.fastPush(this._entries, name);
52
+ Porffor.array.fastPush(this._entries, value);
53
+ }
54
+
55
+ append(name, value) {
56
+ this._push(Headers._name(name), Headers._value(value));
57
+ }
58
+
59
+ delete(name) {
60
+ const normalized = Headers._name(name);
61
+ const next = Porffor.array.new(this._entries.length);
62
+ for (let i = 0; i < this._entries.length; i += 2) {
63
+ if (this._entries[i] == normalized) continue;
64
+ Porffor.array.fastPush(next, this._entries[i]);
65
+ Porffor.array.fastPush(next, this._entries[i + 1]);
66
+ }
67
+ this._entries = next;
68
+ }
69
+
70
+ get(name) {
71
+ const normalized = Headers._name(name);
72
+ let out = null;
73
+ for (let i = 0; i < this._entries.length; i += 2) {
74
+ if (this._entries[i] != normalized) continue;
75
+ out = out == null ? this._entries[i + 1] : out + ', ' + this._entries[i + 1];
76
+ }
77
+ return out;
78
+ }
79
+
80
+ getSetCookie() {
81
+ const out = [];
82
+ for (let i = 0; i < this._entries.length; i += 2) if (this._entries[i] == 'set-cookie') out.push(this._entries[i + 1]);
83
+ return out;
84
+ }
85
+
86
+ has(name) {
87
+ const normalized = Headers._name(name);
88
+ for (let i = 0; i < this._entries.length; i += 2) if (this._entries[i] == normalized) return true;
89
+ return false;
90
+ }
91
+
92
+ set(name, value) {
93
+ const normalized = Headers._name(name), v = Headers._value(value);
94
+ this.delete(normalized);
95
+ this._push(normalized, v);
96
+ }
97
+
98
+ // The spec's sort-and-combine: names ascending, values joined with ", ", except
99
+ // set-cookie, which is listed once per value.
100
+ _sorted() {
101
+ const names = [];
102
+ for (let i = 0; i < this._entries.length; i += 2) if (!names.includes(this._entries[i])) names.push(this._entries[i]);
103
+ names.sort();
104
+ const out = [];
105
+ for (const name of names) {
106
+ if (name == 'set-cookie') {
107
+ for (const value of this.getSetCookie()) out.push([name, value]);
108
+ } else out.push([name, this.get(name)]);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ entries() { return this._sorted(); }
114
+ keys() { return this._sorted().map((pair) => pair[0]); }
115
+ values() { return this._sorted().map((pair) => pair[1]); }
116
+ forEach(callback, thisArg = undefined) {
117
+ for (const pair of this._sorted()) callback.call(thisArg, pair[1], pair[0], this);
118
+ }
119
+ }
120
+ `;
@@ -0,0 +1,38 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { execFileSync } from "node:child_process";
4
+ import { tmpdir } from "node:os";
5
+ import { resolve } from "node:path";
6
+ import { patchQuietCollection } from "./patch";
7
+
8
+ test("idle reclamation preserves unconditional request GC and is idempotent on the pinned compiler", async () => {
9
+ const root = await mkdtemp(resolve(tmpdir(), "sb-quiet-gc-"));
10
+ try {
11
+ execFileSync("tar", [
12
+ "-xzf",
13
+ resolve(import.meta.dir, "../vendor/porffor-deda494.tar.gz"),
14
+ "--strip-components=1",
15
+ "-C",
16
+ root,
17
+ ]);
18
+ await patchQuietCollection(root);
19
+ const render = await readFile(resolve(root, "compiler/render.js"), "utf8");
20
+ const shim = await readFile(resolve(root, "compiler/uwebsockets.js"), "utf8");
21
+ const start = shim.indexOf("static void collect_after_request(void) {");
22
+ const end = shim.indexOf("\n}\n", start);
23
+ const body = shim.slice(start, end);
24
+ const normal = body.indexOf(
25
+ "porf_native_fetch_collect_normal_from((void*)&porf_stack_anchor);",
26
+ );
27
+ const guard = body.indexOf("if (porf_native_fetch_needs_quiet_collect()) {");
28
+ expect(normal).toBeGreaterThan(-1);
29
+ expect(guard).toBeGreaterThan(normal);
30
+ expect(body.slice(0, guard)).not.toContain("return;");
31
+ expect(render).toContain("porf_gc_mark_queue_len == 0 && porf_gc_mark_queue_cap > 4096");
32
+ await patchQuietCollection(root);
33
+ expect(await readFile(resolve(root, "compiler/render.js"), "utf8")).toBe(render);
34
+ expect(await readFile(resolve(root, "compiler/uwebsockets.js"), "utf8")).toBe(shim);
35
+ } finally {
36
+ await rm(root, { recursive: true, force: true });
37
+ }
38
+ });
@@ -0,0 +1,39 @@
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
+ // Found by the sproutd W2 conformance suite: JSON.stringify of a cycle overflowed the
8
+ // native stack (the process died) instead of throwing, and typed arrays had no toString
9
+ // tag. Compared against Bun, except the cycle message, which follows V8.
10
+ const source = `const out = [];
11
+ const a = { n: 1 }; a.self = a;
12
+ try { JSON.stringify(a); out.push("no throw"); } catch (e) { out.push(e.name); }
13
+ const arr = [1]; arr.push(arr);
14
+ try { JSON.stringify(arr); out.push("no throw"); } catch (e) { out.push(e.name); }
15
+ const shared = { x: 1 };
16
+ out.push(JSON.stringify({ a: shared, b: [shared, [shared]] }));
17
+ const bad = { toJSON() { throw new RangeError("t"); } };
18
+ try { JSON.stringify({ x: { y: bad } }); } catch (e) { out.push(e.name); }
19
+ out.push(JSON.stringify({ x: { y: 1 } }));
20
+ out.push([new Uint8Array(1), new Float64Array(1), new BigInt64Array(1), new DataView(new ArrayBuffer(1)), []].map((x) => Object.prototype.toString.call(x)).join(" "));
21
+ console.log(JSON.stringify(out));`;
22
+
23
+ test("JSON.stringify throws on cycles and typed arrays have toString tags, as in Bun", async () => {
24
+ const dir = await mkdtemp(resolve(tmpdir(), "sb-json-cycles-"));
25
+ try {
26
+ const porffor = await ensurePorffor();
27
+ const input = resolve(dir, "case.js"), binary = resolve(dir, "case");
28
+ await writeFile(input, source);
29
+ const compile = Bun.spawnSync(["bun", resolve(porffor, "runtime/index.js"), "native", input, "-o", binary, "-s", "-O0"], { stdout: "pipe", stderr: "pipe" });
30
+ expect(compile.exitCode, new TextDecoder().decode(compile.stderr)).toBe(0);
31
+ const run = Bun.spawnSync([binary], { stdout: "pipe", stderr: "pipe", timeout: 10_000 });
32
+ expect(run.exitCode, new TextDecoder().decode(run.stderr)).toBe(0);
33
+ const reference = Bun.spawnSync(["bun", input], { stdout: "pipe" });
34
+ const strip = (bytes: Uint8Array) => new TextDecoder().decode(bytes).replaceAll("\x1b[33m", "").replaceAll("\x1b[0m", "").trim();
35
+ expect(JSON.parse(strip(run.stdout))).toEqual(JSON.parse(strip(reference.stdout)));
36
+ } finally {
37
+ await rm(dir, { recursive: true, force: true });
38
+ }
39
+ }, 60_000);