@sproutboat/runtime 0.16.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/WEB_STREAMS.md +7 -3
- package/package.json +1 -1
- package/src/asset-rules.test.js +125 -0
- package/src/egress.test.ts +248 -5
- package/src/gen-streams.ts +4 -5
- package/src/native-fetch-prelude.js +776 -112
- package/src/streams-entry.js +17 -3
- package/src/transport-broker.js +12 -0
- package/src/transport-embedded.js +403 -71
- package/test/native-bodies.ts +136 -0
- package/test/native-errors.ts +101 -0
- package/test/native-events.ts +102 -0
- package/test/native-iterators.ts +6 -1
- package/test/native-json.ts +102 -0
- package/test/native-raw-body.ts +118 -0
- package/test/native-request.ts +101 -0
- package/test/native-response-defaults.ts +98 -0
- package/test/native-streaming.ts +117 -0
- package/test/native-url.ts +145 -0
- package/test/native-waituntil.ts +107 -0
- package/test/native-web-apis.ts +110 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
6
|
+
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
|
+
|
|
8
|
+
// Bodies on the wire and the Fetch classes, against Bun: byte bodies go out as their bytes
|
|
9
|
+
// (a Uint8Array was sent as "104,105"), Blob and FormData bodies carry their type, and
|
|
10
|
+
// Request/Response/Blob follow the spec where Bun and workerd agree.
|
|
11
|
+
const handler = `export default {async fetch(request) {
|
|
12
|
+
const path = new URL(request.url).pathname;
|
|
13
|
+
if (path === "/u8") return new Response(new Uint8Array([104, 105, 0, 255]));
|
|
14
|
+
if (path === "/ab") return new Response(new Uint8Array([1, 2, 3]).buffer);
|
|
15
|
+
if (path === "/blob") return new Response(new Blob(["hé", new Uint8Array([33])], { type: "text/x-test" }));
|
|
16
|
+
if (path === "/stream") return new Response(new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode("he")); c.enqueue(new Uint8Array([108, 108, 111])); c.close(); } }));
|
|
17
|
+
if (path === "/stream-async") { await null; return new Response(new ReadableStream({ async pull(c) { await new Promise((r) => setTimeout(r, 5)); c.enqueue(new Uint8Array([0, 255])); c.close(); } }).pipeThrough(new TransformStream())); }
|
|
18
|
+
if (path === "/stream-strings") return new Response(new ReadableStream({ start(c) { c.enqueue("text"); c.close(); } }));
|
|
19
|
+
if (path === "/form") { const f = new FormData(); f.append("a", "1"); f.append("f", new Blob(["xy"]), "n.txt"); return new Response(f); }
|
|
20
|
+
const cases = [
|
|
21
|
+
() => new Request("http://a.test/a/../b?x=1#f").url,
|
|
22
|
+
async () => { const r = new Request("http://a.test", { method: "POST", body: "x" }); await r.text(); try { await r.text(); return "no throw"; } catch (e) { return r.bodyUsed + ":" + e.name; } },
|
|
23
|
+
() => new Request("http://a.test").redirect,
|
|
24
|
+
() => { try { new Request("/relative"); return "no throw"; } catch (e) { return e.name; } },
|
|
25
|
+
() => new Response("x", { status: 201, statusText: "Made" }).statusText,
|
|
26
|
+
() => { try { new Response("x", { status: 99 }); return "no throw"; } catch (e) { return e.name; } },
|
|
27
|
+
() => Response.error().type + ":" + Response.error().status,
|
|
28
|
+
async () => Array.from(new Uint8Array(await new Response(new Uint8Array([1, 2, 3])).arrayBuffer())).join(),
|
|
29
|
+
async () => (await new Response("abc").blob()).size,
|
|
30
|
+
() => { const b = new Blob(["abcdef"], { type: "Text/Plain" }); return b.size + ":" + b.type; },
|
|
31
|
+
async () => new Blob(["abcdef"]).slice(-2).text(),
|
|
32
|
+
async () => { const f = new FormData(); f.append("f", new Blob(["x"]), "n.txt"); const v = f.get("f"); return v.name + ":" + v.size; },
|
|
33
|
+
async () => (await new Response(new URLSearchParams({ a: "1" })).text()) + "|" + new Response(new URLSearchParams("a=1")).headers.get("content-type"),
|
|
34
|
+
];
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const run of cases) { try { out.push(String(await run())); } catch (e) { out.push("THROWS " + (e && e.name)); } }
|
|
37
|
+
return new Response(JSON.stringify(out));
|
|
38
|
+
}};`;
|
|
39
|
+
const CASE_SOURCES = handler.slice(handler.indexOf("const cases = ["), handler.indexOf(" ];") + 4);
|
|
40
|
+
|
|
41
|
+
function freePort(): number {
|
|
42
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
43
|
+
const port = listener.port;
|
|
44
|
+
listener.stop(true);
|
|
45
|
+
return port;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-bodies-"));
|
|
49
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
50
|
+
try {
|
|
51
|
+
const porffor = await ensurePorffor();
|
|
52
|
+
const node = Bun.which("node");
|
|
53
|
+
if (!node) throw new Error("native streams test needs node on PATH");
|
|
54
|
+
|
|
55
|
+
const [core, broker] = await Promise.all([
|
|
56
|
+
readFile(preludePath, "utf8"),
|
|
57
|
+
readFile(transportPath("broker"), "utf8"),
|
|
58
|
+
]);
|
|
59
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
60
|
+
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
61
|
+
const generated = join(workdir, "bodies.generated.js");
|
|
62
|
+
const binary = join(workdir, "bodies-native");
|
|
63
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, prelude, undefined, undefined, undefined, "2026-10-01"));
|
|
64
|
+
await rm(binary, { force: true });
|
|
65
|
+
|
|
66
|
+
const compile = Bun.spawn([node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"], {
|
|
67
|
+
cwd: workdir,
|
|
68
|
+
env: process.env,
|
|
69
|
+
stdout: "pipe",
|
|
70
|
+
stderr: "pipe",
|
|
71
|
+
});
|
|
72
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
73
|
+
compile.exited,
|
|
74
|
+
new Response(compile.stdout).text(),
|
|
75
|
+
new Response(compile.stderr).text(),
|
|
76
|
+
]);
|
|
77
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
78
|
+
|
|
79
|
+
const port = freePort();
|
|
80
|
+
server = Bun.spawn([binary], {
|
|
81
|
+
cwd: workdir,
|
|
82
|
+
env: { ...process.env, PORT: String(port) },
|
|
83
|
+
stdout: "ignore",
|
|
84
|
+
stderr: "inherit",
|
|
85
|
+
});
|
|
86
|
+
let ready = false;
|
|
87
|
+
for (let attempt = 0; attempt < 50 && !ready; attempt++) {
|
|
88
|
+
try {
|
|
89
|
+
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
|
|
90
|
+
ready = true;
|
|
91
|
+
} catch {
|
|
92
|
+
if (server.exitCode !== null) break;
|
|
93
|
+
await Bun.sleep(100);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
assert(ready, "compiled handler did not start");
|
|
97
|
+
const bytes = async (path: string) => {
|
|
98
|
+
const r = await fetch(`http://127.0.0.1:${port}${path}`);
|
|
99
|
+
return { type: r.headers.get("content-type"), bytes: Array.from(new Uint8Array(await r.arrayBuffer())) };
|
|
100
|
+
};
|
|
101
|
+
assert.deepEqual(await bytes("/u8"), { type: null, bytes: [104, 105, 0, 255] });
|
|
102
|
+
assert.deepEqual(await bytes("/ab"), { type: null, bytes: [1, 2, 3] });
|
|
103
|
+
assert.deepEqual(await bytes("/blob"), { type: "text/x-test", bytes: [104, 195, 169, 33] });
|
|
104
|
+
assert.deepEqual(await bytes("/stream"), { type: null, bytes: [104, 101, 108, 108, 111] });
|
|
105
|
+
assert.deepEqual(await bytes("/stream-async"), { type: null, bytes: [0, 255] });
|
|
106
|
+
// A stream of strings is not a body (TypeError, as in workerd). Streamed, the status line is
|
|
107
|
+
// already out when the first chunk is read, so the response ends empty instead of a 500.
|
|
108
|
+
const strings = await fetch(`http://127.0.0.1:${port}/stream-strings`);
|
|
109
|
+
assert.equal(await strings.text(), "", "a stream of strings sends no body");
|
|
110
|
+
const form = await fetch(`http://127.0.0.1:${port}/form`);
|
|
111
|
+
const boundary = (form.headers.get("content-type") || "").split("boundary=")[1];
|
|
112
|
+
assert(boundary, "multipart content type");
|
|
113
|
+
const parsed = await new Response(await form.arrayBuffer(), { headers: { "content-type": `multipart/form-data; boundary=${boundary}` } }).formData();
|
|
114
|
+
assert.equal(parsed.get("a"), "1");
|
|
115
|
+
const file = parsed.get("f");
|
|
116
|
+
assert(file instanceof File, "the file part parses back as a File");
|
|
117
|
+
assert.equal(await file.text(), "xy");
|
|
118
|
+
assert.equal(file.name, "n.txt");
|
|
119
|
+
// SAFETY: the handler above is the only producer of this body: a JSON array with one
|
|
120
|
+
// string per case.
|
|
121
|
+
const got = JSON.parse(await (await fetch(`http://127.0.0.1:${port}/cases`)).text()) as string[];
|
|
122
|
+
const want: string[] = [];
|
|
123
|
+
// SAFETY: CASE_SOURCES is the handler's own literal list of case functions.
|
|
124
|
+
const reference = new Function(`return (() => { ${CASE_SOURCES} return cases; })()`)() as Array<() => Promise<string> | string>;
|
|
125
|
+
for (const run of reference) {
|
|
126
|
+
try { want.push(String(await run())); } catch (e) { want.push("THROWS " + (e instanceof Error ? e.name : String(e))); }
|
|
127
|
+
}
|
|
128
|
+
assert.deepEqual(got, want);
|
|
129
|
+
console.log(`Native bodies are sent as bytes and ${want.length} Fetch class cases match Bun`);
|
|
130
|
+
} finally {
|
|
131
|
+
if (server) {
|
|
132
|
+
server.kill(9);
|
|
133
|
+
await server.exited;
|
|
134
|
+
}
|
|
135
|
+
await rm(workdir, { recursive: true, force: true });
|
|
136
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
6
|
+
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
|
+
|
|
8
|
+
// Error.stack as V8 starts it (Bun is the reference for the first line): Porffor keeps no
|
|
9
|
+
// frames, so only the "Name: message" line is compared.
|
|
10
|
+
const CASES = [
|
|
11
|
+
`typeof new Error("boom").stack`,
|
|
12
|
+
`new Error("boom").stack.split("\\n")[0]`,
|
|
13
|
+
`new Error().stack.split("\\n")[0]`,
|
|
14
|
+
`new TypeError("bad type").stack.split("\\n")[0]`,
|
|
15
|
+
`new RangeError("out").stack.split("\\n")[0]`,
|
|
16
|
+
`(() => { try { null.x; } catch (e) { return typeof e.stack; } })()`,
|
|
17
|
+
`(() => { try { throw new Error("thrown"); } catch (e) { return e.stack.split("\\n")[0]; } })()`,
|
|
18
|
+
`JSON.stringify(new Error("x").stack.split("\\n")[0])`,
|
|
19
|
+
];
|
|
20
|
+
const handler = `export default {fetch() {
|
|
21
|
+
const U = URL;
|
|
22
|
+
const cases = [
|
|
23
|
+
${CASES.map((c) => ` () => (${c}),`).join("\n")}
|
|
24
|
+
];
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const run of cases) {
|
|
27
|
+
try { out.push(String(run())); } catch (e) { out.push("THROWS " + (e && e.message)); }
|
|
28
|
+
}
|
|
29
|
+
return new Response(JSON.stringify(out));
|
|
30
|
+
}};`;
|
|
31
|
+
const want = CASES.map((c) => String(new Function("U", "return " + c)(URL)));
|
|
32
|
+
|
|
33
|
+
function freePort(): number {
|
|
34
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
35
|
+
const port = listener.port;
|
|
36
|
+
listener.stop(true);
|
|
37
|
+
return port;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-errors-"));
|
|
41
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
42
|
+
try {
|
|
43
|
+
const porffor = await ensurePorffor();
|
|
44
|
+
const node = Bun.which("node");
|
|
45
|
+
if (!node) throw new Error("native streams test needs node on PATH");
|
|
46
|
+
|
|
47
|
+
const [core, broker] = await Promise.all([
|
|
48
|
+
readFile(preludePath, "utf8"),
|
|
49
|
+
readFile(transportPath("broker"), "utf8"),
|
|
50
|
+
]);
|
|
51
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
52
|
+
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
53
|
+
const generated = join(workdir, "errors.generated.js");
|
|
54
|
+
const binary = join(workdir, "errors-native");
|
|
55
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
|
|
56
|
+
await rm(binary, { force: true });
|
|
57
|
+
|
|
58
|
+
const compile = Bun.spawn([node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"], {
|
|
59
|
+
cwd: workdir,
|
|
60
|
+
env: process.env,
|
|
61
|
+
stdout: "pipe",
|
|
62
|
+
stderr: "pipe",
|
|
63
|
+
});
|
|
64
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
65
|
+
compile.exited,
|
|
66
|
+
new Response(compile.stdout).text(),
|
|
67
|
+
new Response(compile.stderr).text(),
|
|
68
|
+
]);
|
|
69
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
70
|
+
|
|
71
|
+
const port = freePort();
|
|
72
|
+
server = Bun.spawn([binary], {
|
|
73
|
+
cwd: workdir,
|
|
74
|
+
env: { ...process.env, PORT: String(port) },
|
|
75
|
+
stdout: "ignore",
|
|
76
|
+
stderr: "inherit",
|
|
77
|
+
});
|
|
78
|
+
let ready = false;
|
|
79
|
+
for (let attempt = 0; attempt < 50 && !ready; attempt++) {
|
|
80
|
+
try {
|
|
81
|
+
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
|
|
82
|
+
ready = true;
|
|
83
|
+
} catch {
|
|
84
|
+
if (server.exitCode !== null) break;
|
|
85
|
+
await Bun.sleep(100);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
assert(ready, "compiled handler did not start");
|
|
89
|
+
// SAFETY: the handler above is the only producer of this body: a JSON array with one
|
|
90
|
+
// string per case; the comparison below reports any entry that differs.
|
|
91
|
+
const got = JSON.parse(await (await fetch(`http://127.0.0.1:${port}/`)).text()) as string[];
|
|
92
|
+
const diffs = CASES.flatMap((c, i) => (got[i] === want[i] ? [] : [`${c}\n want ${JSON.stringify(want[i])}\n got ${JSON.stringify(got[i])}`]));
|
|
93
|
+
assert.deepEqual(diffs, [], `Error.stack differs from V8 in ${diffs.length} of ${CASES.length} cases:\n${diffs.join("\n")}`);
|
|
94
|
+
console.log(`Native Error.stack starts like V8 in all ${CASES.length} cases`);
|
|
95
|
+
} finally {
|
|
96
|
+
if (server) {
|
|
97
|
+
server.kill(9);
|
|
98
|
+
await server.exited;
|
|
99
|
+
}
|
|
100
|
+
await rm(workdir, { recursive: true, force: true });
|
|
101
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
6
|
+
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
|
+
|
|
8
|
+
// structuredClone, Event/EventTarget and AbortController in a compiled handler,
|
|
9
|
+
// compared with Bun (the prelude polyfills them; found by the W2 conformance suite).
|
|
10
|
+
const CASES = [
|
|
11
|
+
`(() => { const a = { n: 1, m: new Map([["k", new Set([1])]]), d: new Date(0), b: new Uint8Array([1, 2]) }; a.self = a; const c = structuredClone(a); return [c.self === c, c !== a, [...c.m.get("k")].join(), c.d.getTime(), c.b[1]].join(); })()`,
|
|
12
|
+
`(() => { try { structuredClone(() => 1); return "no throw"; } catch (e) { return e.name; } })()`,
|
|
13
|
+
`(() => { const e = structuredClone(new RangeError("x")); return e.name + ":" + e.message; })()`,
|
|
14
|
+
`(() => { const t = new EventTarget(); const o = []; const f = () => o.push("f"); t.addEventListener("x", f); t.addEventListener("x", f); t.addEventListener("x", () => o.push("once"), { once: true }); t.addEventListener("x", { handleEvent() { o.push("h"); } }); t.dispatchEvent(new Event("x")); t.dispatchEvent(new Event("x")); return o.join(); })()`,
|
|
15
|
+
`(() => { const t = new EventTarget(); t.addEventListener("x", (e) => e.preventDefault()); return [t.dispatchEvent(new Event("x", { cancelable: true })), t.dispatchEvent(new Event("x"))].join(); })()`,
|
|
16
|
+
`new CustomEvent("c", { detail: { a: 1 } }).detail.a`,
|
|
17
|
+
`(() => { const c = new AbortController(); let n = 0; c.signal.addEventListener("abort", () => n++); c.signal.onabort = () => n++; c.abort(); c.abort(); return [c.signal.aborted, c.signal.reason.name, n].join(); })()`,
|
|
18
|
+
`(() => { const c = new AbortController(); const s = AbortSignal.any([c.signal, new AbortController().signal]); c.abort("y"); return s.reason; })()`,
|
|
19
|
+
`(() => { try { AbortSignal.abort(new TypeError("t")).throwIfAborted(); return "no throw"; } catch (e) { return e.name; } })()`,
|
|
20
|
+
`(() => { const c = new AbortController(); const t = new EventTarget(); let n = 0; t.addEventListener("x", () => n++, { signal: c.signal }); c.abort(); t.dispatchEvent(new Event("x")); return n; })()`,
|
|
21
|
+
];
|
|
22
|
+
const handler = `export default {async fetch() {
|
|
23
|
+
const cases = [
|
|
24
|
+
${CASES.map((c) => ` () => (${c}),`).join("\n")}
|
|
25
|
+
];
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const run of cases) {
|
|
28
|
+
try { out.push(String(await run())); } catch (e) { out.push("THROWS " + (e && e.message)); }
|
|
29
|
+
}
|
|
30
|
+
return new Response(JSON.stringify(out));
|
|
31
|
+
}};`;
|
|
32
|
+
const want = await Promise.all(CASES.map(async (c) => { try { return String(await new Function("return " + c)()); } catch (e) { return "THROWS " + (e instanceof Error ? e.message : String(e)); } }));
|
|
33
|
+
|
|
34
|
+
function freePort(): number {
|
|
35
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
36
|
+
const port = listener.port;
|
|
37
|
+
listener.stop(true);
|
|
38
|
+
return port;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-events-"));
|
|
42
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
43
|
+
try {
|
|
44
|
+
const porffor = await ensurePorffor();
|
|
45
|
+
const node = Bun.which("node");
|
|
46
|
+
if (!node) throw new Error("native streams test needs node on PATH");
|
|
47
|
+
|
|
48
|
+
const [core, broker] = await Promise.all([
|
|
49
|
+
readFile(preludePath, "utf8"),
|
|
50
|
+
readFile(transportPath("broker"), "utf8"),
|
|
51
|
+
]);
|
|
52
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
53
|
+
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
54
|
+
const generated = join(workdir, "events.generated.js");
|
|
55
|
+
const binary = join(workdir, "events-native");
|
|
56
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
|
|
57
|
+
await rm(binary, { force: true });
|
|
58
|
+
|
|
59
|
+
const compile = Bun.spawn([node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"], {
|
|
60
|
+
cwd: workdir,
|
|
61
|
+
env: process.env,
|
|
62
|
+
stdout: "pipe",
|
|
63
|
+
stderr: "pipe",
|
|
64
|
+
});
|
|
65
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
66
|
+
compile.exited,
|
|
67
|
+
new Response(compile.stdout).text(),
|
|
68
|
+
new Response(compile.stderr).text(),
|
|
69
|
+
]);
|
|
70
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
71
|
+
|
|
72
|
+
const port = freePort();
|
|
73
|
+
server = Bun.spawn([binary], {
|
|
74
|
+
cwd: workdir,
|
|
75
|
+
env: { ...process.env, PORT: String(port) },
|
|
76
|
+
stdout: "ignore",
|
|
77
|
+
stderr: "inherit",
|
|
78
|
+
});
|
|
79
|
+
let ready = false;
|
|
80
|
+
for (let attempt = 0; attempt < 50 && !ready; attempt++) {
|
|
81
|
+
try {
|
|
82
|
+
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
|
|
83
|
+
ready = true;
|
|
84
|
+
} catch {
|
|
85
|
+
if (server.exitCode !== null) break;
|
|
86
|
+
await Bun.sleep(100);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
assert(ready, "compiled handler did not start");
|
|
90
|
+
// SAFETY: the handler above is the only producer of this body: a JSON array with one
|
|
91
|
+
// string per case; the comparison below reports any entry that differs.
|
|
92
|
+
const got = JSON.parse(await (await fetch(`http://127.0.0.1:${port}/`)).text()) as string[];
|
|
93
|
+
const diffs = CASES.flatMap((c, i) => (got[i] === want[i] ? [] : [`${c}\n want ${JSON.stringify(want[i])}\n got ${JSON.stringify(got[i])}`]));
|
|
94
|
+
assert.deepEqual(diffs, [], `Event, abort and clone results differ from Bun in ${diffs.length} of ${CASES.length} cases:\n${diffs.join("\n")}`);
|
|
95
|
+
console.log(`Native events, abort and structuredClone match Bun in all ${CASES.length} cases`);
|
|
96
|
+
} finally {
|
|
97
|
+
if (server) {
|
|
98
|
+
server.kill(9);
|
|
99
|
+
await server.exited;
|
|
100
|
+
}
|
|
101
|
+
await rm(workdir, { recursive: true, force: true });
|
|
102
|
+
}
|
package/test/native-iterators.ts
CHANGED
|
@@ -10,6 +10,7 @@ const expected = {
|
|
|
10
10
|
keys: ["a", "a", "b"], values: ["1", "2", "3"],
|
|
11
11
|
live: ["x", "y", true], form: [["x", "1"], ["x", "2"]],
|
|
12
12
|
formLive: ["x", "y", true], self: true,
|
|
13
|
+
headers: [["a", "1"], ["b", "2"]], headersMap: 2, headersFromEntries: { a: "1", b: "2" },
|
|
13
14
|
};
|
|
14
15
|
const handler = `export default { fetch(request) {
|
|
15
16
|
if (new URL(request.url).pathname === "/null") return new Response(null);
|
|
@@ -27,9 +28,13 @@ const handler = `export default { fetch(request) {
|
|
|
27
28
|
const liveForm = new FormData(); liveForm.append("x", "1");
|
|
28
29
|
const formIterator = liveForm.keys(); const f1 = formIterator.next().value;
|
|
29
30
|
liveForm.append("y", "2"); const f2 = formIterator.next().value;
|
|
31
|
+
// Sorted input: the spec also sorts and combines on iteration, which Porffor's
|
|
32
|
+
// Headers does not do yet (insertion order).
|
|
33
|
+
const h = new Headers([["a", "1"], ["b", "2"]]);
|
|
30
34
|
return Response.json({ query, keys: Array.from(params.keys()), values: Array.from(params.values()),
|
|
31
35
|
live: [first, second, done], form, formLive: [f1, f2, formIterator.next().done],
|
|
32
|
-
self: iterator[Symbol.iterator]() === iterator
|
|
36
|
+
self: iterator[Symbol.iterator]() === iterator,
|
|
37
|
+
headers: [...h], headersMap: new Map(h).size, headersFromEntries: Object.fromEntries(h) });
|
|
33
38
|
} };`;
|
|
34
39
|
|
|
35
40
|
function freePort(): number {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
6
|
+
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
|
+
|
|
8
|
+
// JSON.stringify as the spec defines it (Bun is the reference): toJSON on Date, URL and
|
|
9
|
+
// user objects, nested and in arrays, and a toJSON that returns undefined.
|
|
10
|
+
const CASES = [
|
|
11
|
+
`JSON.stringify({ at: new Date(0) })`,
|
|
12
|
+
`JSON.stringify([new Date(86400000), 1])`,
|
|
13
|
+
`JSON.stringify(new Date(1700000000000))`,
|
|
14
|
+
`JSON.stringify({ u: new U("https://a.com/x?y=1") })`,
|
|
15
|
+
`JSON.stringify({ a: { toJSON() { return { b: 2 }; } } })`,
|
|
16
|
+
`JSON.stringify({ a: 1, gone: { toJSON() { return undefined; } } })`,
|
|
17
|
+
`JSON.stringify({ n: { toJSON() { return 42; } } })`,
|
|
18
|
+
`JSON.stringify({ nested: { at: new Date(0), list: [new Date(0)] } })`,
|
|
19
|
+
`JSON.stringify({ plain: { x: 1 } })`,
|
|
20
|
+
];
|
|
21
|
+
const handler = `export default {fetch() {
|
|
22
|
+
const U = URL;
|
|
23
|
+
const cases = [
|
|
24
|
+
${CASES.map((c) => ` () => (${c}),`).join("\n")}
|
|
25
|
+
];
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const run of cases) {
|
|
28
|
+
try { out.push(String(run())); } catch (e) { out.push("THROWS " + (e && e.message)); }
|
|
29
|
+
}
|
|
30
|
+
return new Response(JSON.stringify(out));
|
|
31
|
+
}};`;
|
|
32
|
+
const want = CASES.map((c) => String(new Function("U", "return " + c)(URL)));
|
|
33
|
+
|
|
34
|
+
function freePort(): number {
|
|
35
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
36
|
+
const port = listener.port;
|
|
37
|
+
listener.stop(true);
|
|
38
|
+
return port;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-json-"));
|
|
42
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
43
|
+
try {
|
|
44
|
+
const porffor = await ensurePorffor();
|
|
45
|
+
const node = Bun.which("node");
|
|
46
|
+
if (!node) throw new Error("native streams test needs node on PATH");
|
|
47
|
+
|
|
48
|
+
const [core, broker] = await Promise.all([
|
|
49
|
+
readFile(preludePath, "utf8"),
|
|
50
|
+
readFile(transportPath("broker"), "utf8"),
|
|
51
|
+
]);
|
|
52
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
53
|
+
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
54
|
+
const generated = join(workdir, "json.generated.js");
|
|
55
|
+
const binary = join(workdir, "json-native");
|
|
56
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
|
|
57
|
+
await rm(binary, { force: true });
|
|
58
|
+
|
|
59
|
+
const compile = Bun.spawn([node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"], {
|
|
60
|
+
cwd: workdir,
|
|
61
|
+
env: process.env,
|
|
62
|
+
stdout: "pipe",
|
|
63
|
+
stderr: "pipe",
|
|
64
|
+
});
|
|
65
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
66
|
+
compile.exited,
|
|
67
|
+
new Response(compile.stdout).text(),
|
|
68
|
+
new Response(compile.stderr).text(),
|
|
69
|
+
]);
|
|
70
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
71
|
+
|
|
72
|
+
const port = freePort();
|
|
73
|
+
server = Bun.spawn([binary], {
|
|
74
|
+
cwd: workdir,
|
|
75
|
+
env: { ...process.env, PORT: String(port) },
|
|
76
|
+
stdout: "ignore",
|
|
77
|
+
stderr: "inherit",
|
|
78
|
+
});
|
|
79
|
+
let ready = false;
|
|
80
|
+
for (let attempt = 0; attempt < 50 && !ready; attempt++) {
|
|
81
|
+
try {
|
|
82
|
+
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
|
|
83
|
+
ready = true;
|
|
84
|
+
} catch {
|
|
85
|
+
if (server.exitCode !== null) break;
|
|
86
|
+
await Bun.sleep(100);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
assert(ready, "compiled handler did not start");
|
|
90
|
+
// SAFETY: the handler above is the only producer of this body: a JSON array with one
|
|
91
|
+
// string per case; the comparison below reports any entry that differs.
|
|
92
|
+
const got = JSON.parse(await (await fetch(`http://127.0.0.1:${port}/`)).text()) as string[];
|
|
93
|
+
const diffs = CASES.flatMap((c, i) => (got[i] === want[i] ? [] : [`${c}\n want ${JSON.stringify(want[i])}\n got ${JSON.stringify(got[i])}`]));
|
|
94
|
+
assert.deepEqual(diffs, [], `JSON.stringify differs from the spec in ${diffs.length} of ${CASES.length} cases:\n${diffs.join("\n")}`);
|
|
95
|
+
console.log(`Native JSON.stringify honours toJSON in all ${CASES.length} cases: Date, URL, user objects, nested, undefined`);
|
|
96
|
+
} finally {
|
|
97
|
+
if (server) {
|
|
98
|
+
server.kill(9);
|
|
99
|
+
await server.exited;
|
|
100
|
+
}
|
|
101
|
+
await rm(workdir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
6
|
+
import {
|
|
7
|
+
preludePath,
|
|
8
|
+
TRANSPORT_MARKER,
|
|
9
|
+
transportPath,
|
|
10
|
+
wrapNativeFetchHandler,
|
|
11
|
+
} from "../src/wrap";
|
|
12
|
+
|
|
13
|
+
// fetch() hands a response's bytes back as a raw body (x-sb-raw-body, #176), and
|
|
14
|
+
// they can arrive as a UTF-16 string holding one byte per unit: JSON.parse of the
|
|
15
|
+
// transport's reply makes one. Returning that response unchanged must send the bytes.
|
|
16
|
+
const handler = `export default {fetch() {
|
|
17
|
+
const bytes = JSON.parse('"caf\\u00e9 \\u00ff ok"');
|
|
18
|
+
return new Response(bytes, { headers: { "x-sb-raw-body": "1", "content-type": "application/octet-stream" } });
|
|
19
|
+
}};`;
|
|
20
|
+
const want = [0x63, 0x61, 0x66, 0xe9, 0x20, 0xff, 0x20, 0x6f, 0x6b];
|
|
21
|
+
|
|
22
|
+
function freePort(): number {
|
|
23
|
+
const listener = Bun.listen({
|
|
24
|
+
hostname: "127.0.0.1",
|
|
25
|
+
port: 0,
|
|
26
|
+
socket: { data() {} },
|
|
27
|
+
});
|
|
28
|
+
const port = listener.port;
|
|
29
|
+
listener.stop(true);
|
|
30
|
+
return port;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-raw-body-"));
|
|
34
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
35
|
+
try {
|
|
36
|
+
const porffor = await ensurePorffor();
|
|
37
|
+
const node = Bun.which("node");
|
|
38
|
+
if (!node) throw new Error("native streams test needs node on PATH");
|
|
39
|
+
|
|
40
|
+
const [core, broker] = await Promise.all([
|
|
41
|
+
readFile(preludePath, "utf8"),
|
|
42
|
+
readFile(transportPath("broker"), "utf8"),
|
|
43
|
+
]);
|
|
44
|
+
assert(
|
|
45
|
+
core.includes(TRANSPORT_MARKER),
|
|
46
|
+
"runtime prelude is missing its transport marker",
|
|
47
|
+
);
|
|
48
|
+
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
49
|
+
const generated = join(workdir, "raw.generated.js");
|
|
50
|
+
const binary = join(workdir, "raw-native");
|
|
51
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
|
|
52
|
+
await rm(binary, { force: true });
|
|
53
|
+
|
|
54
|
+
const compile = Bun.spawn(
|
|
55
|
+
[
|
|
56
|
+
node,
|
|
57
|
+
resolve(porffor, "runtime/index.js"),
|
|
58
|
+
"native",
|
|
59
|
+
generated,
|
|
60
|
+
"-o",
|
|
61
|
+
binary,
|
|
62
|
+
"-s",
|
|
63
|
+
"-O0",
|
|
64
|
+
],
|
|
65
|
+
{
|
|
66
|
+
cwd: workdir,
|
|
67
|
+
env: process.env,
|
|
68
|
+
stdout: "pipe",
|
|
69
|
+
stderr: "pipe",
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
73
|
+
compile.exited,
|
|
74
|
+
new Response(compile.stdout).text(),
|
|
75
|
+
new Response(compile.stderr).text(),
|
|
76
|
+
]);
|
|
77
|
+
assert.equal(
|
|
78
|
+
exitCode,
|
|
79
|
+
0,
|
|
80
|
+
`Porffor native compile failed:\n${stderr}\n${stdout}`,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const port = freePort();
|
|
84
|
+
server = Bun.spawn([binary], {
|
|
85
|
+
cwd: workdir,
|
|
86
|
+
env: { ...process.env, PORT: String(port) },
|
|
87
|
+
stdout: "ignore",
|
|
88
|
+
stderr: "inherit",
|
|
89
|
+
});
|
|
90
|
+
let ready = false;
|
|
91
|
+
for (let attempt = 0; attempt < 50 && !ready; attempt++) {
|
|
92
|
+
try {
|
|
93
|
+
await fetch(`http://127.0.0.1:${port}/`, {
|
|
94
|
+
signal: AbortSignal.timeout(500),
|
|
95
|
+
});
|
|
96
|
+
ready = true;
|
|
97
|
+
} catch {
|
|
98
|
+
if (server.exitCode !== null) break;
|
|
99
|
+
await Bun.sleep(100);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
assert(ready, "compiled handler did not start");
|
|
103
|
+
const response = await fetch(`http://127.0.0.1:${port}/`);
|
|
104
|
+
const got = [...new Uint8Array(await response.arrayBuffer())];
|
|
105
|
+
assert.deepEqual(got, want, "raw body bytes");
|
|
106
|
+
assert.equal(
|
|
107
|
+
response.headers.get("x-sb-raw-body"),
|
|
108
|
+
null,
|
|
109
|
+
"the marker never reaches the client",
|
|
110
|
+
);
|
|
111
|
+
console.log("A raw UTF-16 body is sent as its bytes");
|
|
112
|
+
} finally {
|
|
113
|
+
if (server) {
|
|
114
|
+
server.kill(9);
|
|
115
|
+
await server.exited;
|
|
116
|
+
}
|
|
117
|
+
await rm(workdir, { recursive: true, force: true });
|
|
118
|
+
}
|