sproutboat 0.9.0 → 0.10.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 +62 -1
- package/README.md +20 -7
- package/SURFACE.md +5 -1
- package/bin/sproutboat.cjs +32 -0
- package/package.json +19 -6
- package/scripts/build-cli.ts +44 -0
- package/src/assets.ts +4 -121
- package/src/broker.ts +4 -1182
- package/src/build.ts +24 -17
- package/src/compile.ts +40 -19
- package/src/config.ts +4 -379
- package/src/dev.ts +263 -62
- package/src/json.ts +3 -38
- package/src/main.ts +39 -14
- package/src/manifest.ts +4 -167
- package/src/patch-porffor.ts +5 -144
- package/src/porffor-toolchain.ts +5 -0
- package/src/report.ts +5 -7
- package/src/source.ts +4 -44
- package/src/surface.ts +16 -0
- package/src/toolchain.ts +157 -44
- package/src/types-gen.ts +3 -0
- package/src/wrap.ts +5 -267
- package/types/sproutboat.d.ts +31 -0
- package/src/native-fetch-prelude.js +0 -950
- package/src/transport-broker.js +0 -299
- package/src/transport-embedded.js +0 -1571
package/src/toolchain.ts
CHANGED
|
@@ -7,14 +7,17 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
-
import { chmod, mkdir, readFile, rm } from "node:fs/promises";
|
|
10
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { dirname, resolve } from "node:path";
|
|
13
|
+
// @ts-expect-error Bun's file loader returns the embedded asset path in a compiled executable.
|
|
14
|
+
import embeddedUwsArchive from "../vendor/uwebsockets-360c276d-musl.tar.xz" with { type: "file" };
|
|
15
|
+
import { cachedPorfforRoot, PORFFOR_CHANNEL, PORFFOR_COMMIT } from "./porffor-toolchain";
|
|
13
16
|
|
|
14
17
|
export const ZIG_VERSION = "0.16.0";
|
|
15
18
|
|
|
16
19
|
/** The `<arch>-<os>` platforms ziglang.org publishes a tarball for that we pin. */
|
|
17
|
-
type ZigPlatform = "x86_64-linux" | "aarch64-linux" | "x86_64-macos" | "aarch64-macos";
|
|
20
|
+
export type ZigPlatform = "x86_64-linux" | "aarch64-linux" | "x86_64-macos" | "aarch64-macos";
|
|
18
21
|
|
|
19
22
|
// sha256 of the official ziglang.org tarballs for ZIG_VERSION, keyed by
|
|
20
23
|
// `<arch>-<os>` (the download naming). Bump alongside ZIG_VERSION.
|
|
@@ -25,17 +28,12 @@ const ZIG_SHA256 = {
|
|
|
25
28
|
"aarch64-macos": "b23d70deaa879b5c2d486ed3316f7eaa53e84acf6fc9cc747de152450d401489",
|
|
26
29
|
} satisfies Record<ZigPlatform, string>;
|
|
27
30
|
|
|
28
|
-
// Pinned Porffor identity
|
|
29
|
-
// (`github:CanadaHonk/porffor#alpha-4`, commit a415d19). PORFFOR_VERSION overrides.
|
|
31
|
+
// Pinned Porffor identity comes from porffor-toolchain.ts. PORFFOR_VERSION overrides.
|
|
30
32
|
// When bumping this pin (#55): run the monorepo's `bun run diff` against the
|
|
31
33
|
// frozen reference handlers and update its COMPAT.md for any new mismatch before
|
|
32
34
|
// releasing — the alpha compiler's output can shift between pins. Checked by
|
|
33
35
|
// hand at bump time, not in CI.
|
|
34
|
-
|
|
35
|
-
const PORFFOR_COMMIT = "a415d19";
|
|
36
|
-
|
|
37
|
-
// uWebSockets commit Porffor alpha-4 fetches for the native-fetch server. Read
|
|
38
|
-
// from node_modules/porffor at build time; this is the fallback for the stamp.
|
|
36
|
+
// uWebSockets commit Porffor alpha-4 fetches for the native-fetch server.
|
|
39
37
|
const UWS_COMMIT = "360c276d";
|
|
40
38
|
const UWS_COMMIT_FULL = "360c276d609d59af56ae6932adb95154ace9f15f";
|
|
41
39
|
|
|
@@ -56,51 +54,157 @@ function platformKey(): ZigPlatform {
|
|
|
56
54
|
return `${arch}-${os}`;
|
|
57
55
|
}
|
|
58
56
|
|
|
57
|
+
export class ZigToolchainError extends Error {
|
|
58
|
+
constructor(
|
|
59
|
+
readonly kind: "download" | "integrity" | "archive" | "cache" | "unsupported",
|
|
60
|
+
message: string,
|
|
61
|
+
) {
|
|
62
|
+
super(message);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type EnsureZigOptions = {
|
|
67
|
+
cacheRoot?: string;
|
|
68
|
+
platform?: ZigPlatform;
|
|
69
|
+
url?: string;
|
|
70
|
+
expectedSha256?: string;
|
|
71
|
+
fetcher?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
72
|
+
timeoutMs?: number;
|
|
73
|
+
};
|
|
74
|
+
|
|
59
75
|
async function sha256File(path: string): Promise<string> {
|
|
60
76
|
const hash = createHash("sha256");
|
|
61
77
|
hash.update(await readFile(path));
|
|
62
78
|
return hash.digest("hex");
|
|
63
79
|
}
|
|
64
80
|
|
|
65
|
-
|
|
66
|
-
|
|
81
|
+
async function zigComplete(dir: string, key: ZigPlatform, expectedArchive: string): Promise<boolean> {
|
|
82
|
+
try {
|
|
83
|
+
// SAFETY: this manifest is private data written below; its values are only
|
|
84
|
+
// accepted when the immutable identity and binary digest both match.
|
|
85
|
+
const manifest = JSON.parse(await readFile(resolve(dir, ".sproutboat-complete"), "utf8")) as {
|
|
86
|
+
version?: string;
|
|
87
|
+
platform?: string;
|
|
88
|
+
archiveSha256?: string;
|
|
89
|
+
binarySha256?: string;
|
|
90
|
+
};
|
|
91
|
+
const bin = resolve(dir, "zig");
|
|
92
|
+
return (
|
|
93
|
+
manifest.version === ZIG_VERSION &&
|
|
94
|
+
manifest.platform === key &&
|
|
95
|
+
manifest.archiveSha256 === expectedArchive &&
|
|
96
|
+
manifest.binarySha256 !== undefined &&
|
|
97
|
+
(await sha256File(bin)) === manifest.binarySha256
|
|
98
|
+
);
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function downloadZig(
|
|
105
|
+
url: string,
|
|
106
|
+
path: string,
|
|
107
|
+
fetcher: NonNullable<EnsureZigOptions["fetcher"]>,
|
|
108
|
+
timeoutMs: number,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
let last: unknown;
|
|
111
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
112
|
+
try {
|
|
113
|
+
const response = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
114
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
115
|
+
await writeFile(path, new Uint8Array(await response.arrayBuffer()));
|
|
116
|
+
return;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
last = error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
throw new ZigToolchainError("download", `could not download pinned Zig from ${url}: ${String(last)}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function waitForZigPublisher(
|
|
125
|
+
dir: string,
|
|
126
|
+
lock: string,
|
|
127
|
+
key: ZigPlatform,
|
|
128
|
+
expected: string,
|
|
129
|
+
): Promise<string | null> {
|
|
130
|
+
const deadline = Date.now() + 30_000;
|
|
131
|
+
while (Date.now() < deadline) {
|
|
132
|
+
if (await zigComplete(dir, key, expected)) return resolve(dir, "zig");
|
|
133
|
+
if (!existsSync(lock)) return null;
|
|
134
|
+
if (Date.now() - (await stat(lock)).mtimeMs > 5 * 60_000) {
|
|
135
|
+
await rm(lock, { recursive: true, force: true });
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
await Bun.sleep(25);
|
|
139
|
+
}
|
|
140
|
+
throw new ZigToolchainError("cache", `timed out waiting for Zig cache lock ${lock}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Absolute path to a verified Zig binary, downloading it on first use. */
|
|
144
|
+
export async function ensureZig(options: EnsureZigOptions = {}): Promise<string> {
|
|
67
145
|
const override = process.env.SPROUTBOAT_ZIG;
|
|
68
146
|
if (override) {
|
|
69
|
-
if (!existsSync(override)) throw new
|
|
147
|
+
if (!existsSync(override)) throw new ZigToolchainError("unsupported", `SPROUTBOAT_ZIG=${override} does not exist`);
|
|
70
148
|
return override;
|
|
71
149
|
}
|
|
72
|
-
const key = platformKey();
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
150
|
+
const key = options.platform ?? platformKey();
|
|
151
|
+
const root = resolve(
|
|
152
|
+
options.cacheRoot ?? process.env.SPROUTBOAT_TOOLCHAIN_CACHE ?? resolve(homedir(), ".cache/sproutboat"),
|
|
153
|
+
);
|
|
154
|
+
const dir = resolve(root, `zig-${ZIG_VERSION}-${key}`);
|
|
155
|
+
const expected = options.expectedSha256 ?? ZIG_SHA256[key];
|
|
156
|
+
if (await zigComplete(dir, key, expected)) return resolve(dir, "zig");
|
|
157
|
+
await mkdir(root, { recursive: true });
|
|
158
|
+
const lock = `${dir}.lock`;
|
|
159
|
+
try {
|
|
160
|
+
await mkdir(lock);
|
|
161
|
+
} catch {
|
|
162
|
+
const published = await waitForZigPublisher(dir, lock, key, expected);
|
|
163
|
+
if (published) return published;
|
|
164
|
+
return ensureZig(options);
|
|
165
|
+
}
|
|
166
|
+
const stage = resolve(root, `.zig-${ZIG_VERSION}-${key}-${process.pid}-${crypto.randomUUID()}`);
|
|
167
|
+
const url = options.url ?? `https://ziglang.org/download/${ZIG_VERSION}/zig-${key}-${ZIG_VERSION}.tar.xz`;
|
|
80
168
|
console.log(`Fetching Zig ${ZIG_VERSION} (${key}, one-time)...`);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const response = await fetch(url);
|
|
84
|
-
if (!response.ok) throw new Error(`could not download Zig: ${url} (${response.status})`);
|
|
85
|
-
await Bun.write(archive, response);
|
|
86
|
-
|
|
87
|
-
const actual = await sha256File(archive);
|
|
88
|
-
if (expected && actual !== expected) {
|
|
169
|
+
try {
|
|
170
|
+
if (await zigComplete(dir, key, expected)) return resolve(dir, "zig");
|
|
89
171
|
await rm(dir, { recursive: true, force: true });
|
|
90
|
-
|
|
172
|
+
await mkdir(stage);
|
|
173
|
+
const archive = resolve(stage, "zig.tar.xz");
|
|
174
|
+
await downloadZig(url, archive, options.fetcher ?? fetch, options.timeoutMs ?? 30_000);
|
|
175
|
+
const actual = await sha256File(archive);
|
|
176
|
+
if (actual !== expected)
|
|
177
|
+
throw new ZigToolchainError(
|
|
178
|
+
"integrity",
|
|
179
|
+
`Zig archive sha256 mismatch\n expected ${expected}\n got ${actual}`,
|
|
180
|
+
);
|
|
181
|
+
const untar = Bun.spawn(["tar", "-xJf", archive, "-C", stage, "--strip-components=1"], {
|
|
182
|
+
stdout: "pipe",
|
|
183
|
+
stderr: "pipe",
|
|
184
|
+
});
|
|
185
|
+
const [code, err] = await Promise.all([untar.exited, new Response(untar.stderr).text()]);
|
|
186
|
+
if (code !== 0)
|
|
187
|
+
throw new ZigToolchainError("archive", `could not extract Zig (needs \`tar\` with xz support): ${err.trim()}`);
|
|
188
|
+
await rm(archive, { force: true });
|
|
189
|
+
const bin = resolve(stage, "zig");
|
|
190
|
+
if (!existsSync(bin)) throw new ZigToolchainError("archive", "Zig archive did not contain a `zig` binary");
|
|
191
|
+
await chmod(bin, 0o755);
|
|
192
|
+
await writeFile(
|
|
193
|
+
resolve(stage, ".sproutboat-complete"),
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
version: ZIG_VERSION,
|
|
196
|
+
platform: key,
|
|
197
|
+
archiveSha256: actual,
|
|
198
|
+
binarySha256: await sha256File(bin),
|
|
199
|
+
}),
|
|
200
|
+
{ mode: 0o444 },
|
|
201
|
+
);
|
|
202
|
+
await rename(stage, dir);
|
|
203
|
+
return resolve(dir, "zig");
|
|
204
|
+
} finally {
|
|
205
|
+
await rm(stage, { recursive: true, force: true });
|
|
206
|
+
await rm(lock, { recursive: true, force: true });
|
|
91
207
|
}
|
|
92
|
-
|
|
93
|
-
// `tar -xJ` (xz) works on macOS bsdtar and GNU tar with xz on PATH.
|
|
94
|
-
const untar = Bun.spawn(["tar", "-xJf", archive, "-C", dir, "--strip-components=1"], {
|
|
95
|
-
stdout: "pipe",
|
|
96
|
-
stderr: "pipe",
|
|
97
|
-
});
|
|
98
|
-
const [code, err] = await Promise.all([untar.exited, new Response(untar.stderr).text()]);
|
|
99
|
-
if (code !== 0) throw new Error(`could not extract Zig (needs \`tar\` with xz support): ${err.trim()}`);
|
|
100
|
-
await rm(archive, { force: true });
|
|
101
|
-
if (!existsSync(bin)) throw new Error("Zig archive did not contain a `zig` binary");
|
|
102
|
-
await chmod(bin, 0o755);
|
|
103
|
-
return bin;
|
|
104
208
|
}
|
|
105
209
|
|
|
106
210
|
function uwsCommitFull(): string {
|
|
@@ -117,7 +221,9 @@ export class UwsUnavailableError extends Error {}
|
|
|
117
221
|
|
|
118
222
|
/** Path to the vendored prebuilt archive for the given short commit. */
|
|
119
223
|
export function uwsVendorArchive(short: string): string {
|
|
120
|
-
return
|
|
224
|
+
return short === UWS_COMMIT_FULL.slice(0, 8)
|
|
225
|
+
? embeddedUwsArchive
|
|
226
|
+
: resolve(import.meta.dir, "..", "vendor", `uwebsockets-${short}-musl.tar.xz`);
|
|
121
227
|
}
|
|
122
228
|
|
|
123
229
|
/**
|
|
@@ -162,11 +268,16 @@ export async function ensureUWebSockets(): Promise<void> {
|
|
|
162
268
|
/** Unpack the vendored source tree into `dir`. */
|
|
163
269
|
async function extractUws(archive: string, dir: string): Promise<void> {
|
|
164
270
|
await mkdir(dir, { recursive: true });
|
|
165
|
-
|
|
271
|
+
// External programs cannot read Bun's virtual /$bunfs paths. Materialize the
|
|
272
|
+
// embedded release asset inside the destination before handing it to tar.
|
|
273
|
+
const readableArchive = archive.includes("/$bunfs/") ? resolve(dir, ".sproutboat-uwebsockets.tar.xz") : archive;
|
|
274
|
+
if (readableArchive !== archive) await Bun.write(readableArchive, Bun.file(archive));
|
|
275
|
+
const untar = Bun.spawn(["tar", "-xJf", readableArchive, "-C", dir, "--strip-components=1"], {
|
|
166
276
|
stdout: "pipe",
|
|
167
277
|
stderr: "pipe",
|
|
168
278
|
});
|
|
169
279
|
const [code, err] = await Promise.all([untar.exited, new Response(untar.stderr).text()]);
|
|
280
|
+
if (readableArchive !== archive) await rm(readableArchive, { force: true });
|
|
170
281
|
if (code !== 0) {
|
|
171
282
|
await rm(dir, { recursive: true, force: true });
|
|
172
283
|
throw new UwsUnavailableError(`could not extract vendored uWebSockets: ${err.trim()}`);
|
|
@@ -241,12 +352,14 @@ export async function ensureUWebSocketsHost(): Promise<void> {
|
|
|
241
352
|
|
|
242
353
|
/** Directory holding node_modules/porffor (walks up from this file). */
|
|
243
354
|
export function porfforRoot(start = import.meta.dir): string {
|
|
355
|
+
const managed = cachedPorfforRoot();
|
|
356
|
+
if (existsSync(resolve(managed, "runtime/index.js"))) return managed;
|
|
244
357
|
let dir = start;
|
|
245
358
|
for (;;) {
|
|
246
359
|
const candidate = resolve(dir, "node_modules/porffor");
|
|
247
360
|
if (existsSync(resolve(candidate, "runtime/index.js"))) return candidate;
|
|
248
361
|
const parent = dirname(dir);
|
|
249
|
-
if (parent === dir)
|
|
362
|
+
if (parent === dir) return managed;
|
|
250
363
|
dir = parent;
|
|
251
364
|
}
|
|
252
365
|
}
|
package/src/types-gen.ts
CHANGED
|
@@ -49,6 +49,9 @@ function entries(config: SproutboatConfig): Entry[] {
|
|
|
49
49
|
for (const service of config.services ?? []) {
|
|
50
50
|
out.push({ name: service.binding, type: "Fetcher", note: `service binding: ${service.service}` });
|
|
51
51
|
}
|
|
52
|
+
for (const rl of config.ratelimiters ?? []) {
|
|
53
|
+
out.push({ name: rl.binding, type: "RateLimit", note: `rate limit: ${rl.limit} per ${rl.period}s` });
|
|
54
|
+
}
|
|
52
55
|
if (config.assets?.binding) {
|
|
53
56
|
out.push({ name: config.assets.binding, type: "Fetcher", note: `static assets: ${config.assets.directory}` });
|
|
54
57
|
}
|
package/src/wrap.ts
CHANGED
|
@@ -1,267 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
/**
|
|
17
|
-
* #15 — the two transports the prelude can be built with.
|
|
18
|
-
*
|
|
19
|
-
* Both define `__sbCall(reqJson) -> replyJson` and nothing else; every binding
|
|
20
|
-
* shim above that line is identical, which is what lets one conformance suite
|
|
21
|
-
* hold both honest. `broker` talks to the per-deployment broker over loopback
|
|
22
|
-
* (deployed, dev, phase-0 standalone); `embedded` compiles SQLite into the
|
|
23
|
-
* sprout and needs no second process at all.
|
|
24
|
-
*/
|
|
25
|
-
export type Transport = "broker" | "embedded";
|
|
26
|
-
export const transportPath = (transport: Transport): URL =>
|
|
27
|
-
new URL(transport === "embedded" ? "./transport-embedded.js" : "./transport-broker.js", import.meta.url);
|
|
28
|
-
|
|
29
|
-
/** Where the prelude expects its transport spliced in. */
|
|
30
|
-
export const TRANSPORT_MARKER =
|
|
31
|
-
"// TRANSPORT: wrap.ts splices one of transport-broker.js / transport-embedded.js here.";
|
|
32
|
-
|
|
33
|
-
// The server honours $PORT at runtime (patches/porffor-render.patch); this baked
|
|
34
|
-
// value is only a fallback for a directly-run binary.
|
|
35
|
-
const DEFAULT_PORT = 8080;
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* What an artifact with no `compatibilityDate` means. Artifacts built before
|
|
39
|
-
* the field existed keep the semantics of that day forever, because the binary
|
|
40
|
-
* is immutable and `rollback` can reactivate it at any time.
|
|
41
|
-
*
|
|
42
|
-
* How to use it: when a runtime behaviour has to change in a way that would
|
|
43
|
-
* break a deployed handler, don't change it unconditionally — gate it in the
|
|
44
|
-
* prelude on `__sbCompat >= "YYYY-MM-DD"` (ISO dates compare correctly as
|
|
45
|
-
* strings) and document the flip date. Old binaries carry their old date and
|
|
46
|
-
* keep the old behaviour; a project opts in by moving `compatibility_date` in
|
|
47
|
-
* its `sproutboat.jsonc` and rebuilding.
|
|
48
|
-
*/
|
|
49
|
-
export const BASELINE_COMPATIBILITY_DATE = "2026-08-26";
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Binding names a project declares. `do` maps a binding name to a Durable Object
|
|
53
|
-
* class name; `crons` are schedule expressions with no name.
|
|
54
|
-
*/
|
|
55
|
-
export type Bindings = {
|
|
56
|
-
kv: string[];
|
|
57
|
-
secrets: string[];
|
|
58
|
-
outbound: string[];
|
|
59
|
-
d1: string[];
|
|
60
|
-
r2: string[];
|
|
61
|
-
queues: string[];
|
|
62
|
-
analytics: string[];
|
|
63
|
-
do: Array<{ binding: string; className: string }>;
|
|
64
|
-
/** #48 — worker-to-worker: binding name -> the project it calls. The hostname
|
|
65
|
-
* it resolves to is a runtime input, not part of the artifact. */
|
|
66
|
-
services: Array<{ binding: string; service: string }>;
|
|
67
|
-
crons: string[];
|
|
68
|
-
/** Static-asset binding name for `env.<NAME>.fetch(request)`; `""` when assets are edge-only. */
|
|
69
|
-
assets: string;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
export const EMPTY_BINDINGS: Bindings = {
|
|
73
|
-
kv: [],
|
|
74
|
-
secrets: [],
|
|
75
|
-
outbound: [],
|
|
76
|
-
d1: [],
|
|
77
|
-
r2: [],
|
|
78
|
-
queues: [],
|
|
79
|
-
analytics: [],
|
|
80
|
-
do: [],
|
|
81
|
-
services: [],
|
|
82
|
-
crons: [],
|
|
83
|
-
assets: "",
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
function hasBindings(b: Bindings): boolean {
|
|
87
|
-
return (
|
|
88
|
-
b.kv.length > 0 ||
|
|
89
|
-
b.secrets.length > 0 ||
|
|
90
|
-
b.outbound.length > 0 ||
|
|
91
|
-
b.d1.length > 0 ||
|
|
92
|
-
b.r2.length > 0 ||
|
|
93
|
-
b.queues.length > 0 ||
|
|
94
|
-
b.analytics.length > 0 ||
|
|
95
|
-
b.do.length > 0 ||
|
|
96
|
-
b.services.length > 0 ||
|
|
97
|
-
b.assets !== ""
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Turn the module's exports into plain top-level declarations, so the handler
|
|
103
|
-
* object is reachable as `__sbHandlers` and Durable Object classes stay
|
|
104
|
-
* addressable by name.
|
|
105
|
-
*
|
|
106
|
-
* Two shapes reach us. A hand-written file exports inline
|
|
107
|
-
* (`export default { fetch }`), while a bundled one declares everything first
|
|
108
|
-
* and re-exports at the end (`export { src_default as default, Counter }`) —
|
|
109
|
-
* #89 made the second shape the normal case. Returns null when neither matches.
|
|
110
|
-
*/
|
|
111
|
-
export function neutraliseExports(source: string): string | null {
|
|
112
|
-
if (/\bexport\s+default\s*\{/.test(source)) {
|
|
113
|
-
return source
|
|
114
|
-
.replace(/^(\s*)export\s+default\s*/m, "$1const __sbHandlers = ")
|
|
115
|
-
.replace(/^export\s+(async\s+function|function|class|const|let|var)\b/gm, "$1");
|
|
116
|
-
}
|
|
117
|
-
// Not anchored to a line: a minified bundle puts the whole module on one
|
|
118
|
-
// line. Bundlers emit exactly one such block, at the end.
|
|
119
|
-
const blocks = [...source.matchAll(/export\s*\{([^}]*)\}\s*;?/g)];
|
|
120
|
-
const block = blocks[blocks.length - 1];
|
|
121
|
-
if (block === undefined) return null;
|
|
122
|
-
let handler: string | null = null;
|
|
123
|
-
const aliases: string[] = [];
|
|
124
|
-
for (const entry of block[1]
|
|
125
|
-
.split(",")
|
|
126
|
-
.map((part) => part.trim())
|
|
127
|
-
.filter(Boolean)) {
|
|
128
|
-
const parts = entry.match(/^(\S+)(?:\s+as\s+(\S+))?$/);
|
|
129
|
-
if (parts === null) continue;
|
|
130
|
-
const local = parts[1];
|
|
131
|
-
const exported = parts[2] ?? local;
|
|
132
|
-
if (exported === "default") handler = local;
|
|
133
|
-
// `export { Counter as Counter }` needs no alias; a renamed one does, so
|
|
134
|
-
// `durable_objects` in the config can still name the class it expects.
|
|
135
|
-
else if (exported !== local) aliases.push(`const ${exported} = ${local};`);
|
|
136
|
-
}
|
|
137
|
-
if (handler === null) return null;
|
|
138
|
-
return source.replace(block[0], [`const __sbHandlers = ${handler};`, ...aliases].join("\n"));
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Build the final native-fetch module: the prelude (Web API shims + the broker
|
|
143
|
-
* binding shim + the trigger dispatcher), then `const env = {…}` with the baked
|
|
144
|
-
* `vars`, then — if any binding is declared — one `__sbInstallBindings(env, …)`
|
|
145
|
-
* line, then the user's source with its `export` keywords neutralised (so its
|
|
146
|
-
* `export default {…}` becomes a plain object we can hand to the dispatcher),
|
|
147
|
-
* then our single `export default { fetch }` that routes every request through
|
|
148
|
-
* `__sbEntry` (HTTP → `handlers.fetch`; `x-sb-trigger` → scheduled / queue / DO).
|
|
149
|
-
*
|
|
150
|
-
* With no bindings and no `scheduled`/`queue`/DO the output behaves exactly like
|
|
151
|
-
* a plain `export default { fetch }` sprout.
|
|
152
|
-
*
|
|
153
|
-
* `port` is only the baked fallback in `export default { port }`; the runtime
|
|
154
|
-
* reads `$PORT` first. The monorepo's bench path overrides it.
|
|
155
|
-
*
|
|
156
|
-
* ponytail: the sprout process is long-lived, so a handler that mutates `env`
|
|
157
|
-
* leaks that change to later requests. Freeze upstream once Porffor supports
|
|
158
|
-
* Object.freeze in native mode.
|
|
159
|
-
*/
|
|
160
|
-
export function wrapNativeFetchHandler(
|
|
161
|
-
source: string,
|
|
162
|
-
prelude: string,
|
|
163
|
-
vars: Record<string, string> = {},
|
|
164
|
-
bindings: Bindings = EMPTY_BINDINGS,
|
|
165
|
-
port: number = DEFAULT_PORT,
|
|
166
|
-
compatibilityDate: string = BASELINE_COMPATIBILITY_DATE,
|
|
167
|
-
appName: string = "app",
|
|
168
|
-
/** #15 — assets baked into the module for a binary that has no files beside it. */
|
|
169
|
-
assets?: { manifest: unknown; files: Record<string, string> },
|
|
170
|
-
): string {
|
|
171
|
-
const neutralised = neutraliseExports(source);
|
|
172
|
-
if (neutralised === null || !/\bfetch\s*\(/.test(source)) {
|
|
173
|
-
throw new Error("handler must default-export an object with a fetch(request) method");
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const env = `const env = ${JSON.stringify(vars)};\nglobalThis.env = env;\n`;
|
|
177
|
-
// Baked, not a binding: the date belongs to the artifact, and a handler must
|
|
178
|
-
// not be able to change the semantics it was compiled against at runtime.
|
|
179
|
-
const compat =
|
|
180
|
-
`globalThis.__sbCompat = ${JSON.stringify(compatibilityDate)};\n` +
|
|
181
|
-
// #15 — the embedded transport derives its default data directory from this.
|
|
182
|
-
`globalThis.__sbAppName = ${JSON.stringify(appName)};\n` +
|
|
183
|
-
// #15 — and enforces the outbound allowlist itself, with no broker to do it.
|
|
184
|
-
`globalThis.__sbOutbound = ${JSON.stringify(bindings.outbound)};\n` +
|
|
185
|
-
(assets ? `globalThis.__sbAssets = ${JSON.stringify(assets)};\n` : "");
|
|
186
|
-
const wire = hasBindings(bindings) ? `__sbInstallBindings(env, ${JSON.stringify(bindings)});\n` : "";
|
|
187
|
-
const registerDO = bindings.do.length
|
|
188
|
-
? `__sbRegisterDO({ ${bindings.do.map((d) => `${d.className}: ${d.className}`).join(", ")} });\n`
|
|
189
|
-
: "";
|
|
190
|
-
// Cron / queue / alarm timers, for a transport that has no broker to deliver
|
|
191
|
-
// them. The broker transport defines this as a no-op, so the emitted module
|
|
192
|
-
// is the same either way.
|
|
193
|
-
const triggers = hasBindings(bindings) ? `__sbStartLocalTriggers(__sbHandlers, ${JSON.stringify(bindings)});\n` : "";
|
|
194
|
-
|
|
195
|
-
return (
|
|
196
|
-
`${prelude}\n${compat}${env}${wire}` +
|
|
197
|
-
`${neutralised}\n` +
|
|
198
|
-
`${registerDO}${triggers}` +
|
|
199
|
-
`export default {\n port: ${port},\n fetch(request) { return __sbEntry(__sbHandlers, request); }\n};\n`
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
type VarsJson = string | number | boolean | null | { readonly [key: string]: VarsJson } | VarsJson[];
|
|
204
|
-
function isVarsObject(value: VarsJson): value is { readonly [key: string]: VarsJson } {
|
|
205
|
-
return value !== null && Object(value) === value && !Array.isArray(value);
|
|
206
|
-
}
|
|
207
|
-
function isVarsString(value: VarsJson): value is string {
|
|
208
|
-
return Object(value) !== value && value === String(value);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** `SPROUTBOAT_VARS_JSON` (set by the build) → a validated flat string map. */
|
|
212
|
-
export function readVarsFromEnv(): Record<string, string> {
|
|
213
|
-
const raw = process.env.SPROUTBOAT_VARS_JSON;
|
|
214
|
-
if (!raw) return {};
|
|
215
|
-
const parsed: VarsJson = JSON.parse(raw);
|
|
216
|
-
if (!isVarsObject(parsed)) throw new Error("SPROUTBOAT_VARS_JSON must be a JSON object");
|
|
217
|
-
return Object.fromEntries(
|
|
218
|
-
Object.entries(parsed).map(([key, value]): [string, string] => {
|
|
219
|
-
if (!/^[A-Z][A-Z0-9_]*$/.test(key) || !isVarsString(value))
|
|
220
|
-
throw new Error(`SPROUTBOAT_VARS_JSON.${key} must map an UPPER_SNAKE name to a string`);
|
|
221
|
-
return [key, value];
|
|
222
|
-
}),
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* `SPROUTBOAT_BINDINGS_JSON` (the artifact's `bindings.json`, passed by the
|
|
228
|
-
* build) → a `Bindings` shape. Every field is re-validated here; unknown keys
|
|
229
|
-
* are dropped and a missing / empty payload is `EMPTY_BINDINGS`, so an old build
|
|
230
|
-
* with no bindings still compiles.
|
|
231
|
-
*/
|
|
232
|
-
export function readBindingsFromEnv(): Bindings {
|
|
233
|
-
const raw = process.env.SPROUTBOAT_BINDINGS_JSON;
|
|
234
|
-
if (!raw) return EMPTY_BINDINGS;
|
|
235
|
-
const parsed: VarsJson = JSON.parse(raw);
|
|
236
|
-
if (!isVarsObject(parsed)) throw new Error("SPROUTBOAT_BINDINGS_JSON must be a JSON object");
|
|
237
|
-
const strings = (v: VarsJson): string[] => (Array.isArray(v) ? v.filter(isVarsString) : []);
|
|
238
|
-
const services: Array<{ binding: string; service: string }> = [];
|
|
239
|
-
if (Array.isArray(parsed.services)) {
|
|
240
|
-
for (const entry of parsed.services) {
|
|
241
|
-
if (isVarsObject(entry) && isVarsString(entry.binding) && isVarsString(entry.service)) {
|
|
242
|
-
services.push({ binding: entry.binding, service: entry.service });
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
const dos: Array<{ binding: string; className: string }> = [];
|
|
247
|
-
if (Array.isArray(parsed.do)) {
|
|
248
|
-
for (const entry of parsed.do) {
|
|
249
|
-
if (isVarsObject(entry) && isVarsString(entry.binding) && isVarsString(entry.className)) {
|
|
250
|
-
dos.push({ binding: entry.binding, className: entry.className });
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
return {
|
|
255
|
-
kv: strings(parsed.kv),
|
|
256
|
-
secrets: strings(parsed.secrets),
|
|
257
|
-
outbound: strings(parsed.outbound),
|
|
258
|
-
d1: strings(parsed.d1),
|
|
259
|
-
r2: strings(parsed.r2),
|
|
260
|
-
queues: strings(parsed.queues),
|
|
261
|
-
analytics: strings(parsed.analytics),
|
|
262
|
-
do: dos,
|
|
263
|
-
services,
|
|
264
|
-
crons: strings(parsed.crons),
|
|
265
|
-
assets: isVarsString(parsed.assets) ? parsed.assets : "",
|
|
266
|
-
};
|
|
267
|
-
}
|
|
1
|
+
// Re-exported from @sproutboat/runtime (moved verbatim with its file-URL
|
|
2
|
+
// siblings: transports and prelude travel together because wrap.ts locates
|
|
3
|
+
// them by file URL next to itself). This shim keeps every `./wrap` importer
|
|
4
|
+
// and the `sproutboat/runtime/wrap` export path working.
|
|
5
|
+
export * from "@sproutboat/runtime";
|
package/types/sproutboat.d.ts
CHANGED
|
@@ -176,6 +176,37 @@ declare global {
|
|
|
176
176
|
query<T = unknown>(options?: { limit?: number }): { count: number; rows: T[] };
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// ------------------------------------ Rate limiting
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A rate-limiter binding (#69). `limit` and `period` are fixed in
|
|
183
|
+
* sproutboat.jsonc; `limit({ key })` counts one call against a fixed window
|
|
184
|
+
* and reports whether the key is still under the cap.
|
|
185
|
+
*/
|
|
186
|
+
interface RateLimit {
|
|
187
|
+
limit(options: { key: string }): { success: boolean };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ------------------------------------ Crypto
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* `crypto.subtle` covers `digest` (SHA-256/384/512) and HMAC
|
|
194
|
+
* `importKey` / `sign` / `verify`; other algorithms throw.
|
|
195
|
+
*
|
|
196
|
+
* `crypto.scryptVerify` is a Sproutboat extension (#153), not WebCrypto: it
|
|
197
|
+
* re-derives a scrypt hash and compares it in constant time, for migrating
|
|
198
|
+
* password hashes made by Node/Bun `scrypt`. Verify-only on purpose; new
|
|
199
|
+
* credentials should use HMAC/PBKDF2 via `crypto.subtle`.
|
|
200
|
+
*/
|
|
201
|
+
interface Crypto {
|
|
202
|
+
scryptVerify(
|
|
203
|
+
password: string | ArrayBuffer | ArrayBufferView,
|
|
204
|
+
salt: string | ArrayBuffer | ArrayBufferView,
|
|
205
|
+
expected: string | ArrayBuffer | ArrayBufferView,
|
|
206
|
+
params?: { N?: number; r?: number; p?: number },
|
|
207
|
+
): boolean;
|
|
208
|
+
}
|
|
209
|
+
|
|
179
210
|
// ------------------------------------ Fetchers: services and assets
|
|
180
211
|
|
|
181
212
|
/** A service binding, and the shape of the static-asset binding. */
|