@sproutboat/toolchain 0.3.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # @sproutboat/toolchain
2
+
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - New package: `@sproutboat/toolchain` — the single home for the Porffor pin
8
+ (`pin.ts`), the source patches (`patch.ts`: `ensurePorfforPatched` plus the
9
+ `$PORT`, request-body-limit, status-line, console-sink and remote-address
10
+ passes), and `ensurePorffor()` (fetch, verify sha256, extract, patch, cache in
11
+ `~/.cache/sproutboat`).
12
+
13
+ `sproutboat-cli` and the `sproutboat` monorepo both depend on it instead of
14
+ carrying their own copies, so the pin and the patch set can no longer drift —
15
+ which they had (the monorepo was on `alpha-4` and applied 1 of the ~13 patches).
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@sproutboat/toolchain",
3
+ "version": "0.3.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./pin": "./src/pin.ts",
11
+ "./patch": "./src/patch.ts",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "scripts": {
15
+ "test": "bun test ./"
16
+ }
17
+ }
@@ -0,0 +1,145 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtemp, mkdir, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, join } from "node:path";
6
+ import { ensurePorffor, PORFFOR_COMMIT_FULL, PorfforToolchainError } from "./index";
7
+
8
+ const temporary: string[] = [];
9
+ afterEach(async () => {
10
+ await Promise.all(temporary.splice(0).map((path) => rm(path, { recursive: true, force: true })));
11
+ });
12
+
13
+ async function fixture(): Promise<{ archive: string; sha256: string }> {
14
+ const root = await mkdtemp(join(tmpdir(), "sb-porffor-fixture-"));
15
+ temporary.push(root);
16
+ const source = join(root, `porffor-${PORFFOR_COMMIT_FULL}`);
17
+ const files = {
18
+ "runtime/index.js": "fixture:runtime/index.js\n",
19
+ "compiler/render.js":
20
+ "void porf_native_fetch_runtime_init(void) {\n signal(SIGPIPE, SIG_IGN);\n porf_init(0, NULL);\n}\n" +
21
+ "f64 porf_native_fetch_get_port(void) {\nfixture:compiler/render.js\n",
22
+ "compiler/index.js": " '-xc', '-', '-c',\n uSocketsArchive,\n '-lm'\n",
23
+ "compiler/uwebsockets.js":
24
+ "static const size_t REQUEST_BODY_MAX_BYTES = 1024u * 1024u;\n" +
25
+ "static std::string_view lookup_status_line(i32 status) {\n" +
26
+ ' switch (status) {\n case 302: return "302 Found";\n default: return {};\n }\n}\n' +
27
+ "static i32 collect_headers(uWS::HttpRequest* req) {\n" +
28
+ " i32 header_capacity = 0;\n" +
29
+ " const i32 header_bytes = 16 + header_capacity * 8;\n" +
30
+ " i32 slot = 0;\n for (auto [key, value] : *req) {\n slot++;\n }\n" +
31
+ " *((i32*)(porf_mem + headers_ptr)) = slot;\n\n return headers_ptr;\n}\n" +
32
+ "static void on_request(uWS::HttpResponse<false>* res, uWS::HttpRequest* req) {\n" +
33
+ " const i32 headers_ptr = collect_headers(req);\n}\n",
34
+ };
35
+ for (const [file, contents] of Object.entries(files)) {
36
+ const path = join(source, file);
37
+ await mkdir(join(path, ".."), { recursive: true });
38
+ await writeFile(path, contents);
39
+ }
40
+ const archive = join(root, "porffor.tar.gz");
41
+ const tar = Bun.spawn(["tar", "-czf", archive, "-C", root, basename(source)], { stderr: "pipe" });
42
+ const [code, stderr] = await Promise.all([tar.exited, new Response(tar.stderr).text()]);
43
+ if (code !== 0) throw new Error(`could not create fixture: ${stderr}`);
44
+ const sha256 = createHash("sha256")
45
+ .update(await readFile(archive))
46
+ .digest("hex");
47
+ return { archive, sha256 };
48
+ }
49
+
50
+ test("Porffor acquisition is atomic, shared concurrently, and warm-cache offline", async () => {
51
+ const { archive, sha256 } = await fixture();
52
+ const cacheRoot = await mkdtemp(join(tmpdir(), "sb-porffor-cache-"));
53
+ temporary.push(cacheRoot);
54
+ let requests = 0;
55
+ const fetcher = async () => {
56
+ requests += 1;
57
+ await Bun.sleep(5);
58
+ return new Response(Bun.file(archive));
59
+ };
60
+ const options = { cacheRoot, url: "fixture", expectedSha256: sha256, fetcher };
61
+ const roots = await Promise.all(Array.from({ length: 8 }, () => ensurePorffor(options)));
62
+ expect(new Set(roots).size).toBe(1);
63
+ expect(requests).toBe(1);
64
+ expect((await readdir(cacheRoot)).filter((name) => name.startsWith(".porffor-"))).toEqual([]);
65
+ const offline = await ensurePorffor({
66
+ ...options,
67
+ fetcher: async () => {
68
+ throw new Error("offline fetch must not run");
69
+ },
70
+ });
71
+ expect(offline).toBe(roots[0]);
72
+ });
73
+
74
+ test("integrity failures stop acquisition and publish no cache entry", async () => {
75
+ const { archive } = await fixture();
76
+ const cacheRoot = await mkdtemp(join(tmpdir(), "sb-porffor-integrity-"));
77
+ temporary.push(cacheRoot);
78
+ const error = await ensurePorffor({
79
+ cacheRoot,
80
+ url: "fixture",
81
+ expectedSha256: "0".repeat(64),
82
+ fetcher: async () => new Response(Bun.file(archive)),
83
+ }).catch((cause: unknown) => cause);
84
+ expect(error).toBeInstanceOf(PorfforToolchainError);
85
+ if (!(error instanceof PorfforToolchainError)) throw error;
86
+ expect(error.kind).toBe("integrity");
87
+ expect((await readdir(cacheRoot)).some((name) => name.startsWith("porffor-"))).toBe(false);
88
+ });
89
+
90
+ test("a corrupted warm cache is replaced from the verified archive", async () => {
91
+ const { archive, sha256 } = await fixture();
92
+ const cacheRoot = await mkdtemp(join(tmpdir(), "sb-porffor-corrupt-"));
93
+ temporary.push(cacheRoot);
94
+ let requests = 0;
95
+ const options = {
96
+ cacheRoot,
97
+ url: "fixture",
98
+ expectedSha256: sha256,
99
+ fetcher: async () => {
100
+ requests += 1;
101
+ return new Response(Bun.file(archive));
102
+ },
103
+ };
104
+ const root = await ensurePorffor(options);
105
+ await writeFile(join(root, "compiler/render.js"), "corrupt");
106
+ await ensurePorffor(options);
107
+ expect(requests).toBe(2);
108
+ expect(await readFile(join(root, "compiler/render.js"), "utf8")).toContain('getenv("PORT")');
109
+ });
110
+
111
+ test("an interrupted stale lock is recovered", async () => {
112
+ const { archive, sha256 } = await fixture();
113
+ const cacheRoot = await mkdtemp(join(tmpdir(), "sb-porffor-interrupted-"));
114
+ temporary.push(cacheRoot);
115
+ const lock = join(cacheRoot, `porffor-${PORFFOR_COMMIT_FULL}.lock`);
116
+ await mkdir(lock);
117
+ const stale = new Date(Date.now() - 10 * 60_000);
118
+ await utimes(lock, stale, stale);
119
+ const root = await ensurePorffor({
120
+ cacheRoot,
121
+ url: "fixture",
122
+ expectedSha256: sha256,
123
+ fetcher: async () => new Response(Bun.file(archive)),
124
+ });
125
+ expect(await readFile(join(root, "runtime/index.js"), "utf8")).toContain("fixture:");
126
+ expect((await readdir(cacheRoot)).some((name) => name.endsWith(".lock"))).toBe(false);
127
+ });
128
+
129
+ test("download failures are classified without publishing partial state", async () => {
130
+ const cacheRoot = await mkdtemp(join(tmpdir(), "sb-porffor-download-"));
131
+ temporary.push(cacheRoot);
132
+ let attempts = 0;
133
+ const error = await ensurePorffor({
134
+ cacheRoot,
135
+ url: "https://invalid.test/source.tar.gz",
136
+ fetcher: async () => {
137
+ attempts += 1;
138
+ throw new Error("offline");
139
+ },
140
+ }).catch((cause: unknown) => cause);
141
+ if (!(error instanceof PorfforToolchainError)) throw error;
142
+ expect(error.kind).toBe("download");
143
+ expect(attempts).toBe(2);
144
+ expect(await readdir(cacheRoot)).toEqual([]);
145
+ });
package/src/acquire.ts ADDED
@@ -0,0 +1,163 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { resolve } from "node:path";
6
+ import { ensurePorfforPatched } from "./patch";
7
+ import { PORFFOR_ARCHIVE_SHA256, PORFFOR_ARCHIVE_URL, PORFFOR_COMMIT_FULL } from "./pin";
8
+
9
+ export class PorfforToolchainError extends Error {
10
+ constructor(
11
+ readonly kind: "download" | "integrity" | "archive" | "cache" | "unsupported",
12
+ message: string,
13
+ ) {
14
+ super(message);
15
+ }
16
+ }
17
+
18
+ type AcquireOptions = {
19
+ cacheRoot?: string;
20
+ url?: string;
21
+ expectedSha256?: string;
22
+ fetcher?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
23
+ timeoutMs?: number;
24
+ };
25
+
26
+ const required = ["runtime/index.js", "compiler/render.js", "compiler/index.js", "compiler/uwebsockets.js"];
27
+ async function digest(path: string): Promise<string> {
28
+ return createHash("sha256")
29
+ .update(await readFile(path))
30
+ .digest("hex");
31
+ }
32
+
33
+ async function complete(dir: string, expectedArchive = PORFFOR_ARCHIVE_SHA256): Promise<boolean> {
34
+ try {
35
+ // SAFETY: the cache manifest is private data written below; every consumed
36
+ // field is still checked before it can make the cache valid.
37
+ const manifest = JSON.parse(await readFile(resolve(dir, ".sproutboat-complete"), "utf8")) as {
38
+ commit?: string;
39
+ archiveSha256?: string;
40
+ files?: Record<string, string>;
41
+ };
42
+ if (manifest.commit !== PORFFOR_COMMIT_FULL || manifest.archiveSha256 !== expectedArchive) return false;
43
+ for (const file of required)
44
+ if (!manifest.files?.[file] || (await digest(resolve(dir, file))) !== manifest.files[file]) return false;
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ async function download(
52
+ url: string,
53
+ path: string,
54
+ fetcher: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
55
+ timeoutMs: number,
56
+ ): Promise<void> {
57
+ let last: unknown;
58
+ for (let attempt = 0; attempt < 2; attempt++) {
59
+ try {
60
+ const response = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) });
61
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
62
+ await writeFile(path, new Uint8Array(await response.arrayBuffer()));
63
+ return;
64
+ } catch (error) {
65
+ last = error;
66
+ }
67
+ }
68
+ throw new PorfforToolchainError(
69
+ "download",
70
+ `could not download pinned Porffor from ${url}: ${last instanceof Error ? last.message : String(last)}`,
71
+ );
72
+ }
73
+
74
+ async function extract(archive: string, stage: string): Promise<void> {
75
+ const child = Bun.spawn(["tar", "-xzf", archive, "-C", stage, "--strip-components=1"], {
76
+ stdout: "pipe",
77
+ stderr: "pipe",
78
+ });
79
+ const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
80
+ if (code !== 0)
81
+ throw new PorfforToolchainError("archive", `could not extract pinned Porffor archive: ${stderr.trim()}`);
82
+ for (const file of required)
83
+ if (!existsSync(resolve(stage, file)))
84
+ throw new PorfforToolchainError("archive", `Porffor archive is missing required file ${file}`);
85
+ }
86
+
87
+ async function waitForPublisher(dir: string, lock: string): Promise<string | null> {
88
+ const deadline = Date.now() + 30_000;
89
+ while (Date.now() < deadline) {
90
+ if (await complete(dir)) return dir;
91
+ if (!existsSync(lock)) return null;
92
+ const age = Date.now() - (await stat(lock)).mtimeMs;
93
+ if (age > 5 * 60_000) {
94
+ await rm(lock, { recursive: true, force: true });
95
+ return null;
96
+ }
97
+ await Bun.sleep(25);
98
+ }
99
+ throw new PorfforToolchainError("cache", `timed out waiting for Porffor cache lock ${lock}`);
100
+ }
101
+
102
+ /** Acquire the immutable compiler source into a verified, atomically published cache entry. */
103
+ export async function ensurePorffor(options: AcquireOptions = {}): Promise<string> {
104
+ const override = process.env.SPROUTBOAT_PORFFOR_DIR;
105
+ if (override) {
106
+ const dir = resolve(override);
107
+ for (const file of required)
108
+ if (!existsSync(resolve(dir, file)))
109
+ throw new PorfforToolchainError("unsupported", `SPROUTBOAT_PORFFOR_DIR is missing ${file}`);
110
+ return dir;
111
+ }
112
+ const root = resolve(
113
+ options.cacheRoot ?? process.env.SPROUTBOAT_TOOLCHAIN_CACHE ?? resolve(homedir(), ".cache/sproutboat"),
114
+ );
115
+ const dir = resolve(root, `porffor-${PORFFOR_COMMIT_FULL}`);
116
+ const expected = options.expectedSha256 ?? PORFFOR_ARCHIVE_SHA256;
117
+ if (await complete(dir, expected)) return dir;
118
+ await mkdir(root, { recursive: true });
119
+ const lock = `${dir}.lock`;
120
+ try {
121
+ await mkdir(lock);
122
+ } catch {
123
+ const published = await waitForPublisher(dir, lock);
124
+ if (published) return published;
125
+ return ensurePorffor(options);
126
+ }
127
+ const stage = resolve(root, `.porffor-${PORFFOR_COMMIT_FULL}-${process.pid}-${crypto.randomUUID()}`);
128
+ try {
129
+ if (await complete(dir, expected)) return dir;
130
+ await rm(dir, { recursive: true, force: true });
131
+ await mkdir(stage);
132
+ const archive = resolve(stage, "source.tar.gz");
133
+ await download(options.url ?? PORFFOR_ARCHIVE_URL, archive, options.fetcher ?? fetch, options.timeoutMs ?? 30_000);
134
+ const actual = await digest(archive);
135
+ if (actual !== expected)
136
+ throw new PorfforToolchainError(
137
+ "integrity",
138
+ `Porffor archive sha256 mismatch\n expected ${expected}\n got ${actual}`,
139
+ );
140
+ await extract(archive, stage);
141
+ await rm(archive, { force: true });
142
+ await ensurePorfforPatched(stage);
143
+ const files = Object.fromEntries(
144
+ await Promise.all(required.map(async (file) => [file, await digest(resolve(stage, file))] as const)),
145
+ );
146
+ await writeFile(
147
+ resolve(stage, ".sproutboat-complete"),
148
+ JSON.stringify({ commit: PORFFOR_COMMIT_FULL, archiveSha256: actual, files }),
149
+ { mode: 0o444 },
150
+ );
151
+ await rename(stage, dir);
152
+ return dir;
153
+ } finally {
154
+ await rm(stage, { recursive: true, force: true });
155
+ await rm(lock, { recursive: true, force: true });
156
+ }
157
+ }
158
+
159
+ export function cachedPorfforRoot(
160
+ root = process.env.SPROUTBOAT_TOOLCHAIN_CACHE ?? resolve(homedir(), ".cache/sproutboat"),
161
+ ): string {
162
+ return resolve(root, `porffor-${PORFFOR_COMMIT_FULL}`);
163
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@sproutboat/toolchain` — the one place the Porffor pin and the source
3
+ * patches live. Both `sproutboat-cli` and the `sproutboat` monorepo depend on
4
+ * this so they cannot drift apart (which is exactly what happened before).
5
+ *
6
+ * - `pin` — the pinned commit + archive hash, and `porfforVersion()`.
7
+ * - `patch` — `ensurePorfforPatched(root)` and the individual patch passes.
8
+ * - `acquire` — `ensurePorffor()`: fetch, verify, extract, patch, cache.
9
+ */
10
+ export * from "./pin";
11
+ export * from "./patch";
12
+ export * from "./acquire";
@@ -0,0 +1,134 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { patchRenderJs, patchUwebsockets } from "./patch";
6
+
7
+ // The parts of Porffor's compiler/render.js the patch anchors to.
8
+ const RENDER = `void porf_native_fetch_runtime_init(void) {
9
+ #ifdef _WIN32
10
+ exit(1);
11
+ #else
12
+ signal(SIGPIPE, SIG_IGN);
13
+ porf_init(0, NULL);
14
+ #endif
15
+ }
16
+
17
+ f64 porf_native_fetch_get_port(void) {
18
+ return __porffor_native_fetch_port.val;
19
+ }
20
+ `;
21
+
22
+ // Porffor's compiler/uwebsockets.js, trimmed to the parts the patch touches:
23
+ // body limit (#56), the #156 status-line switch, and #163's collect_headers.
24
+ const SHIM = `static const size_t REQUEST_BODY_MAX_BYTES = 1024u * 1024u;
25
+
26
+ static std::string_view lookup_status_line(i32 status) {
27
+ switch (status) {
28
+ case 200: return "200 OK";
29
+ case 302: return "302 Found";
30
+ case 429: return "429 Too Many Requests";
31
+ default: return {};
32
+ }
33
+ }
34
+
35
+ static i32 collect_headers(uWS::HttpRequest* req) {
36
+ i32 header_capacity = 0;
37
+ for (auto [key, value] : *req) {
38
+ (void)key;
39
+ (void)value;
40
+ header_capacity += 2;
41
+ }
42
+ const i32 header_bytes = 16 + header_capacity * 8;
43
+ const i32 headers_ptr = (i32)porf_native_fetch_alloc((u32)header_bytes, 208);
44
+ const i32 entries_ptr = headers_ptr + 16;
45
+ i32 slot = 0;
46
+ for (auto [key, value] : *req) {
47
+ *((u64*)(porf_mem + entries_ptr + slot * 8)) = pack_bytestring((i32)porf_native_fetch_alloc_bytestring(key.data(), key.size()));
48
+ slot++;
49
+ *((u64*)(porf_mem + entries_ptr + slot * 8)) = pack_bytestring((i32)porf_native_fetch_alloc_bytestring(value.data(), value.size()));
50
+ slot++;
51
+ }
52
+ *((i32*)(porf_mem + headers_ptr)) = slot;
53
+
54
+ return headers_ptr;
55
+ }
56
+
57
+ static void on_request(uWS::HttpResponse<false>* res, uWS::HttpRequest* req) {
58
+ const i32 headers_ptr = collect_headers(req);
59
+ }
60
+ `;
61
+
62
+ async function shimDir(): Promise<string> {
63
+ const root = await mkdtemp(join(tmpdir(), "sb-patch-uws-"));
64
+ await mkdir(join(root, "compiler"), { recursive: true });
65
+ await writeFile(join(root, "compiler/uwebsockets.js"), SHIM);
66
+ return root;
67
+ }
68
+
69
+ test("#156: status-line fallback synthesizes a line for unlisted codes, idempotently", async () => {
70
+ const root = await shimDir();
71
+ try {
72
+ await patchUwebsockets(root);
73
+ const once = await readFile(join(root, "compiler/uwebsockets.js"), "utf8");
74
+
75
+ // Return type widened, empty fallback replaced, known cases untouched.
76
+ expect(once).toContain("static std::string lookup_status_line(i32 status) {");
77
+ expect(once).not.toContain("std::string_view lookup_status_line");
78
+ expect(once).toContain('default: return std::to_string(status) + " Status";');
79
+ expect(once).not.toContain("default: return {};");
80
+ expect(once).toContain('case 302: return "302 Found";');
81
+ // 303 gets its real phrase; the switch stays well-formed (302 still present once).
82
+ expect(once).toContain('case 303: return "303 See Other";');
83
+ expect(once.match(/case 302: return "302 Found";/g)).toHaveLength(1);
84
+ // Body-limit edit still rides along.
85
+ expect(once).toContain("sb_request_body_max");
86
+
87
+ // #163: collect_headers takes res, drops a client-sent x-sb-remote-addr, and
88
+ // appends the real peer; the call site passes res through.
89
+ expect(once).toContain("static i32 collect_headers(uWS::HttpRequest* req, uWS::HttpResponse<false>* res) {");
90
+ expect(once).toContain("const i32 headers_ptr = collect_headers(req, res);");
91
+ expect(once).toContain('if (key == "x-sb-remote-addr") continue;');
92
+ expect(once).toContain("res->getRemoteAddressAsText()");
93
+ expect(once).toContain('porf_native_fetch_alloc_bytestring("x-sb-remote-addr", 16)');
94
+ expect(once).toContain("header_capacity += 2;");
95
+
96
+ await patchUwebsockets(root);
97
+ expect(await readFile(join(root, "compiler/uwebsockets.js"), "utf8")).toBe(once);
98
+ } finally {
99
+ await rm(root, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test("a drifted shim fails loudly instead of silently no-op'ing", async () => {
104
+ const root = await mkdtemp(join(tmpdir(), "sb-patch-uws-drift-"));
105
+ try {
106
+ await mkdir(join(root, "compiler"), { recursive: true });
107
+ await writeFile(join(root, "compiler/uwebsockets.js"), "// nothing the patch recognizes\n");
108
+ await expect(patchUwebsockets(root)).rejects.toThrow(/anchor not found/);
109
+ } finally {
110
+ await rm(root, { recursive: true, force: true });
111
+ }
112
+ });
113
+
114
+ test("#165: render.js routes console output to stderr, unbuffered, idempotently", async () => {
115
+ const root = await mkdtemp(join(tmpdir(), "sb-patch-render-"));
116
+ try {
117
+ await mkdir(join(root, "compiler"), { recursive: true });
118
+ await writeFile(join(root, "compiler/render.js"), RENDER);
119
+ await patchRenderJs(root);
120
+ const once = await readFile(join(root, "compiler/render.js"), "utf8");
121
+
122
+ expect(once).toContain("dup2(2, 1);");
123
+ expect(once).toContain("setvbuf(stdout, NULL, _IONBF, 0);");
124
+ // Injected inside runtime_init, right after the SIGPIPE line.
125
+ expect(once).toMatch(/signal\(SIGPIPE, SIG_IGN\);\n {2}\/\* sproutboat #165/);
126
+ // The $PORT edit still lands too.
127
+ expect(once).toContain('getenv("PORT")');
128
+
129
+ await patchRenderJs(root);
130
+ expect(await readFile(join(root, "compiler/render.js"), "utf8")).toBe(once);
131
+ } finally {
132
+ await rm(root, { recursive: true, force: true });
133
+ }
134
+ });
package/src/patch.ts ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Idempotent, marker-guarded in-place edits to Porffor's generated C
3
+ * (`compiler/render.js`, `compiler/index.js`, `compiler/uwebsockets.js`). Run
4
+ * from the build path, not a `postinstall` hook: package managers block
5
+ * dependency lifecycle scripts by default, so a published `postinstall` would
6
+ * silently not run.
7
+ *
8
+ * Each edit is independent and re-applied on every build; a file patched by an
9
+ * older version of this module still receives the newer edits. All of them are
10
+ * tracked in patches/UPSTREAM.md and go away as Porffor closes the gaps.
11
+ */
12
+ import { readFile, writeFile } from "node:fs/promises";
13
+ import { resolve } from "node:path";
14
+
15
+ // --- compiler/render.js: the native-fetch server, rendered as C text ---
16
+
17
+ /** The `porf_native_fetch_get_port()` open brace: $PORT / --port injection site. */
18
+ const PORT_ANCHOR = "f64 porf_native_fetch_get_port(void) {\n";
19
+ /** Port from $PORT (the supervisor and `sproutboat dev` set it). */
20
+ const PORT_INJECT =
21
+ ' const char* __sb_port = getenv("PORT");\n' +
22
+ " if (__sb_port && *__sb_port) { long __sb_v = strtol(__sb_port, NULL, 10); if (__sb_v > 0 && __sb_v < 65536) return (f64)__sb_v; }\n";
23
+ const PORT_MARKER = 'getenv("PORT")';
24
+
25
+ /**
26
+ * baronunread/sproutboat#165 — a handler's `console.log` / `console.error` goes
27
+ * to stdout via `printf`, which is fully buffered when stdout is not a TTY (a
28
+ * pipe, a file, a service manager) and never flushed, because the native-fetch
29
+ * server loop never returns. So the output simply vanishes.
30
+ *
31
+ * Point stdout at stderr — the conventional log sink, and where Porffor already
32
+ * writes its own banner and diagnostics — and make it unbuffered so records
33
+ * land as they happen. Applies to every native-fetch build; a deployed sprout's
34
+ * supervisor wants the same records (#87, #146).
35
+ */
36
+ const CONSOLE_ANCHOR = " signal(SIGPIPE, SIG_IGN);\n";
37
+ const CONSOLE_INJECT =
38
+ " /* sproutboat #165: unbuffered handler console output, routed to stderr */\n" +
39
+ " dup2(2, 1);\n" +
40
+ " setvbuf(stdout, NULL, _IONBF, 0);\n";
41
+ const CONSOLE_MARKER = "sproutboat #165";
42
+
43
+ /** render.js edits: `[marker, anchor, inject, what]`, each inserted after its anchor. */
44
+ const RENDER_EDITS = [
45
+ [PORT_MARKER, PORT_ANCHOR, PORT_INJECT, "$PORT support"],
46
+ [CONSOLE_MARKER, CONSOLE_ANCHOR, CONSOLE_INJECT, "console output sink (#165)"],
47
+ ] as const;
48
+
49
+ // #15 — no `--port` flag: Porffor's native-fetch entry point calls
50
+ // `porf_init(0, NULL)` (see porf_native_fetch_runtime_init in render.js), so a
51
+ // native-fetch binary never sees argv at all. A standalone binary takes its
52
+ // port and data directory from the environment instead, which is what systemd
53
+ // and docker set anyway. Worth an upstream note alongside the $PORT ask.
54
+
55
+ /**
56
+ * #15 — let the build add objects to the native-fetch link line.
57
+ *
58
+ * Porffor builds `linkArgs` as a fixed array, so an embedded backend that needs
59
+ * SQLite compiled into the sprout has nowhere to put it. `CXX` is not a way in:
60
+ * a musl (deploy) build overrides it outright. This splices one spread of
61
+ * `SB_EXTRA_LINK` before `-lm`, inert unless the variable is set.
62
+ */
63
+ const LINK_ANCHOR = " uSocketsArchive,\n '-lm'\n";
64
+ const LINK_INJECT =
65
+ " ...(process.env.SB_EXTRA_LINK ? process.env.SB_EXTRA_LINK.split(' ').filter(Boolean) : []),\n";
66
+ const LINK_MARKER = "SB_EXTRA_LINK";
67
+
68
+ /**
69
+ * #15 — and the same for the compile step, so the prelude's inline C can
70
+ * `#include <bearssl.h>`. The link patch alone is not enough: Porffor compiles
71
+ * the generated C from stdin with a fixed argument list, so there is otherwise
72
+ * no way to add an include path.
73
+ */
74
+ const CFLAGS_ANCHOR = " '-xc', '-', '-c',\n";
75
+ const CFLAGS_INJECT =
76
+ " ...(process.env.SB_EXTRA_CFLAGS ? process.env.SB_EXTRA_CFLAGS.split(' ').filter(Boolean) : []),\n";
77
+ const CFLAGS_MARKER = "SB_EXTRA_CFLAGS";
78
+
79
+ /**
80
+ * #56 — make the inbound request-body limit configurable.
81
+ *
82
+ * Porffor's uWebSockets shim hardcodes 1 MiB and answers anything larger with a
83
+ * bare `413 request body too large` before the handler runs, so a project can
84
+ * neither accept a bigger upload nor say anything useful about the refusal.
85
+ *
86
+ * The default stays 1 MiB: a larger body is held in memory whole, so raising it
87
+ * is a decision about this deployment's memory, not something to inherit.
88
+ */
89
+ const BODY_ANCHOR = "static const size_t REQUEST_BODY_MAX_BYTES = 1024u * 1024u;";
90
+ const BODY_INJECT = `static size_t sb_request_body_max(void) {
91
+ static size_t cached = 0;
92
+ if (cached == 0) {
93
+ const char* raw = getenv("SB_REQUEST_BODY_MAX");
94
+ long parsed = raw && *raw ? atol(raw) : 0;
95
+ cached = parsed > 0 ? (size_t)parsed : 1024u * 1024u;
96
+ }
97
+ return cached;
98
+ }
99
+ #define REQUEST_BODY_MAX_BYTES sb_request_body_max()`;
100
+ const BODY_MARKER = "sb_request_body_max";
101
+
102
+ /**
103
+ * baronunread/sproutboat#156 — `lookup_status_line()` maps a status code to a
104
+ * reason string for `res->writeStatus()`, and any code missing from its switch
105
+ * (303, 206, 300, 305, 402, 451, ...) falls to `default: return {}` — an empty
106
+ * status line, which uWS emits as a malformed response and the client sees as a
107
+ * connection reset. Only the embedded/standalone path hits this; the broker
108
+ * serializes its own status line.
109
+ *
110
+ * Rather than chase the IANA registry case by case, synthesize a valid line for
111
+ * anything unlisted from the number itself (return type widens to `std::string`;
112
+ * the sole caller feeds it straight to `writeStatus`, which copies synchronously).
113
+ * Known codes keep their proper reason phrase.
114
+ */
115
+ const STATUS_SIG_ANCHOR = "static std::string_view lookup_status_line(i32 status) {";
116
+ const STATUS_SIG_INJECT = "static std::string lookup_status_line(i32 status) {";
117
+ const STATUS_SIG_MARKER = "static std::string lookup_status_line(i32 status) {";
118
+ // 303 is the one this was reported for (POST-redirect-GET); give it the real
119
+ // reason phrase. Everything else unlisted rides the synthesized fallback below.
120
+ const STATUS_303_ANCHOR = ' case 302: return "302 Found";\n';
121
+ const STATUS_303_INJECT = ' case 302: return "302 Found";\n case 303: return "303 See Other";\n';
122
+ const STATUS_303_MARKER = 'case 303: return "303 See Other";';
123
+ const STATUS_DEFAULT_ANCHOR = " default: return {};";
124
+ const STATUS_DEFAULT_INJECT = ' default: return std::to_string(status) + " Status";';
125
+ const STATUS_DEFAULT_MARKER = 'std::to_string(status) + " Status"';
126
+
127
+ /**
128
+ * baronunread/sproutboat#163 — a native-fetch handler has no way to see the
129
+ * connection's remote address, so standalone apps hand-roll `X-Forwarded-For`
130
+ * parsing with a spoofable boolean flag. Nothing but C on the socket side can
131
+ * reach the peer, so `collect_headers` (the one place request headers cross into
132
+ * JS) grows a `res` argument and appends `x-sb-remote-addr:
133
+ * <res->getRemoteAddressAsText()>`. Any client-sent header of that name is
134
+ * dropped first — it is reserved, the server owns it. The prelude reads it into
135
+ * `request.cf.clientIp` and resolves it against `SB_TRUSTED_PROXIES` if set.
136
+ */
137
+ const HDR_SIG_ANCHOR = "static i32 collect_headers(uWS::HttpRequest* req) {\n";
138
+ const HDR_SIG_INJECT = "static i32 collect_headers(uWS::HttpRequest* req, uWS::HttpResponse<false>* res) {\n";
139
+ const HDR_SIG_MARKER = "collect_headers(uWS::HttpRequest* req, uWS::HttpResponse";
140
+
141
+ const HDR_CALL_ANCHOR = "const i32 headers_ptr = collect_headers(req);";
142
+ const HDR_CALL_INJECT = "const i32 headers_ptr = collect_headers(req, res);";
143
+ const HDR_CALL_MARKER = "collect_headers(req, res)";
144
+
145
+ const HDR_CAP_ANCHOR = " const i32 header_bytes = 16 + header_capacity * 8;\n";
146
+ const HDR_CAP_INJECT =
147
+ " header_capacity += 2; // sproutboat #163: room for the x-sb-remote-addr entry\n" +
148
+ " const i32 header_bytes = 16 + header_capacity * 8;\n";
149
+ const HDR_CAP_MARKER = "sproutboat #163";
150
+
151
+ const HDR_SKIP_ANCHOR = " i32 slot = 0;\n for (auto [key, value] : *req) {\n";
152
+ const HDR_SKIP_INJECT =
153
+ " i32 slot = 0;\n for (auto [key, value] : *req) {\n" +
154
+ ' if (key == "x-sb-remote-addr") continue; // #163: reserved, the server sets it below\n';
155
+ const HDR_SKIP_MARKER = 'key == "x-sb-remote-addr"';
156
+
157
+ const HDR_APPEND_ANCHOR = " *((i32*)(porf_mem + headers_ptr)) = slot;\n\n return headers_ptr;";
158
+ const HDR_APPEND_INJECT =
159
+ " {\n" +
160
+ " const std::string_view __sb_peer = res ? res->getRemoteAddressAsText() : std::string_view();\n" +
161
+ ' *((u64*)(porf_mem + entries_ptr + slot * 8)) = pack_bytestring((i32)porf_native_fetch_alloc_bytestring("x-sb-remote-addr", 16));\n' +
162
+ " slot++;\n" +
163
+ " *((u64*)(porf_mem + entries_ptr + slot * 8)) = pack_bytestring((i32)porf_native_fetch_alloc_bytestring(__sb_peer.data(), __sb_peer.size()));\n" +
164
+ " slot++;\n" +
165
+ " }\n" +
166
+ " *((i32*)(porf_mem + headers_ptr)) = slot;\n\n return headers_ptr;";
167
+ const HDR_APPEND_MARKER = "__sb_peer";
168
+
169
+ const done = new Set<string>();
170
+
171
+ /** Edits to Porffor's uWebSockets shim: `[marker, anchor, inject, what]`. */
172
+ const UWS_EDITS = [
173
+ [BODY_MARKER, BODY_ANCHOR, BODY_INJECT, "request body limit"],
174
+ [STATUS_SIG_MARKER, STATUS_SIG_ANCHOR, STATUS_SIG_INJECT, "status-line return type (#156)"],
175
+ [STATUS_303_MARKER, STATUS_303_ANCHOR, STATUS_303_INJECT, "status-line 303 case (#156)"],
176
+ [STATUS_DEFAULT_MARKER, STATUS_DEFAULT_ANCHOR, STATUS_DEFAULT_INJECT, "status-line fallback (#156)"],
177
+ [HDR_SIG_MARKER, HDR_SIG_ANCHOR, HDR_SIG_INJECT, "collect_headers res arg (#163)"],
178
+ [HDR_CALL_MARKER, HDR_CALL_ANCHOR, HDR_CALL_INJECT, "collect_headers call site (#163)"],
179
+ [HDR_CAP_MARKER, HDR_CAP_ANCHOR, HDR_CAP_INJECT, "remote-addr header capacity (#163)"],
180
+ [HDR_SKIP_MARKER, HDR_SKIP_ANCHOR, HDR_SKIP_INJECT, "drop client-sent x-sb-remote-addr (#163)"],
181
+ [HDR_APPEND_MARKER, HDR_APPEND_ANCHOR, HDR_APPEND_INJECT, "append x-sb-remote-addr (#163)"],
182
+ ] as const;
183
+
184
+ /** The uWebSockets shim source: body limit + the #156 status-line fix. Exported for tests. */
185
+ export async function patchUwebsockets(root: string): Promise<void> {
186
+ const file = resolve(root, "compiler/uwebsockets.js");
187
+ let src = await readFile(file, "utf8");
188
+ let changed = false;
189
+ for (const [marker, anchor, inject, what] of UWS_EDITS) {
190
+ if (src.includes(marker)) continue;
191
+ if (!src.includes(anchor)) {
192
+ throw new Error(
193
+ `could not patch Porffor's ${what}: anchor not found in ${file}. ` +
194
+ "Porffor's uWebSockets shim changed — check patches/UPSTREAM.md.",
195
+ );
196
+ }
197
+ src = src.replace(anchor, inject);
198
+ changed = true;
199
+ }
200
+ if (changed) await writeFile(file, src);
201
+ }
202
+
203
+ async function patchCompilerArgs(root: string): Promise<void> {
204
+ const file = resolve(root, "compiler/index.js");
205
+ let src = await readFile(file, "utf8");
206
+ let changed = false;
207
+ for (const [marker, anchor, inject, what] of [
208
+ [LINK_MARKER, LINK_ANCHOR, LINK_INJECT, "extra link args"],
209
+ [CFLAGS_MARKER, CFLAGS_ANCHOR, CFLAGS_INJECT, "extra compiler flags"],
210
+ ] as const) {
211
+ if (src.includes(marker)) continue;
212
+ const at = src.indexOf(anchor);
213
+ if (at === -1) {
214
+ throw new Error(
215
+ `could not patch Porffor for ${what}: anchor not found in ${file}. ` +
216
+ "Porffor's native-fetch build changed — check patches/UPSTREAM.md.",
217
+ );
218
+ }
219
+ // After the anchor for cflags (the args follow it), before it for the link
220
+ // line (the object list ends with it).
221
+ src =
222
+ marker === CFLAGS_MARKER
223
+ ? src.slice(0, at + anchor.length) + inject + src.slice(at + anchor.length)
224
+ : src.slice(0, at) + inject + src.slice(at);
225
+ changed = true;
226
+ }
227
+ if (changed) await writeFile(file, src);
228
+ }
229
+
230
+ /** The native-fetch server source: $PORT support + the #165 console sink. Exported for tests. */
231
+ export async function patchRenderJs(root: string): Promise<void> {
232
+ const file = resolve(root, "compiler/render.js");
233
+ let src = await readFile(file, "utf8");
234
+ let changed = false;
235
+ for (const [marker, anchor, inject, what] of RENDER_EDITS) {
236
+ if (src.includes(marker)) continue;
237
+ const at = src.indexOf(anchor);
238
+ if (at === -1) {
239
+ throw new Error(
240
+ `could not patch Porffor for ${what}: anchor not found in ${file}. ` +
241
+ "Porffor's native-fetch renderer changed — check patches/UPSTREAM.md.",
242
+ );
243
+ }
244
+ src = src.slice(0, at + anchor.length) + inject + src.slice(at + anchor.length);
245
+ changed = true;
246
+ }
247
+ if (changed) await writeFile(file, src);
248
+ }
249
+
250
+ /**
251
+ * Apply every patch to a Porffor checkout at `root` (the directory that holds
252
+ * `compiler/` and `runtime/`). Idempotent per process and per marker, so it is
253
+ * safe to call on every build. Callers pass an explicit root — `ensurePorffor`
254
+ * right after extraction, the compile path against its resolved checkout.
255
+ */
256
+ export async function ensurePorfforPatched(root: string): Promise<void> {
257
+ if (done.has(root)) return;
258
+ await patchCompilerArgs(root);
259
+ await patchUwebsockets(root);
260
+ await patchRenderJs(root);
261
+ done.add(root);
262
+ }
package/src/pin.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The single pinned Porffor identity for the whole platform — the CLI and the
3
+ * monorepo both read it from here. The `alpha-*` git tags ship no package.json,
4
+ * so there is no npm dep to resolve; `ensurePorffor` fetches the commit tarball
5
+ * and verifies it against `PORFFOR_ARCHIVE_SHA256`.
6
+ *
7
+ * To move the pin: bump all four constants below (the sha is
8
+ * `shasum -a 256` of the archive at `PORFFOR_ARCHIVE_URL`), check whether the
9
+ * new `compiler/uwebsockets.js` changed `UWS_COMMIT` — if so re-vendor the
10
+ * uWebSockets archive in `sproutboat-cli/vendor/` — then run both repos'
11
+ * test + conformance suites. See sproutboat-cli/MIGRATION.md.
12
+ */
13
+ export const PORFFOR_CHANNEL = "alpha-5";
14
+ export const PORFFOR_COMMIT_FULL = "1f4ae4ae3e0a5f0a93b3bc084359e1a3a23391fd";
15
+ export const PORFFOR_COMMIT = PORFFOR_COMMIT_FULL.slice(0, 7);
16
+ export const PORFFOR_ARCHIVE_SHA256 = "a49a0e857574e93cb09c574dcf53b7cc049d3cb65c5b944b169755cad2a51d5d";
17
+ export const PORFFOR_ARCHIVE_URL = `https://codeload.github.com/CanadaHonk/porffor/tar.gz/${PORFFOR_COMMIT_FULL}`;
18
+
19
+ /** A compact identity string for a manifest / report (`alpha-5 (1f4ae4a)`). */
20
+ export function porfforVersion(): string {
21
+ return process.env.PORFFOR_VERSION || `${PORFFOR_CHANNEL} (${PORFFOR_COMMIT})`;
22
+ }