@sproutboat/runtime 0.34.2 → 0.34.4

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,280 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { connect } from "node:net";
5
+ import { tmpdir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { ensurePorffor } from "../../toolchain/src/acquire";
8
+ import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
9
+
10
+ // The native HTTP server's wire behaviour with raw sockets: keep-alive, pipelining (responses in
11
+ // order, also when a request arrives in pieces), a response bigger than the socket buffer to a slow
12
+ // reader (partial writes), a large request body, and many connections at once. Run once with a
13
+ // collection after every request (SB_GC_EVERY=1) and once with the default interval, so the
14
+ // collection schedule is shown not to disturb any of it.
15
+ const handler = `export default {async fetch(request) {
16
+ const url = new URL(request.url);
17
+ const path = url.pathname;
18
+ if (path.startsWith("/n/")) return new Response("n=" + path.slice(3), { headers: { "x-n": path.slice(3) } });
19
+ if (path === "/echo") { const b = await request.arrayBuffer(); return new Response("len=" + b.byteLength + " first=" + (b.byteLength ? new Uint8Array(b)[0] : -1) + " last=" + (b.byteLength ? new Uint8Array(b)[b.byteLength - 1] : -1)); }
20
+ if (path === "/big") {
21
+ const size = Number(url.searchParams.get("size"));
22
+ const out = new Uint8Array(size);
23
+ for (let i = 0; i < size; i++) out[i] = (i * 31 + 7) & 255;
24
+ return new Response(out);
25
+ }
26
+ if (path === "/garbage") { const rows = []; for (let i = 0; i < 2000; i++) rows.push({ i, s: "x".repeat(i % 50) }); return new Response(JSON.stringify(rows).length + ""); }
27
+ return new Response("not found", { status: 404 });
28
+ }};`;
29
+
30
+ function freePort(): number {
31
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
32
+ const port = listener.port;
33
+ listener.stop(true);
34
+ return port;
35
+ }
36
+
37
+ type Parsed = { status: number; headers: Map<string, string>; body: Buffer };
38
+
39
+ // Parses as many whole Content-Length responses as `buffer` holds; returns them and the rest.
40
+ type ParsedBatch = { done: Parsed[]; rest: Buffer };
41
+ function parseResponses(buffer: Buffer): ParsedBatch {
42
+ const done: Parsed[] = [];
43
+ let rest = buffer;
44
+ for (;;) {
45
+ const end = rest.indexOf("\r\n\r\n");
46
+ if (end < 0) break;
47
+ const head = rest.subarray(0, end).toString("latin1").split("\r\n");
48
+ const status = Number(head[0].split(" ")[1]);
49
+ const headers = new Map<string, string>();
50
+ for (const line of head.slice(1)) {
51
+ const at = line.indexOf(":");
52
+ headers.set(line.slice(0, at).toLowerCase(), line.slice(at + 1).trim());
53
+ }
54
+ const length = Number(headers.get("content-length") ?? 0);
55
+ if (rest.length < end + 4 + length) break;
56
+ done.push({ status, headers, body: rest.subarray(end + 4, end + 4 + length) });
57
+ rest = rest.subarray(end + 4 + length);
58
+ }
59
+ return { done, rest };
60
+ }
61
+
62
+ // Writes `chunks` (with `gapMs` between them), then reads until `want` responses arrived.
63
+ // `readDelayMs` holds the socket paused first, so the server's send buffer fills.
64
+ async function exchange(
65
+ port: number,
66
+ chunks: Buffer[],
67
+ want: number,
68
+ opts: { gapMs?: number; readDelayMs?: number; timeoutMs?: number } = {},
69
+ ): Promise<Parsed[]> {
70
+ return new Promise((resolveExchange, reject) => {
71
+ const socket = connect(port, "127.0.0.1");
72
+ let pending = Buffer.alloc(0);
73
+ const responses: Parsed[] = [];
74
+ const timer = setTimeout(() => {
75
+ socket.destroy();
76
+ reject(new Error(`timed out with ${responses.length} of ${want} responses`));
77
+ }, opts.timeoutMs ?? 30_000);
78
+ socket.on("error", reject);
79
+ socket.on("data", (data) => {
80
+ pending = Buffer.concat([pending, data]);
81
+ const { done, rest } = parseResponses(pending);
82
+ pending = rest;
83
+ responses.push(...done);
84
+ if (responses.length >= want) {
85
+ clearTimeout(timer);
86
+ socket.destroy();
87
+ resolveExchange(responses);
88
+ }
89
+ });
90
+ socket.on("connect", async () => {
91
+ if (opts.readDelayMs) {
92
+ socket.pause();
93
+ setTimeout(() => socket.resume(), opts.readDelayMs);
94
+ }
95
+ for (const chunk of chunks) {
96
+ socket.write(chunk);
97
+ if (opts.gapMs) await Bun.sleep(opts.gapMs);
98
+ }
99
+ });
100
+ });
101
+ }
102
+
103
+ const get = (path: string, close = false) =>
104
+ Buffer.from(`GET ${path} HTTP/1.1\r\nHost: t\r\n${close ? "Connection: close\r\n" : ""}\r\n`);
105
+ const post = (path: string, body: Buffer) =>
106
+ Buffer.concat([Buffer.from(`POST ${path} HTTP/1.1\r\nHost: t\r\nContent-Length: ${body.length}\r\n\r\n`), body]);
107
+
108
+ async function suite(port: number, label: string) {
109
+ // Keep-alive: sequential requests on one connection, each answered before the next is sent.
110
+ {
111
+ const socket = connect(port, "127.0.0.1");
112
+ await new Promise<void>((r) => socket.once("connect", () => r()));
113
+ let pending = Buffer.alloc(0);
114
+ for (let i = 0; i < 5; i++) {
115
+ socket.write(get(`/n/${i}`));
116
+ let got: Parsed | undefined;
117
+ while (!got) {
118
+ const data = await new Promise<Buffer>((r) => socket.once("data", r));
119
+ pending = Buffer.concat([pending, data]);
120
+ const parsed = parseResponses(pending);
121
+ pending = parsed.rest;
122
+ got = parsed.done[0];
123
+ }
124
+ assert.equal(got.body.toString(), `n=${i}`, `${label}: keep-alive response ${i}`);
125
+ }
126
+ socket.destroy();
127
+ }
128
+
129
+ // Pipelining: 60 requests in one write come back complete and in order.
130
+ {
131
+ const all = Buffer.concat(Array.from({ length: 60 }, (_, i) => get(`/n/${i}`)));
132
+ const got = await exchange(port, [all], 60);
133
+ assert.deepEqual(
134
+ got.map((r) => r.body.toString()),
135
+ Array.from({ length: 60 }, (_, i) => `n=${i}`),
136
+ `${label}: pipelined order`,
137
+ );
138
+ }
139
+
140
+ // Pipelining with requests cut at every awkward place: one byte at a time for the first two.
141
+ {
142
+ const bytes = Buffer.concat([get("/n/a"), post("/echo", Buffer.from("hello")), get("/n/b")]);
143
+ const chunks: Buffer[] = [];
144
+ for (let i = 0; i < 40; i++) chunks.push(bytes.subarray(i, i + 1));
145
+ chunks.push(bytes.subarray(40));
146
+ const got = await exchange(port, chunks, 3, { gapMs: 2 });
147
+ assert.deepEqual(
148
+ got.map((r) => r.body.toString()),
149
+ ["n=a", "len=5 first=104 last=111", "n=b"],
150
+ `${label}: pipelined requests split across packets`,
151
+ );
152
+ }
153
+
154
+ // A POST whose body arrives in pieces, followed by a pipelined GET.
155
+ {
156
+ const body = Buffer.alloc(300_000, 0x61);
157
+ body[0] = 0x62;
158
+ const request = Buffer.concat([post("/echo", body), get("/n/after")]);
159
+ const chunks: Buffer[] = [];
160
+ for (let at = 0; at < request.length; at += 40_000) chunks.push(request.subarray(at, at + 40_000));
161
+ const got = await exchange(port, chunks, 2, { gapMs: 3 });
162
+ assert.equal(got[0].body.toString(), "len=300000 first=98 last=97", `${label}: large request body`);
163
+ assert.equal(got[1].body.toString(), "n=after", `${label}: request after a large body`);
164
+ }
165
+
166
+ // Partial writes: 12 MB to a reader that does not read for 400 ms, then a pipelined small response
167
+ // that must still arrive after it, in order.
168
+ {
169
+ const size = 12 * 1024 * 1024;
170
+ const got = await exchange(port, [get(`/big?size=${size}`), get("/n/last")], 2, {
171
+ readDelayMs: 400,
172
+ timeoutMs: 60_000,
173
+ });
174
+ assert.equal(got[0].body.length, size, `${label}: big body length`);
175
+ const expected = Buffer.alloc(size);
176
+ for (let i = 0; i < size; i++) expected[i] = (i * 31 + 7) & 255;
177
+ assert.equal(
178
+ createHash("sha256").update(got[0].body).digest("hex"),
179
+ createHash("sha256").update(expected).digest("hex"),
180
+ `${label}: big body bytes`,
181
+ );
182
+ assert.equal(got[1].body.toString(), "n=last", `${label}: response after a partial write`);
183
+ }
184
+
185
+ // Connection: close ends the connection after the response.
186
+ {
187
+ const got = await exchange(port, [get("/n/close", true)], 1);
188
+ assert.equal(got[0].body.toString(), "n=close");
189
+ }
190
+
191
+ // 150 connections at once, 4 pipelined requests each, with garbage-heavy requests mixed in.
192
+ {
193
+ const results = await Promise.all(
194
+ Array.from({ length: 150 }, async (_, c) => {
195
+ const chunks = [get(`/n/${c}-0`), get("/garbage"), get(`/n/${c}-2`), get(`/n/${c}-3`)];
196
+ return exchange(port, [Buffer.concat(chunks)], 4);
197
+ }),
198
+ );
199
+ results.forEach((got, c) => {
200
+ assert.deepEqual(
201
+ [got[0].body.toString(), got[2].body.toString(), got[3].body.toString()],
202
+ [`n=${c}-0`, `n=${c}-2`, `n=${c}-3`],
203
+ `${label}: connection ${c}`,
204
+ );
205
+ assert.equal(got[1].status, 200);
206
+ });
207
+ }
208
+ }
209
+
210
+ const workdir = await mkdtemp(join(tmpdir(), "sb-native-http-"));
211
+ const servers: Array<ReturnType<typeof Bun.spawn>> = [];
212
+ try {
213
+ const porffor = await ensurePorffor();
214
+ const node = Bun.which("node");
215
+ if (!node) throw new Error("native http test needs node on PATH");
216
+ const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
217
+ assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
218
+ const generated = join(workdir, "http.generated.js");
219
+ const binary = join(workdir, "http-native");
220
+ await writeFile(
221
+ generated,
222
+ wrapNativeFetchHandler(
223
+ handler,
224
+ core.replace(TRANSPORT_MARKER, broker),
225
+ undefined,
226
+ undefined,
227
+ undefined,
228
+ "2026-10-21",
229
+ ),
230
+ );
231
+ const compile = Bun.spawn(
232
+ [node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
233
+ {
234
+ cwd: workdir,
235
+ env: process.env,
236
+ stdout: "pipe",
237
+ stderr: "pipe",
238
+ },
239
+ );
240
+ const [exitCode, stdout, stderr] = await Promise.all([
241
+ compile.exited,
242
+ new Response(compile.stdout).text(),
243
+ new Response(compile.stderr).text(),
244
+ ]);
245
+ assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
246
+
247
+ for (const every of ["1", "8"]) {
248
+ const port = freePort();
249
+ const server = Bun.spawn([binary], {
250
+ cwd: workdir,
251
+ env: { ...process.env, PORT: String(port), SB_GC_EVERY: every },
252
+ stdout: "ignore",
253
+ stderr: "inherit",
254
+ });
255
+ servers.push(server);
256
+ let ready = false;
257
+ for (let attempt = 0; attempt < 50 && !ready; attempt++) {
258
+ try {
259
+ await fetch(`http://127.0.0.1:${port}/n/0`, { signal: AbortSignal.timeout(500) });
260
+ ready = true;
261
+ } catch {
262
+ if (server.exitCode !== null) break;
263
+ await Bun.sleep(100);
264
+ }
265
+ }
266
+ assert(ready, "compiled handler did not start");
267
+ await suite(port, `SB_GC_EVERY=${every}`);
268
+ // The server is still answering after all of it.
269
+ assert.equal(await (await fetch(`http://127.0.0.1:${port}/n/z`)).text(), "n=z");
270
+ }
271
+ console.log(
272
+ "Native HTTP: keep-alive, pipelining, split requests, large bodies, partial writes and 150 connections hold at SB_GC_EVERY=1 and 8",
273
+ );
274
+ } finally {
275
+ for (const server of servers) {
276
+ server.kill(9);
277
+ await server.exited;
278
+ }
279
+ await rm(workdir, { recursive: true, force: true });
280
+ }
@@ -0,0 +1,131 @@
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 of strings as the spec defines it (Bun is the reference): quotes and backslash,
9
+ // every control character, U+2028 and U+2029 (not escaped), DEL, Latin-1 and wider text, astral
10
+ // characters, and lone or misordered surrogates (escaped as \\udXXX). Each input is built from
11
+ // code units in the handler, so the compiled string has whatever layout concatenation gives it.
12
+ const units = (s: string) => Array.from({ length: s.length }, (_, i) => s.charCodeAt(i));
13
+ const everyControl = Array.from({ length: 0x20 }, (_, i) => String.fromCharCode(i)).join("");
14
+ const INPUTS: string[] = [
15
+ "",
16
+ "abc",
17
+ 'a"b\\c"',
18
+ everyControl,
19
+ "x" + everyControl + "y",
20
+ "\u007f\u0080\u00ff",
21
+ "caf\u00e9",
22
+ "line\u2028sep\u2029end",
23
+ "\u0100",
24
+ "\u4e1c\u4eac",
25
+ "x\u{1F680}y",
26
+ "\u{1F680}".repeat(40),
27
+ "\ud800",
28
+ "\udc00",
29
+ "\ud800x",
30
+ "x\udc00",
31
+ "\udc00\ud800",
32
+ "\ud800\ud800\udc00",
33
+ "a\ud83dz",
34
+ "\ud83d\ude80\ud83d",
35
+ "tab\there\nnewline\r\f\b",
36
+ "plain ascii text that is long enough to run the loop for a while. ".repeat(60),
37
+ 'mix \u00e9\u4e1c\u{1F680}"\\\n\ud800 '.repeat(50),
38
+ ];
39
+ const handler = `export default {fetch() {
40
+ const inputs = ${JSON.stringify(INPUTS.map(units))};
41
+ const out = [];
42
+ for (let n = 0; n < inputs.length; n++) {
43
+ let s = "";
44
+ for (let i = 0; i < inputs[n].length; i++) s += String.fromCharCode(inputs[n][i]);
45
+ out.push(JSON.stringify(s));
46
+ out.push(JSON.stringify({ [s]: s, list: [s, 1, null], nested: { k: s } }));
47
+ }
48
+ return new Response(out.join("\\u0001"));
49
+ }};`;
50
+ const want = INPUTS.flatMap((s) => [
51
+ JSON.stringify(s),
52
+ JSON.stringify({ [s]: s, list: [s, 1, null], nested: { k: s } }),
53
+ ]);
54
+ function freePort(): number {
55
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
56
+ const port = listener.port;
57
+ listener.stop(true);
58
+ return port;
59
+ }
60
+
61
+ const workdir = await mkdtemp(join(tmpdir(), "sb-native-json-strings-"));
62
+ let server: ReturnType<typeof Bun.spawn> | undefined;
63
+ try {
64
+ const porffor = await ensurePorffor();
65
+ const node = Bun.which("node");
66
+ if (!node) throw new Error("native streams test needs node on PATH");
67
+
68
+ const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
69
+ assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
70
+ const prelude = core.replace(TRANSPORT_MARKER, broker);
71
+ const generated = join(workdir, "json-strings.generated.js");
72
+ const binary = join(workdir, "json-strings-native");
73
+ await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
74
+ await rm(binary, { force: true });
75
+
76
+ const compile = Bun.spawn(
77
+ [node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
78
+ {
79
+ cwd: workdir,
80
+ env: process.env,
81
+ stdout: "pipe",
82
+ stderr: "pipe",
83
+ },
84
+ );
85
+ const [exitCode, stdout, stderr] = await Promise.all([
86
+ compile.exited,
87
+ new Response(compile.stdout).text(),
88
+ new Response(compile.stderr).text(),
89
+ ]);
90
+ assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
91
+
92
+ const port = freePort();
93
+ server = Bun.spawn([binary], {
94
+ cwd: workdir,
95
+ env: { ...process.env, PORT: String(port) },
96
+ stdout: "ignore",
97
+ stderr: "inherit",
98
+ });
99
+ let ready = false;
100
+ for (let attempt = 0; attempt < 50 && !ready; attempt++) {
101
+ try {
102
+ await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
103
+ ready = true;
104
+ } catch {
105
+ if (server.exitCode !== null) break;
106
+ await Bun.sleep(100);
107
+ }
108
+ }
109
+ assert(ready, "compiled handler did not start");
110
+ const got = (await (await fetch(`http://127.0.0.1:${port}/`)).text()).split("\u0001");
111
+ assert.equal(got.length, want.length, "one result per case");
112
+ const diffs = want.flatMap((w, i) =>
113
+ got[i] === w
114
+ ? []
115
+ : [
116
+ `case ${i >> 1} (${i & 1 ? "object" : "string"})\n want ${w.slice(0, 200)}\n got ${got[i].slice(0, 200)}`,
117
+ ],
118
+ );
119
+ assert.deepEqual(
120
+ diffs,
121
+ [],
122
+ `JSON.stringify differs from the spec in ${diffs.length} of ${want.length} cases:\n${diffs.join("\n")}`,
123
+ );
124
+ console.log(`Native JSON.stringify of strings is byte-identical to the spec in all ${want.length} cases`);
125
+ } finally {
126
+ if (server) {
127
+ server.kill(9);
128
+ await server.exited;
129
+ }
130
+ await rm(workdir, { recursive: true, force: true });
131
+ }