sproutboat 0.2.0 → 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/README.md +10 -3
- package/SURFACE.md +4 -1
- package/package.json +12 -1
- package/src/assets.ts +24 -0
- package/src/broker.ts +19 -14
- package/src/compile.ts +34 -78
- package/src/main.ts +0 -0
- package/src/native-fetch-prelude.js +146 -29
- package/src/surface.ts +3 -0
- package/src/toolchain.ts +68 -0
- package/src/wrap.ts +151 -0
- package/vendor/uwebsockets-360c276d-musl.tar.xz +0 -0
package/README.md
CHANGED
|
@@ -70,9 +70,16 @@ binding, with an Astro UI and a runnable end-to-end harness.
|
|
|
70
70
|
## Requirements
|
|
71
71
|
|
|
72
72
|
- [Bun](https://bun.sh) 1.4+
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
|
|
74
|
+
`build` / `deploy` cross-compile the handler to a static `linux-x86_64` binary
|
|
75
|
+
with Porffor and Zig (Zig is fetched automatically on first use). The package
|
|
76
|
+
ships a prebuilt uWebSockets, so nothing else is compiled from source. No Docker,
|
|
77
|
+
no root. On Windows, build from WSL.
|
|
78
|
+
|
|
79
|
+
If that prebuilt is unusable (a `porffor` pin bump before the archive is
|
|
80
|
+
refreshed), the first build falls back to compiling uWebSockets locally, which
|
|
81
|
+
needs `git` and `make` on `PATH`. `SPROUTBOAT_UWS_TARBALL=<archive>` overrides
|
|
82
|
+
the shipped one.
|
|
76
83
|
|
|
77
84
|
## Limits (v1)
|
|
78
85
|
|
package/SURFACE.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> Generated by `src/surface.test.ts` from `src/surface.ts` + the pinned
|
|
4
4
|
> toolchain constants. Do not edit by hand — run `UPDATE_SURFACE=1 bun test`.
|
|
5
5
|
|
|
6
|
-
**Package:** `sproutboat` 0.
|
|
6
|
+
**Package:** `sproutboat` 0.3.0 · runs on Bun (use `bunx`, not `npx`)
|
|
7
7
|
|
|
8
8
|
## Commands
|
|
9
9
|
|
|
@@ -30,7 +30,10 @@ usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | dep
|
|
|
30
30
|
| `SPROUTBOAT_API_URL` | Control-plane URL. Overrides the saved active endpoint. |
|
|
31
31
|
| `SPROUTBOAT_TOKEN` | API token. Overrides the saved credential for the endpoint. |
|
|
32
32
|
| `SPROUTBOAT_ZIG` | Path to a Zig binary to use instead of downloading the pinned one. |
|
|
33
|
+
| `SPROUTBOAT_UWS_TARBALL` | Path to a prebuilt uWebSockets (x86_64-linux-musl) tarball to seed the Porffor cache with, instead of downloading it (removes the first-build git + make need). |
|
|
33
34
|
| `SPROUTBOAT_COMPILE_TIMEOUT_MS` | Porffor compile timeout in ms (default 600000). |
|
|
35
|
+
| `SPROUTBOAT_VARS_JSON` | JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the worker module. |
|
|
36
|
+
| `SPROUTBOAT_BINDINGS_JSON` | The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line. |
|
|
34
37
|
| `SPROUTBOAT_CONFIG_DIR` | Directory for credentials.json (default ~/.config/sproutboat). |
|
|
35
38
|
| `XDG_CONFIG_HOME` | Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset. |
|
|
36
39
|
| `PORFFOR_VERSION` | Override the Porffor identity string recorded in the manifest. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sproutboat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Wrangler-shaped CLI for Sproutboat. Deploys workers to any control plane via --api-url / SPROUTBOAT_API_URL.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,9 +22,20 @@
|
|
|
22
22
|
"bin": {
|
|
23
23
|
"sproutboat": "src/main.ts"
|
|
24
24
|
},
|
|
25
|
+
"exports": {
|
|
26
|
+
"./runtime/config": "./src/config.ts",
|
|
27
|
+
"./runtime/source": "./src/source.ts",
|
|
28
|
+
"./runtime/manifest": "./src/manifest.ts",
|
|
29
|
+
"./runtime/assets": "./src/assets.ts",
|
|
30
|
+
"./runtime/broker": "./src/broker.ts",
|
|
31
|
+
"./runtime/wrap": "./src/wrap.ts",
|
|
32
|
+
"./runtime/prelude": "./src/native-fetch-prelude.js",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
25
35
|
"files": [
|
|
26
36
|
"src",
|
|
27
37
|
"!src/*.test.ts",
|
|
38
|
+
"vendor",
|
|
28
39
|
"SURFACE.md"
|
|
29
40
|
],
|
|
30
41
|
"engines": {
|
package/src/assets.ts
CHANGED
|
@@ -36,6 +36,30 @@ export function contentType(name: string): string {
|
|
|
36
36
|
return (dot >= 0 ? TYPES.get(name.slice(dot + 1).toLowerCase()) : undefined) ?? "application/octet-stream";
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a request path to a manifest key the way a static host does:
|
|
41
|
+
* - an exact hit wins;
|
|
42
|
+
* - a directory path (`/docs/`) tries `/docs/index.html`;
|
|
43
|
+
* - an extensionless path (`/docs`) tries `/docs.html`, then `/docs/index.html`.
|
|
44
|
+
* Returns the matched key, or `null`. This only picks which file to serve — no
|
|
45
|
+
* canonical redirects, and the caller still owns not-found handling. Mirrors
|
|
46
|
+
* Cloudflare's `html_handling: "auto-trailing-slash"` minus the 3xx responses.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveAssetKey(path: string, has: (key: string) => boolean): string | null {
|
|
49
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
50
|
+
if (path.endsWith("/")) {
|
|
51
|
+
const index = `${path}index.html`;
|
|
52
|
+
return has(index) ? index : null;
|
|
53
|
+
}
|
|
54
|
+
if (has(path)) return path;
|
|
55
|
+
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
56
|
+
if (!base.includes(".")) {
|
|
57
|
+
if (has(`${path}.html`)) return `${path}.html`;
|
|
58
|
+
if (has(`${path}/index.html`)) return `${path}/index.html`;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
39
63
|
/** Walk `dir` recursively, returning `{ "/path": {hash,size,type} }`. */
|
|
40
64
|
export function walkAssets(dir: string) {
|
|
41
65
|
const out: AssetFiles = {};
|
package/src/broker.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { createHash } from "node:crypto";
|
|
|
22
22
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
23
23
|
import { dirname, join, normalize, resolve } from "node:path";
|
|
24
24
|
import { parseArgs } from "node:util";
|
|
25
|
-
import type
|
|
25
|
+
import { resolveAssetKey, type AssetManifest } from "./assets";
|
|
26
26
|
|
|
27
27
|
export type Bindings = {
|
|
28
28
|
kv: string[];
|
|
@@ -109,6 +109,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
109
109
|
|
|
110
110
|
const db = new Database(dbPath);
|
|
111
111
|
db.exec("PRAGMA journal_mode = WAL");
|
|
112
|
+
// WAL + NORMAL is the standard pairing: a write no longer fsyncs the WAL, so
|
|
113
|
+
// host power loss can drop the last few committed txns, but a process crash
|
|
114
|
+
// never can and the file never corrupts. Right trade for a single-VPS
|
|
115
|
+
// KV/queue/DO store; on real block storage this is ~10-100x on writes.
|
|
116
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
112
117
|
db.exec("CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (ns, key))");
|
|
113
118
|
db.exec(
|
|
114
119
|
"CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, body TEXT NOT NULL, size INTEGER NOT NULL, " +
|
|
@@ -158,6 +163,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
158
163
|
if (d1Dir) mkdirSync(d1Dir, { recursive: true });
|
|
159
164
|
conn = new Database(d1Dir ? join(d1Dir, `${name}.sqlite`) : ":memory:", { create: true });
|
|
160
165
|
conn.exec("PRAGMA journal_mode = WAL");
|
|
166
|
+
conn.exec("PRAGMA synchronous = NORMAL");
|
|
161
167
|
d1Conns.set(name, conn);
|
|
162
168
|
}
|
|
163
169
|
return conn;
|
|
@@ -290,9 +296,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
290
296
|
const prefix = str(msg.prefix);
|
|
291
297
|
const cursor = str(msg.cursor);
|
|
292
298
|
const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 1000);
|
|
293
|
-
const rows = db.query<R2Row, [string, string, string]>(
|
|
294
|
-
"SELECT * FROM r2 WHERE bucket = ? AND key LIKE ? || '%' AND key > ? ORDER BY key LIMIT "
|
|
295
|
-
).all(bucket, prefix, cursor);
|
|
299
|
+
const rows = db.query<R2Row, [string, string, string, number]>(
|
|
300
|
+
"SELECT * FROM r2 WHERE bucket = ? AND key LIKE ? || '%' AND key > ? ORDER BY key LIMIT ?",
|
|
301
|
+
).all(bucket, prefix, cursor, limit + 1);
|
|
296
302
|
const truncated = rows.length > limit;
|
|
297
303
|
const page = truncated ? rows.slice(0, limit) : rows;
|
|
298
304
|
return {
|
|
@@ -335,9 +341,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
335
341
|
// query it via the SQL API). Exposed here so a dashboard can read back.
|
|
336
342
|
const ds = requireAe(msg.dataset);
|
|
337
343
|
const limit = Math.min(Math.max(Number(msg.limit) || 20, 1), 200);
|
|
338
|
-
const rows = db.query<{ ts: number; indexes_json: string; blobs_json: string; doubles_json: string }, [string]>(
|
|
339
|
-
"SELECT ts, indexes_json, blobs_json, doubles_json FROM ae WHERE dataset = ? ORDER BY ts DESC, rowid DESC LIMIT "
|
|
340
|
-
).all(ds);
|
|
344
|
+
const rows = db.query<{ ts: number; indexes_json: string; blobs_json: string; doubles_json: string }, [string, number]>(
|
|
345
|
+
"SELECT ts, indexes_json, blobs_json, doubles_json FROM ae WHERE dataset = ? ORDER BY ts DESC, rowid DESC LIMIT ?",
|
|
346
|
+
).all(ds, limit);
|
|
341
347
|
const total = db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM ae WHERE dataset = ?").get(ds);
|
|
342
348
|
return {
|
|
343
349
|
ok: true,
|
|
@@ -376,18 +382,17 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
376
382
|
case "do.storage.list": {
|
|
377
383
|
const cls = requireDoClass(msg.cls);
|
|
378
384
|
const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 10000);
|
|
379
|
-
const rows = db.query<{ key: string; value: string }, [string, string, string]>(
|
|
380
|
-
"SELECT key, value FROM do_storage WHERE cls = ? AND id = ? AND key LIKE ? || '%' ORDER BY key LIMIT "
|
|
381
|
-
).all(cls, str(msg.id), str(msg.prefix));
|
|
385
|
+
const rows = db.query<{ key: string; value: string }, [string, string, string, number]>(
|
|
386
|
+
"SELECT key, value FROM do_storage WHERE cls = ? AND id = ? AND key LIKE ? || '%' ORDER BY key LIMIT ?",
|
|
387
|
+
).all(cls, str(msg.id), str(msg.prefix), limit);
|
|
382
388
|
return { ok: true, entries: rows.map((r) => [r.key, r.value]) };
|
|
383
389
|
}
|
|
384
390
|
|
|
385
391
|
case "assets.get": {
|
|
386
392
|
if (!bindings.assets) throw new Error("assets not bound");
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const hit = readAsset(path);
|
|
393
|
+
const reqPath = str(msg.path) || "/";
|
|
394
|
+
const key = resolveAssetKey(reqPath, (k) => !!assetManifest?.files[k]);
|
|
395
|
+
const hit = key ? readAsset(key) : null;
|
|
391
396
|
if (hit) return { ok: true, found: true, status: 200, type: hit.type, hash: hit.hash, body: hit.body };
|
|
392
397
|
const nfh = assetManifest?.notFound ?? "none";
|
|
393
398
|
if (nfh === "single-page-application") {
|
package/src/compile.ts
CHANGED
|
@@ -4,89 +4,20 @@
|
|
|
4
4
|
* `zig cc -target x86_64-linux-musl` and statically links, so the same command
|
|
5
5
|
* works from macOS, Linux, or WSL with no Docker.
|
|
6
6
|
*
|
|
7
|
-
* One-time per machine: Porffor
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* One-time per machine: the uWebSockets tree Porffor links is unpacked into
|
|
8
|
+
* ~/.cache/porffor/deps/. `ensureUWebSockets()` extracts the prebuilt archive
|
|
9
|
+
* shipped in `vendor/` so this needs no `git` or `make`; if that archive is
|
|
10
|
+
* unusable it falls back to Porffor's own git + make path (needs both on PATH).
|
|
10
11
|
*/
|
|
11
12
|
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
12
13
|
import { dirname, resolve } from "node:path";
|
|
13
14
|
import { ensurePorfforPatched } from "./patch-porffor";
|
|
14
|
-
import { porfforRoot } from "./toolchain";
|
|
15
|
+
import { ensureUWebSockets, porfforRoot, UwsUnavailableError } from "./toolchain";
|
|
16
|
+
import { EMPTY_BINDINGS, preludePath, wrapNativeFetchHandler, type Bindings } from "./wrap";
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
// The server honours $PORT at runtime (patches/porffor-render.patch); this baked
|
|
18
|
-
// value is only a fallback for a directly-run binary.
|
|
19
|
-
const DEFAULT_PORT = 8080;
|
|
20
|
-
const COMPILE_TIMEOUT_MS = Number(process.env.SPROUTBOAT_COMPILE_TIMEOUT_MS || 600_000);
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Binding names a project declares. `do` maps a binding name to a Durable Object
|
|
24
|
-
* class name; `crons` are schedule expressions with no name.
|
|
25
|
-
*/
|
|
26
|
-
export type Bindings = {
|
|
27
|
-
kv: string[];
|
|
28
|
-
secrets: string[];
|
|
29
|
-
outbound: string[];
|
|
30
|
-
d1: string[];
|
|
31
|
-
r2: string[];
|
|
32
|
-
queues: string[];
|
|
33
|
-
analytics: string[];
|
|
34
|
-
do: Array<{ binding: string; className: string }>;
|
|
35
|
-
crons: string[];
|
|
36
|
-
/** Static-asset binding name for `env.<NAME>.fetch(request)`; `""` when assets are edge-only. */
|
|
37
|
-
assets: string;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const EMPTY_BINDINGS: Bindings = { kv: [], secrets: [], outbound: [], d1: [], r2: [], queues: [], analytics: [], do: [], crons: [], assets: "" };
|
|
18
|
+
export { EMPTY_BINDINGS, preludePath, wrapNativeFetchHandler, type Bindings } from "./wrap";
|
|
41
19
|
|
|
42
|
-
|
|
43
|
-
return (
|
|
44
|
-
b.kv.length > 0 || b.secrets.length > 0 || b.outbound.length > 0 || b.d1.length > 0 || b.r2.length > 0 ||
|
|
45
|
-
b.queues.length > 0 || b.analytics.length > 0 || b.do.length > 0 || b.assets !== ""
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Build the final native-fetch module: the prelude (Web API shims + the broker
|
|
51
|
-
* binding shim + the trigger dispatcher), then `const env = {…}` with the baked
|
|
52
|
-
* `vars`, then — if any binding is declared — one `__sbInstallBindings(env, …)`
|
|
53
|
-
* line, then the user's source with its `export` keywords neutralised (so its
|
|
54
|
-
* `export default {…}` becomes a plain object we can hand to the dispatcher),
|
|
55
|
-
* then our single `export default { fetch }` that routes every request through
|
|
56
|
-
* `__sbEntry` (HTTP → `handlers.fetch`; `x-sb-trigger` → scheduled / queue / DO).
|
|
57
|
-
*
|
|
58
|
-
* With no bindings and no `scheduled`/`queue`/DO the output behaves exactly like
|
|
59
|
-
* a plain `export default { fetch }` worker.
|
|
60
|
-
*/
|
|
61
|
-
export function wrapNativeFetchHandler(
|
|
62
|
-
source: string,
|
|
63
|
-
prelude: string,
|
|
64
|
-
vars: Record<string, string> = {},
|
|
65
|
-
bindings: Bindings = EMPTY_BINDINGS,
|
|
66
|
-
): string {
|
|
67
|
-
if (!/\bexport\s+default\s*\{/.test(source) || !/\bfetch\s*\(/.test(source)) {
|
|
68
|
-
throw new Error("handler must default-export an object with a fetch(request) method");
|
|
69
|
-
}
|
|
70
|
-
// Neutralise the module's exports: its default object becomes `__sbHandlers`,
|
|
71
|
-
// and any `export class`/`function`/`const` (Durable Object classes, helpers)
|
|
72
|
-
// becomes a plain top-level declaration. Imports are already rejected upstream.
|
|
73
|
-
const neutralised = source
|
|
74
|
-
.replace(/^(\s*)export\s+default\s*/m, "$1const __sbHandlers = ")
|
|
75
|
-
.replace(/^export\s+(async\s+function|function|class|const|let|var)\b/gm, "$1");
|
|
76
|
-
|
|
77
|
-
const env = `const env = ${JSON.stringify(vars)};\nglobalThis.env = env;\n`;
|
|
78
|
-
const wire = hasBindings(bindings) ? `__sbInstallBindings(env, ${JSON.stringify(bindings)});\n` : "";
|
|
79
|
-
const registerDO = bindings.do.length
|
|
80
|
-
? `__sbRegisterDO({ ${bindings.do.map((d) => `${d.className}: ${d.className}`).join(", ")} });\n`
|
|
81
|
-
: "";
|
|
82
|
-
|
|
83
|
-
return (
|
|
84
|
-
`${prelude}\n${env}${wire}` +
|
|
85
|
-
`${neutralised}\n` +
|
|
86
|
-
`${registerDO}` +
|
|
87
|
-
`export default {\n port: ${DEFAULT_PORT},\n fetch(request) { return __sbEntry(__sbHandlers, request); }\n};\n`
|
|
88
|
-
);
|
|
89
|
-
}
|
|
20
|
+
const COMPILE_TIMEOUT_MS = Number(process.env.SPROUTBOAT_COMPILE_TIMEOUT_MS || 600_000);
|
|
90
21
|
|
|
91
22
|
export type CompileInput = {
|
|
92
23
|
sourcePath: string;
|
|
@@ -99,6 +30,28 @@ export type CompileInput = {
|
|
|
99
30
|
/** Compile `sourcePath` to a native binary at `outPath` (mode 0555). */
|
|
100
31
|
export async function compileWorker(input: CompileInput): Promise<void> {
|
|
101
32
|
await ensurePorfforPatched();
|
|
33
|
+
|
|
34
|
+
// Seed the Porffor uWebSockets cache from the prebuilt archive in `vendor/` so
|
|
35
|
+
// the first build needs no `git` / `make`. Fall back to Porffor's own git+make
|
|
36
|
+
// path if the archive is missing or fails its checksum.
|
|
37
|
+
try {
|
|
38
|
+
await ensureUWebSockets();
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (!(error instanceof UwsUnavailableError)) throw error;
|
|
41
|
+
const haveGit = Bun.which("git");
|
|
42
|
+
const haveMake = Bun.which("make");
|
|
43
|
+
if (haveGit && haveMake) {
|
|
44
|
+
console.warn(`prebuilt uWebSockets unusable (${error.message.split("\n")[0]}); falling back to git + make (slower, one-time)`);
|
|
45
|
+
} else {
|
|
46
|
+
const missing = [!haveGit && "git", !haveMake && "make"].filter(Boolean).join(" and ");
|
|
47
|
+
throw new Error(
|
|
48
|
+
`${error.message}\n\nThe prebuilt uWebSockets is unusable, and ${missing} ` +
|
|
49
|
+
`${missing.includes("and") ? "are" : "is"} not on PATH for the fallback build. ` +
|
|
50
|
+
`Install ${missing}, or set SPROUTBOAT_UWS_TARBALL to a valid archive.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
102
55
|
const outDir = dirname(input.outPath);
|
|
103
56
|
await mkdir(outDir, { recursive: true });
|
|
104
57
|
const generatedPath = resolve(outDir, "worker.generated.js");
|
|
@@ -111,8 +64,11 @@ export async function compileWorker(input: CompileInput): Promise<void> {
|
|
|
111
64
|
const binDir = resolve(porffor, "../.bin");
|
|
112
65
|
const path = `${dirname(input.zigBin)}:${binDir}:${process.env.PATH ?? ""}`;
|
|
113
66
|
|
|
67
|
+
// `-s`: strip at link. The unstripped static-musl binary is ~90% DWARF that
|
|
68
|
+
// nothing needs at runtime (12 MB -> ~1.3 MB for the kitchen-sink). Porffor
|
|
69
|
+
// forwards `-s` straight to the `zig cc` link step.
|
|
114
70
|
const child = Bun.spawn(
|
|
115
|
-
[process.execPath, launcher, "native", generatedPath, "-o", input.outPath, "--musl"],
|
|
71
|
+
[process.execPath, launcher, "native", generatedPath, "-o", input.outPath, "--musl", "-s"],
|
|
116
72
|
{ cwd: outDir, stdout: "pipe", stderr: "pipe", env: { ...process.env, PATH: path } },
|
|
117
73
|
);
|
|
118
74
|
let timedOut = false;
|
package/src/main.ts
CHANGED
|
File without changes
|
|
@@ -11,6 +11,26 @@
|
|
|
11
11
|
// Declared before it is referenced: a getter body that names a later top-level
|
|
12
12
|
// class throws ReferenceError in Porffor (see patches/UPSTREAM.md draft B).
|
|
13
13
|
|
|
14
|
+
// #41 — cold-start phase marker. Runs as the first thing in the bundle: writes
|
|
15
|
+
// the current wall-clock ms to $SB_STARTUP_FILE so the supervisor can split
|
|
16
|
+
// cold-start into "spawn -> JS starts" (process + runtime bootstrap) and
|
|
17
|
+
// "JS starts -> listening" (module eval + server bind). No-op when unset.
|
|
18
|
+
function __sbStartupMark() {
|
|
19
|
+
Porffor.c`
|
|
20
|
+
const char* __f = getenv("SB_STARTUP_FILE");
|
|
21
|
+
if (__f) {
|
|
22
|
+
struct timespec __ts;
|
|
23
|
+
clock_gettime(CLOCK_REALTIME, &__ts);
|
|
24
|
+
double __ms = (double)__ts.tv_sec * 1000.0 + (double)__ts.tv_nsec / 1000000.0;
|
|
25
|
+
char __buf[32];
|
|
26
|
+
int __n = snprintf(__buf, sizeof(__buf), "%.0f", __ms);
|
|
27
|
+
int __fd = open(__f, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
|
28
|
+
if (__fd >= 0) { write(__fd, __buf, (size_t)__n); close(__fd); }
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
32
|
+
__sbStartupMark();
|
|
33
|
+
|
|
14
34
|
class __SproutboatURLSearchParams {
|
|
15
35
|
constructor(init) {
|
|
16
36
|
this._keys = [];
|
|
@@ -118,13 +138,21 @@ __sbDefineURLAccessor('username', function () { return ''; });
|
|
|
118
138
|
__sbDefineURLAccessor('password', function () { return ''; });
|
|
119
139
|
|
|
120
140
|
// crypto.randomUUID / getRandomValues are absent in native-fetch. Provide them
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
141
|
+
// backed by the OS CSPRNG (`__sbRandomBytes` -> inline C -> /dev/urandom), so
|
|
142
|
+
// tokens, idempotency keys and UUIDs are unpredictable. Deliberately no insecure
|
|
143
|
+
// fallback — a silent downgrade to a weak source is worse than throwing.
|
|
124
144
|
if (globalThis.crypto == null) globalThis.crypto = {};
|
|
125
145
|
if (globalThis.crypto.getRandomValues == null) {
|
|
126
146
|
globalThis.crypto.getRandomValues = function (view) {
|
|
127
|
-
|
|
147
|
+
const n = view.length >>> 0;
|
|
148
|
+
// WebCrypto caps a single call at 65536 bytes.
|
|
149
|
+
if (n > 65536) throw new RangeError("crypto.getRandomValues: byte length exceeds 65536");
|
|
150
|
+
if (n === 0) return view;
|
|
151
|
+
// One CSPRNG byte per element. Correct for Uint8Array (and randomUUID); a
|
|
152
|
+
// wider view gets its low byte filled, matching the previous polyfill's shape.
|
|
153
|
+
const bytes = __sbRandomBytes(String(n));
|
|
154
|
+
if (bytes.length !== n) throw new Error("crypto.getRandomValues: OS entropy source unavailable");
|
|
155
|
+
for (let i = 0; i < n; i++) view[i] = bytes.charCodeAt(i) & 0xff;
|
|
128
156
|
return view;
|
|
129
157
|
};
|
|
130
158
|
}
|
|
@@ -151,62 +179,103 @@ if (globalThis.crypto.randomUUID == null) {
|
|
|
151
179
|
|
|
152
180
|
// ---------------------------------------------------------------------------
|
|
153
181
|
// Bindings: env.<KV>, env.<SECRET>, env.<D1>, env.<R2>, and globalThis.fetch,
|
|
154
|
-
// backed by a Bun broker on a loopback TCP port. The transport is inline C —
|
|
155
|
-
//
|
|
156
|
-
// per request, so a blocking roundtrip is acceptable).
|
|
182
|
+
// backed by a Bun broker on a loopback TCP port. The transport is inline C —
|
|
183
|
+
// blocking write/read per call over ONE long-lived connection (http-sync-v0: one
|
|
184
|
+
// worker event-loop turn per request, so a blocking roundtrip is acceptable).
|
|
185
|
+
// Wire frame:
|
|
157
186
|
// [u32 LE len][ <token> "\n" <json> ] reply: [u32 LE len][ <json> ]
|
|
158
187
|
// SB_BROKER_PORT / SB_BROKER_TOKEN are set by the supervisor next to $PORT.
|
|
159
188
|
// If SB_BROKER_PORT is unset the shims below are never installed (compile.ts
|
|
160
189
|
// only emits the __sbInstallBindings call when the project declares bindings),
|
|
161
190
|
// so a plain worker is byte-for-byte unchanged.
|
|
162
|
-
// ponytail:
|
|
163
|
-
//
|
|
191
|
+
// ponytail: text values only; still AF_INET loopback, not AF_UNIX. A failed
|
|
192
|
+
// exchange reconnects and resends once — a broker crash between "request applied"
|
|
193
|
+
// and "reply read" can double-apply a non-idempotent op (queue.send, INSERT);
|
|
194
|
+
// the old fresh-connection-per-call path just failed the call there instead.
|
|
195
|
+
// Binary values + AF_UNIX = v2.
|
|
164
196
|
|
|
165
197
|
Porffor.c`
|
|
166
198
|
#include <sys/socket.h>
|
|
167
199
|
#include <netinet/in.h>
|
|
200
|
+
#include <netinet/tcp.h>
|
|
201
|
+
#include <signal.h>
|
|
168
202
|
#include <unistd.h>
|
|
169
203
|
#include <string.h>
|
|
170
204
|
#include <stdlib.h>
|
|
171
205
|
#include <stdio.h>
|
|
206
|
+
#include <fcntl.h>
|
|
207
|
+
#include <errno.h>
|
|
208
|
+
#include <time.h>
|
|
172
209
|
|
|
173
210
|
u32 porf_native_fetch_alloc_bytestring(const char* input, size_t len);
|
|
174
211
|
int porf_native_fetch_read_value(jsval value, const char** out_buf, size_t* out_len, char** out_owned);
|
|
175
212
|
|
|
213
|
+
// Fill buf with n bytes from the OS CSPRNG. /dev/urandom is present on Linux and
|
|
214
|
+
// macOS and inside the bubblewrap sandbox; blocking is not a concern after the
|
|
215
|
+
// pool is seeded. Returns 0, or -1 if the source could not be read in full.
|
|
216
|
+
static int sb_os_random(unsigned char* buf, size_t n) {
|
|
217
|
+
int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC);
|
|
218
|
+
if (fd < 0) return -1;
|
|
219
|
+
size_t off = 0;
|
|
220
|
+
while (off < n) {
|
|
221
|
+
long r = read(fd, buf + off, n - off);
|
|
222
|
+
if (r <= 0) {
|
|
223
|
+
if (r < 0 && errno == EINTR) continue;
|
|
224
|
+
close(fd);
|
|
225
|
+
return -1;
|
|
226
|
+
}
|
|
227
|
+
off += (size_t)r;
|
|
228
|
+
}
|
|
229
|
+
close(fd);
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
176
233
|
static int sb_io_all(int fd, unsigned char* buf, size_t len, int writing) {
|
|
177
234
|
size_t done = 0;
|
|
178
235
|
while (done < len) {
|
|
179
236
|
long n = writing ? write(fd, buf + done, len - done) : read(fd, buf + done, len - done);
|
|
180
|
-
if (n <= 0)
|
|
237
|
+
if (n <= 0) {
|
|
238
|
+
if (n < 0 && errno == EINTR) continue;
|
|
239
|
+
return -1;
|
|
240
|
+
}
|
|
181
241
|
done += (size_t)n;
|
|
182
242
|
}
|
|
183
243
|
return 0;
|
|
184
244
|
}
|
|
185
245
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
246
|
+
// One long-lived loopback connection to the broker, reused across every binding
|
|
247
|
+
// call. The broker frames each request/reply independently and keeps the socket
|
|
248
|
+
// open, so the steady-state per-call cost is just write + read — no socket(),
|
|
249
|
+
// connect() handshake or close() each time. -1 = not connected.
|
|
250
|
+
static int sb_broker_fd = -1;
|
|
189
251
|
|
|
252
|
+
static int sb_broker_connect(void) {
|
|
190
253
|
const char* port_s = getenv("SB_BROKER_PORT");
|
|
191
254
|
if (!port_s) return -10;
|
|
192
|
-
|
|
193
|
-
const char* tok = getenv("SB_BROKER_TOKEN");
|
|
194
|
-
size_t tok_len = tok ? strlen(tok) : 0;
|
|
195
|
-
|
|
255
|
+
signal(SIGPIPE, SIG_IGN); // a dead broker must yield EPIPE, not kill the worker
|
|
196
256
|
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
|
197
257
|
if (fd < 0) return -1;
|
|
198
|
-
|
|
258
|
+
int one = 1;
|
|
259
|
+
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
|
|
199
260
|
struct sockaddr_in addr;
|
|
200
261
|
memset(&addr, 0, sizeof(addr));
|
|
201
262
|
addr.sin_family = AF_INET;
|
|
202
|
-
addr.sin_port = htons((unsigned short)
|
|
263
|
+
addr.sin_port = htons((unsigned short)atoi(port_s));
|
|
203
264
|
addr.sin_addr.s_addr = htonl(0x7f000001u); // 127.0.0.1
|
|
204
265
|
if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) { close(fd); return -2; }
|
|
266
|
+
sb_broker_fd = fd;
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Send one framed request, read one framed reply, on the persistent fd.
|
|
271
|
+
static int sb_broker_exchange(const char* req, size_t req_len, char** resp_out, size_t* resp_len_out) {
|
|
272
|
+
const char* tok = getenv("SB_BROKER_TOKEN");
|
|
273
|
+
size_t tok_len = tok ? strlen(tok) : 0;
|
|
205
274
|
|
|
206
275
|
// frame body: token "\n" json
|
|
207
276
|
size_t body_len = tok_len + 1 + req_len;
|
|
208
277
|
unsigned char* frame = (unsigned char*)malloc(4 + body_len);
|
|
209
|
-
if (!frame)
|
|
278
|
+
if (!frame) return -5;
|
|
210
279
|
frame[0] = (unsigned char)(body_len & 0xff);
|
|
211
280
|
frame[1] = (unsigned char)((body_len >> 8) & 0xff);
|
|
212
281
|
frame[2] = (unsigned char)((body_len >> 16) & 0xff);
|
|
@@ -214,23 +283,40 @@ static int sb_broker_roundtrip(const char* req, size_t req_len, char** resp_out,
|
|
|
214
283
|
if (tok_len) memcpy(frame + 4, tok, tok_len);
|
|
215
284
|
frame[4 + tok_len] = '\n';
|
|
216
285
|
if (req_len) memcpy(frame + 4 + tok_len + 1, req, req_len);
|
|
217
|
-
int wr = sb_io_all(
|
|
286
|
+
int wr = sb_io_all(sb_broker_fd, frame, 4 + body_len, 1);
|
|
218
287
|
free(frame);
|
|
219
|
-
if (wr != 0)
|
|
288
|
+
if (wr != 0) return -3;
|
|
220
289
|
|
|
221
290
|
unsigned char rhdr[4];
|
|
222
|
-
if (sb_io_all(
|
|
291
|
+
if (sb_io_all(sb_broker_fd, rhdr, 4, 0) != 0) return -4;
|
|
223
292
|
size_t rlen = (size_t)rhdr[0] | ((size_t)rhdr[1] << 8) | ((size_t)rhdr[2] << 16) | ((size_t)rhdr[3] << 24);
|
|
224
293
|
|
|
225
294
|
char* buf = (char*)malloc(rlen ? rlen : 1);
|
|
226
|
-
if (!buf)
|
|
227
|
-
if (rlen && sb_io_all(
|
|
228
|
-
close(fd);
|
|
295
|
+
if (!buf) return -5;
|
|
296
|
+
if (rlen && sb_io_all(sb_broker_fd, (unsigned char*)buf, rlen, 0) != 0) { free(buf); return -6; }
|
|
229
297
|
|
|
230
298
|
*resp_out = buf;
|
|
231
299
|
*resp_len_out = rlen;
|
|
232
300
|
return 0;
|
|
233
301
|
}
|
|
302
|
+
|
|
303
|
+
static int sb_broker_roundtrip(const char* req, size_t req_len, char** resp_out, size_t* resp_len_out) {
|
|
304
|
+
*resp_out = NULL;
|
|
305
|
+
*resp_len_out = 0;
|
|
306
|
+
// Two tries: a broker restart (or an idle-closed socket) invalidates the fd,
|
|
307
|
+
// so a failed exchange drops the connection and reconnects once before failing.
|
|
308
|
+
for (int attempt = 0; attempt < 2; attempt++) {
|
|
309
|
+
if (sb_broker_fd < 0) {
|
|
310
|
+
int rc = sb_broker_connect();
|
|
311
|
+
if (rc != 0) return rc;
|
|
312
|
+
}
|
|
313
|
+
int rc = sb_broker_exchange(req, req_len, resp_out, resp_len_out);
|
|
314
|
+
if (rc == 0) return 0;
|
|
315
|
+
close(sb_broker_fd);
|
|
316
|
+
sb_broker_fd = -1;
|
|
317
|
+
}
|
|
318
|
+
return -3;
|
|
319
|
+
}
|
|
234
320
|
`;
|
|
235
321
|
|
|
236
322
|
// One request string in, one reply string out. `reqJson` is a parameter, so the
|
|
@@ -255,6 +341,30 @@ function __sbCall(reqJson) {
|
|
|
255
341
|
return res;
|
|
256
342
|
}
|
|
257
343
|
|
|
344
|
+
// `nStr` is the decimal byte count as a string (same string-param pattern as
|
|
345
|
+
// __sbEnv). Returns a bytestring of that many CSPRNG bytes, or '' on failure.
|
|
346
|
+
function __sbRandomBytes(nStr) {
|
|
347
|
+
let out = '';
|
|
348
|
+
Porffor.c`
|
|
349
|
+
const char* __ns; size_t __nsl; char* __nso = 0;
|
|
350
|
+
porf_native_fetch_read_value(nStr, &__ns, &__nsl, &__nso);
|
|
351
|
+
char __nb[16];
|
|
352
|
+
size_t __k = __nsl < 15 ? __nsl : 15;
|
|
353
|
+
memcpy(__nb, __ns, __k); __nb[__k] = 0;
|
|
354
|
+
if (__nso) free(__nso);
|
|
355
|
+
long __n = atol(__nb);
|
|
356
|
+
if (__n > 0 && __n <= 65536) {
|
|
357
|
+
unsigned char* __b = (unsigned char*)malloc((size_t)__n);
|
|
358
|
+
if (__b) {
|
|
359
|
+
if (sb_os_random(__b, (size_t)__n) == 0)
|
|
360
|
+
out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__b, (size_t)__n), 195);
|
|
361
|
+
free(__b);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
`;
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
|
|
258
368
|
function __sbRpc(op, extra) {
|
|
259
369
|
const req = { op };
|
|
260
370
|
if (extra) for (const k in extra) req[k] = extra[k];
|
|
@@ -359,9 +469,17 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
359
469
|
|
|
360
470
|
for (let i = 0; i < (bindings.secrets || []).length; i++) {
|
|
361
471
|
const name = bindings.secrets[i];
|
|
472
|
+
// Fetch lazily, then freeze as a data property: a secret is process-lifetime
|
|
473
|
+
// immutable (a new value means a redeploy = a new process), so one broker
|
|
474
|
+
// round-trip on first read, zero after. A getter that RPCs on every access
|
|
475
|
+
// turns `'Bearer ' + env.KEY` in a loop into a syscall storm.
|
|
362
476
|
Object.defineProperty(target, name, {
|
|
363
477
|
configurable: true,
|
|
364
|
-
get() {
|
|
478
|
+
get() {
|
|
479
|
+
const value = __sbRpc('secret.get', { name }).value;
|
|
480
|
+
Object.defineProperty(target, name, { value, configurable: true, enumerable: true });
|
|
481
|
+
return value;
|
|
482
|
+
},
|
|
365
483
|
});
|
|
366
484
|
}
|
|
367
485
|
|
|
@@ -509,8 +627,7 @@ function __sbMakeDONamespace(binding, className) {
|
|
|
509
627
|
idFromName(name) { return { toString() { return 'name:' + String(name); }, name: String(name) }; },
|
|
510
628
|
idFromString(hex) { return { toString() { return String(hex); } }; },
|
|
511
629
|
newUniqueId() {
|
|
512
|
-
|
|
513
|
-
return { toString() { return 'uid:' + id; } };
|
|
630
|
+
return { toString() { return 'uid:' + crypto.randomUUID(); } };
|
|
514
631
|
},
|
|
515
632
|
get(id) {
|
|
516
633
|
const idStr = typeof id === 'string' ? id : id.toString();
|
package/src/surface.ts
CHANGED
|
@@ -25,7 +25,10 @@ export const ENV_VARS: readonly EnvVar[] = [
|
|
|
25
25
|
{ name: "SPROUTBOAT_API_URL", purpose: "Control-plane URL. Overrides the saved active endpoint." },
|
|
26
26
|
{ name: "SPROUTBOAT_TOKEN", purpose: "API token. Overrides the saved credential for the endpoint." },
|
|
27
27
|
{ name: "SPROUTBOAT_ZIG", purpose: "Path to a Zig binary to use instead of downloading the pinned one." },
|
|
28
|
+
{ name: "SPROUTBOAT_UWS_TARBALL", purpose: "Path to a prebuilt uWebSockets (x86_64-linux-musl) tarball to seed the Porffor cache with, instead of downloading it (removes the first-build git + make need)." },
|
|
28
29
|
{ name: "SPROUTBOAT_COMPILE_TIMEOUT_MS", purpose: "Porffor compile timeout in ms (default 600000)." },
|
|
30
|
+
{ name: "SPROUTBOAT_VARS_JSON", purpose: "JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the worker module." },
|
|
31
|
+
{ name: "SPROUTBOAT_BINDINGS_JSON", purpose: "The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line." },
|
|
29
32
|
{ name: "SPROUTBOAT_CONFIG_DIR", purpose: "Directory for credentials.json (default ~/.config/sproutboat)." },
|
|
30
33
|
{ name: "XDG_CONFIG_HOME", purpose: "Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset." },
|
|
31
34
|
{ name: "PORFFOR_VERSION", purpose: "Override the Porffor identity string recorded in the manifest." },
|
package/src/toolchain.ts
CHANGED
|
@@ -30,6 +30,14 @@ const PORFFOR_COMMIT = "a415d19";
|
|
|
30
30
|
// uWebSockets commit Porffor alpha-4 fetches for the native-fetch server. Read
|
|
31
31
|
// from node_modules/porffor at build time; this is the fallback for the stamp.
|
|
32
32
|
const UWS_COMMIT = "360c276d";
|
|
33
|
+
const UWS_COMMIT_FULL = "360c276d609d59af56ae6932adb95154ace9f15f";
|
|
34
|
+
|
|
35
|
+
// `vendor/uwebsockets-<UWS_COMMIT>-musl.tar.xz` ships in the package: the
|
|
36
|
+
// checked-out, patched, `zig cc -target x86_64-linux-musl`-built uWebSockets
|
|
37
|
+
// tree (headers + `uSockets/uSockets.a`). Regenerate + re-pin the sha whenever
|
|
38
|
+
// the `porffor` pin (and thus UWS_COMMIT_FULL) changes — `bun tools/prebuild-uws.ts`
|
|
39
|
+
// or the `uws-prebuild` workflow.
|
|
40
|
+
const UWS_TARBALL_SHA256 = "e83736f3f8cf9d56a1ebe6ea61625a7af12386763374d47c14cff472ada7484a";
|
|
33
41
|
|
|
34
42
|
function platformKey(): string {
|
|
35
43
|
const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : null;
|
|
@@ -82,6 +90,66 @@ export async function ensureZig(): Promise<string> {
|
|
|
82
90
|
return bin;
|
|
83
91
|
}
|
|
84
92
|
|
|
93
|
+
function uwsCommitFull(): string {
|
|
94
|
+
try {
|
|
95
|
+
const src = readFileSync(resolve(porfforRoot(), "compiler/uwebsockets.js"), "utf8");
|
|
96
|
+
return /UWS_COMMIT\s*=\s*['"]([0-9a-f]{40})/.exec(src)?.[1] ?? UWS_COMMIT_FULL;
|
|
97
|
+
} catch {
|
|
98
|
+
return UWS_COMMIT_FULL;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Thrown when the prebuilt uWebSockets archive is missing or fails its checksum. */
|
|
103
|
+
export class UwsUnavailableError extends Error {}
|
|
104
|
+
|
|
105
|
+
/** Path to the vendored prebuilt archive for the given short commit. */
|
|
106
|
+
export function uwsVendorArchive(short: string): string {
|
|
107
|
+
return resolve(import.meta.dir, "..", "vendor", `uwebsockets-${short}-musl.tar.xz`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Seed `~/.cache/porffor/deps/uWebSockets-<commit>-musl/` with the checked-out,
|
|
112
|
+
* patched, `x86_64-linux-musl`-built uWebSockets tree so Porffor's own
|
|
113
|
+
* `ensureUWebSockets` / `ensureUSocketsBuilt` short-circuit — the first build
|
|
114
|
+
* then needs no `git` and no `make`, only Zig.
|
|
115
|
+
*
|
|
116
|
+
* The archive ships in the package (`vendor/`). No-ops if the cache is already
|
|
117
|
+
* populated. Throws `UwsUnavailableError` if the archive is missing or fails its
|
|
118
|
+
* checksum; the caller decides whether to fall back to Porffor's git + make path.
|
|
119
|
+
*
|
|
120
|
+
* `SPROUTBOAT_UWS_TARBALL=/path/to/archive.tar.xz` overrides the vendored one.
|
|
121
|
+
*/
|
|
122
|
+
export async function ensureUWebSockets(): Promise<void> {
|
|
123
|
+
const commit = uwsCommitFull();
|
|
124
|
+
const short = commit.slice(0, 8);
|
|
125
|
+
const depsRoot = resolve(homedir(), ".cache/porffor/deps");
|
|
126
|
+
const dir = resolve(depsRoot, `uWebSockets-${commit}-musl`);
|
|
127
|
+
if (existsSync(resolve(dir, "src/App.h")) && existsSync(resolve(dir, "uSockets/uSockets.a"))) return;
|
|
128
|
+
|
|
129
|
+
const archive = process.env.SPROUTBOAT_UWS_TARBALL || uwsVendorArchive(short);
|
|
130
|
+
if (!existsSync(archive)) {
|
|
131
|
+
throw new UwsUnavailableError(
|
|
132
|
+
process.env.SPROUTBOAT_UWS_TARBALL
|
|
133
|
+
? `SPROUTBOAT_UWS_TARBALL=${archive} does not exist`
|
|
134
|
+
: `no vendored uWebSockets archive at ${archive} (porffor pin moved? run \`bun tools/prebuild-uws.ts\`)`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (!process.env.SPROUTBOAT_UWS_TARBALL) {
|
|
138
|
+
const actual = await sha256File(archive);
|
|
139
|
+
if (actual !== UWS_TARBALL_SHA256) {
|
|
140
|
+
throw new UwsUnavailableError(`vendored uWebSockets sha256 mismatch\n expected ${UWS_TARBALL_SHA256}\n got ${actual}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await mkdir(dir, { recursive: true });
|
|
145
|
+
const untar = Bun.spawn(["tar", "-xJf", archive, "-C", dir, "--strip-components=1"], { stdout: "pipe", stderr: "pipe" });
|
|
146
|
+
const [code, err] = await Promise.all([untar.exited, new Response(untar.stderr).text()]);
|
|
147
|
+
if (code !== 0) {
|
|
148
|
+
await rm(dir, { recursive: true, force: true });
|
|
149
|
+
throw new UwsUnavailableError(`could not extract vendored uWebSockets: ${err.trim()}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
85
153
|
/** Directory holding node_modules/porffor (walks up from this file). */
|
|
86
154
|
export function porfforRoot(start = import.meta.dir): string {
|
|
87
155
|
let dir = start;
|
package/src/wrap.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The build-independent half of worker compilation: the binding/trigger wrapper
|
|
3
|
+
* that turns a user's `export default { fetch }` into a native-fetch module, plus
|
|
4
|
+
* the `Bindings` shape and the `SPROUTBOAT_*_JSON` env readers.
|
|
5
|
+
*
|
|
6
|
+
* This module has no imports on purpose — the monorepo consumes it via the
|
|
7
|
+
* `sproutboat/runtime/wrap` export to drive its own (host-native, non-musl)
|
|
8
|
+
* compile path without pulling in `toolchain.ts` / `patch-porffor.ts`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The prelude file (Web API shims + broker binding shim + trigger dispatcher).
|
|
12
|
+
* It is read as text and string-prepended before Porffor sees it, never
|
|
13
|
+
* imported — callers do `readFile(preludePath, "utf8")`. */
|
|
14
|
+
export const preludePath = new URL("./native-fetch-prelude.js", import.meta.url);
|
|
15
|
+
|
|
16
|
+
// The server honours $PORT at runtime (patches/porffor-render.patch); this baked
|
|
17
|
+
// value is only a fallback for a directly-run binary.
|
|
18
|
+
const DEFAULT_PORT = 8080;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Binding names a project declares. `do` maps a binding name to a Durable Object
|
|
22
|
+
* class name; `crons` are schedule expressions with no name.
|
|
23
|
+
*/
|
|
24
|
+
export type Bindings = {
|
|
25
|
+
kv: string[];
|
|
26
|
+
secrets: string[];
|
|
27
|
+
outbound: string[];
|
|
28
|
+
d1: string[];
|
|
29
|
+
r2: string[];
|
|
30
|
+
queues: string[];
|
|
31
|
+
analytics: string[];
|
|
32
|
+
do: Array<{ binding: string; className: string }>;
|
|
33
|
+
crons: string[];
|
|
34
|
+
/** Static-asset binding name for `env.<NAME>.fetch(request)`; `""` when assets are edge-only. */
|
|
35
|
+
assets: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const EMPTY_BINDINGS: Bindings = { kv: [], secrets: [], outbound: [], d1: [], r2: [], queues: [], analytics: [], do: [], crons: [], assets: "" };
|
|
39
|
+
|
|
40
|
+
function hasBindings(b: Bindings): boolean {
|
|
41
|
+
return (
|
|
42
|
+
b.kv.length > 0 || b.secrets.length > 0 || b.outbound.length > 0 || b.d1.length > 0 || b.r2.length > 0 ||
|
|
43
|
+
b.queues.length > 0 || b.analytics.length > 0 || b.do.length > 0 || b.assets !== ""
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the final native-fetch module: the prelude (Web API shims + the broker
|
|
49
|
+
* binding shim + the trigger dispatcher), then `const env = {…}` with the baked
|
|
50
|
+
* `vars`, then — if any binding is declared — one `__sbInstallBindings(env, …)`
|
|
51
|
+
* line, then the user's source with its `export` keywords neutralised (so its
|
|
52
|
+
* `export default {…}` becomes a plain object we can hand to the dispatcher),
|
|
53
|
+
* then our single `export default { fetch }` that routes every request through
|
|
54
|
+
* `__sbEntry` (HTTP → `handlers.fetch`; `x-sb-trigger` → scheduled / queue / DO).
|
|
55
|
+
*
|
|
56
|
+
* With no bindings and no `scheduled`/`queue`/DO the output behaves exactly like
|
|
57
|
+
* a plain `export default { fetch }` worker.
|
|
58
|
+
*
|
|
59
|
+
* `port` is only the baked fallback in `export default { port }`; the runtime
|
|
60
|
+
* reads `$PORT` first. The monorepo's bench path overrides it.
|
|
61
|
+
*
|
|
62
|
+
* ponytail: the worker process is long-lived, so a handler that mutates `env`
|
|
63
|
+
* leaks that change to later requests. Freeze upstream once Porffor supports
|
|
64
|
+
* Object.freeze in native mode.
|
|
65
|
+
*/
|
|
66
|
+
export function wrapNativeFetchHandler(
|
|
67
|
+
source: string,
|
|
68
|
+
prelude: string,
|
|
69
|
+
vars: Record<string, string> = {},
|
|
70
|
+
bindings: Bindings = EMPTY_BINDINGS,
|
|
71
|
+
port: number = DEFAULT_PORT,
|
|
72
|
+
): string {
|
|
73
|
+
if (!/\bexport\s+default\s*\{/.test(source) || !/\bfetch\s*\(/.test(source)) {
|
|
74
|
+
throw new Error("handler must default-export an object with a fetch(request) method");
|
|
75
|
+
}
|
|
76
|
+
// Neutralise the module's exports: its default object becomes `__sbHandlers`,
|
|
77
|
+
// and any `export class`/`function`/`const` (Durable Object classes, helpers)
|
|
78
|
+
// becomes a plain top-level declaration. Imports are already rejected upstream.
|
|
79
|
+
const neutralised = source
|
|
80
|
+
.replace(/^(\s*)export\s+default\s*/m, "$1const __sbHandlers = ")
|
|
81
|
+
.replace(/^export\s+(async\s+function|function|class|const|let|var)\b/gm, "$1");
|
|
82
|
+
|
|
83
|
+
const env = `const env = ${JSON.stringify(vars)};\nglobalThis.env = env;\n`;
|
|
84
|
+
const wire = hasBindings(bindings) ? `__sbInstallBindings(env, ${JSON.stringify(bindings)});\n` : "";
|
|
85
|
+
const registerDO = bindings.do.length
|
|
86
|
+
? `__sbRegisterDO({ ${bindings.do.map((d) => `${d.className}: ${d.className}`).join(", ")} });\n`
|
|
87
|
+
: "";
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
`${prelude}\n${env}${wire}` +
|
|
91
|
+
`${neutralised}\n` +
|
|
92
|
+
`${registerDO}` +
|
|
93
|
+
`export default {\n port: ${port},\n fetch(request) { return __sbEntry(__sbHandlers, request); }\n};\n`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type VarsJson = string | number | boolean | null | { readonly [key: string]: VarsJson } | VarsJson[];
|
|
98
|
+
function isVarsObject(value: VarsJson): value is { readonly [key: string]: VarsJson } {
|
|
99
|
+
return value !== null && Object(value) === value && !Array.isArray(value);
|
|
100
|
+
}
|
|
101
|
+
function isVarsString(value: VarsJson): value is string {
|
|
102
|
+
return Object(value) !== value && value === String(value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `SPROUTBOAT_VARS_JSON` (set by the build) → a validated flat string map. */
|
|
106
|
+
export function readVarsFromEnv(): Record<string, string> {
|
|
107
|
+
const raw = process.env.SPROUTBOAT_VARS_JSON;
|
|
108
|
+
const vars: Record<string, string> = {};
|
|
109
|
+
if (!raw) return vars;
|
|
110
|
+
const parsed: VarsJson = JSON.parse(raw);
|
|
111
|
+
if (!isVarsObject(parsed)) throw new Error("SPROUTBOAT_VARS_JSON must be a JSON object");
|
|
112
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
113
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(key) || !isVarsString(value)) throw new Error(`SPROUTBOAT_VARS_JSON.${key} must map an UPPER_SNAKE name to a string`);
|
|
114
|
+
vars[key] = value;
|
|
115
|
+
}
|
|
116
|
+
return vars;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `SPROUTBOAT_BINDINGS_JSON` (the artifact's `bindings.json`, passed by the
|
|
121
|
+
* build) → a `Bindings` shape. Every field is re-validated here; unknown keys
|
|
122
|
+
* are dropped and a missing / empty payload is `EMPTY_BINDINGS`, so an old build
|
|
123
|
+
* with no bindings still compiles.
|
|
124
|
+
*/
|
|
125
|
+
export function readBindingsFromEnv(): Bindings {
|
|
126
|
+
const raw = process.env.SPROUTBOAT_BINDINGS_JSON;
|
|
127
|
+
if (!raw) return EMPTY_BINDINGS;
|
|
128
|
+
const parsed: VarsJson = JSON.parse(raw);
|
|
129
|
+
if (!isVarsObject(parsed)) throw new Error("SPROUTBOAT_BINDINGS_JSON must be a JSON object");
|
|
130
|
+
const strings = (v: VarsJson): string[] => (Array.isArray(v) ? v.filter(isVarsString) : []);
|
|
131
|
+
const dos: Array<{ binding: string; className: string }> = [];
|
|
132
|
+
if (Array.isArray(parsed.do)) {
|
|
133
|
+
for (const entry of parsed.do) {
|
|
134
|
+
if (isVarsObject(entry) && isVarsString(entry.binding) && isVarsString(entry.className)) {
|
|
135
|
+
dos.push({ binding: entry.binding, className: entry.className });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
kv: strings(parsed.kv),
|
|
141
|
+
secrets: strings(parsed.secrets),
|
|
142
|
+
outbound: strings(parsed.outbound),
|
|
143
|
+
d1: strings(parsed.d1),
|
|
144
|
+
r2: strings(parsed.r2),
|
|
145
|
+
queues: strings(parsed.queues),
|
|
146
|
+
analytics: strings(parsed.analytics),
|
|
147
|
+
do: dos,
|
|
148
|
+
crons: strings(parsed.crons),
|
|
149
|
+
assets: isVarsString(parsed.assets) ? parsed.assets : "",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
Binary file
|