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/src/manifest.ts CHANGED
@@ -1,167 +1,4 @@
1
- export const ARTIFACT_SCHEMA_VERSION = 2;
2
- export const RUNTIME = "native-fetch";
3
- export const CAPABILITY_PROFILE = "http-sync-v0";
4
-
5
- /** The only target a deployed artifact may carry. Every box runs linux-x86_64. */
6
- export const DEPLOY_TARGET = "linux-x86_64";
7
-
8
- /**
9
- * `<arch>-<platform>` of the machine doing the build, e.g. `arm64-darwin`.
10
- * Only `sproutboat build --target host` produces one (#62): it runs on this
11
- * machine for local dev and is deliberately not portable, so `validateManifest`
12
- * rejects it and neither `deploy` nor the control plane will accept it.
13
- */
14
- export type HostTarget = `${string}-${string}`;
15
- export const hostTarget = (): HostTarget => `${process.arch}-${process.platform}`;
16
-
17
- export type ArtifactManifest = {
18
- schemaVersion: 2;
19
- project: string;
20
- target: typeof DEPLOY_TARGET | HostTarget;
21
- runtime: "native-fetch";
22
- capabilityProfile: "http-sync-v0";
23
- porfforVersion: string;
24
- esbuildVersion: string;
25
- /** Build provenance, e.g. `zig-musl/0.16.0+porffor/a415d19+uws/360c276d`. */
26
- buildImage: string;
27
- /**
28
- * The project's `compatibility_date`, as `YYYY-MM-DD`. Optional: artifacts
29
- * built before this field existed have none, and every reader must treat that
30
- * as the baseline (see `BASELINE_COMPATIBILITY_DATE` in `wrap.ts`). This is
31
- * how a runtime behaviour change ships without breaking deployed apps — new
32
- * builds opt in by moving their date, old binaries keep the old semantics —
33
- * so it is deliberately *not* part of `schemaVersion`, which stays frozen
34
- * at 2.
35
- */
36
- compatibilityDate?: string;
37
- sourceHash: `sha256:${string}`;
38
- binaryHash: `sha256:${string}`;
39
- binarySize: number;
40
- builtAt: string;
41
- };
42
-
43
- export type ManifestValidation = { ok: true; value: ArtifactManifest } | { ok: false; errors: string[] };
44
-
45
- type JsonValue = string | number | boolean | null | ManifestJsonObject | JsonValue[];
46
-
47
- interface ManifestJsonObject {
48
- readonly [key: string]: JsonValue;
49
- }
50
-
51
- type ManifestInput = JsonValue | undefined;
52
-
53
- function isRecord(value: ManifestInput): value is ManifestJsonObject {
54
- return value !== null && Object(value) === value && !Array.isArray(value) && !(value instanceof Function);
55
- }
56
-
57
- function isString(value: ManifestInput): value is string {
58
- return Object(value) !== value && value === String(value);
59
- }
60
-
61
- function sha256(value: ManifestInput): value is `sha256:${string}` {
62
- return isString(value) && /^sha256:[a-f0-9]{64}$/.test(value);
63
- }
64
-
65
- function isPositiveInteger(value: ManifestInput): value is number {
66
- return Number.isSafeInteger(value) && value === Number(value) && Number(value) > 0;
67
- }
68
-
69
- export function validateManifest(value: ManifestInput): ManifestValidation {
70
- if (!isRecord(value)) return { ok: false, errors: ["manifest must be an object"] };
71
- const errors: string[] = [];
72
- const required = [
73
- "schemaVersion",
74
- "project",
75
- "target",
76
- "runtime",
77
- "capabilityProfile",
78
- "porfforVersion",
79
- "esbuildVersion",
80
- "buildImage",
81
- "sourceHash",
82
- "binaryHash",
83
- "binarySize",
84
- "builtAt",
85
- ];
86
- for (const field of required) if (!(field in value)) errors.push(`missing manifest field: ${field}`);
87
- const schemaVersion = value.schemaVersion === ARTIFACT_SCHEMA_VERSION ? ARTIFACT_SCHEMA_VERSION : null;
88
- if (schemaVersion === null) errors.push("schemaVersion must be 2");
89
- const project =
90
- isString(value.project) && /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/.test(value.project) ? value.project : null;
91
- if (project === null) errors.push("project must be a valid slug");
92
- const target = value.target === DEPLOY_TARGET ? value.target : null;
93
- if (target === null) {
94
- // A `--target host` artifact lands here: runnable where it was built, not
95
- // on a box. Name that, so the failure reads as "wrong build" not "corrupt".
96
- errors.push(
97
- isString(value.target) && value.target !== DEPLOY_TARGET
98
- ? `target must be ${DEPLOY_TARGET}, got ${value.target} — \`--target host\` builds are for local dev and cannot be deployed`
99
- : `target must be ${DEPLOY_TARGET}`,
100
- );
101
- }
102
- const runtime = value.runtime === RUNTIME ? value.runtime : null;
103
- if (runtime === null) errors.push("runtime must be native-fetch");
104
- const capabilityProfile = value.capabilityProfile === CAPABILITY_PROFILE ? value.capabilityProfile : null;
105
- if (capabilityProfile === null) errors.push("capabilityProfile must be http-sync-v0");
106
- const versions = ["porfforVersion", "esbuildVersion", "buildImage"] as const;
107
- const [porfforVersion, esbuildVersion, buildImage] = versions.map((field) =>
108
- isString(value[field]) && value[field] ? value[field] : null,
109
- );
110
- for (const [field, version] of versions.map(
111
- (field, index) => [field, [porfforVersion, esbuildVersion, buildImage][index]] as const,
112
- )) {
113
- if (version === null) errors.push(`${field} must be a non-empty string`);
114
- }
115
- const sourceHash = sha256(value.sourceHash) ? value.sourceHash : null;
116
- if (sourceHash === null) errors.push("sourceHash must be a sha256 digest");
117
- const binaryHash = sha256(value.binaryHash) ? value.binaryHash : null;
118
- if (binaryHash === null) errors.push("binaryHash must be a sha256 digest");
119
- const binarySize = isPositiveInteger(value.binarySize) ? value.binarySize : null;
120
- if (binarySize === null) errors.push("binarySize must be a positive integer");
121
- const builtAt = isString(value.builtAt) && !Number.isNaN(Date.parse(value.builtAt)) ? value.builtAt : null;
122
- if (builtAt === null) errors.push("builtAt must be an ISO-8601 timestamp");
123
- // Optional by design: an artifact from before the field existed is valid and
124
- // must stay deployable, so "absent" is not an error — only "present and
125
- // malformed" is.
126
- let compatibilityDate: string | undefined;
127
- if (value.compatibilityDate !== undefined) {
128
- if (isString(value.compatibilityDate) && /^\d{4}-\d{2}-\d{2}$/.test(value.compatibilityDate))
129
- compatibilityDate = value.compatibilityDate;
130
- else errors.push("compatibilityDate must be YYYY-MM-DD");
131
- }
132
- if (
133
- errors.length ||
134
- schemaVersion === null ||
135
- project === null ||
136
- target === null ||
137
- runtime === null ||
138
- capabilityProfile === null ||
139
- porfforVersion === null ||
140
- esbuildVersion === null ||
141
- buildImage === null ||
142
- sourceHash === null ||
143
- binaryHash === null ||
144
- binarySize === null ||
145
- builtAt === null
146
- )
147
- return { ok: false, errors };
148
- return {
149
- ok: true,
150
- value: {
151
- ...value,
152
- schemaVersion,
153
- project,
154
- target,
155
- runtime,
156
- capabilityProfile,
157
- porfforVersion,
158
- esbuildVersion,
159
- buildImage,
160
- sourceHash,
161
- binaryHash,
162
- binarySize,
163
- builtAt,
164
- compatibilityDate,
165
- },
166
- };
167
- }
1
+ // Re-exported from @sproutboat/artifact (moved verbatim). This shim keeps
2
+ // every `./manifest` importer and the `sproutboat/runtime/manifest` export
3
+ // path working; the implementation lives in the shared package.
4
+ export * from "@sproutboat/artifact";
@@ -1,144 +1,5 @@
1
- /**
2
- * Make Porffor's generated native-fetch server read its listen port from $PORT
3
- * at runtime (falling back to the compiled `port:` value). Porffor renders the
4
- * server as C text in `compiler/render.js`; we splice two lines into
5
- * `porf_native_fetch_get_port()`.
6
- *
7
- * Done as an idempotent in-place edit rather than a `postinstall` hook: package
8
- * managers block dependency lifecycle scripts by default, so a published
9
- * `postinstall` would silently not run. This is called from the build path
10
- * instead, where it always runs.
11
- *
12
- * Tracked upstream in patches/UPSTREAM.md — once Porffor reads $PORT (or exposes
13
- * env to handlers) this whole file goes away.
14
- */
15
- import { readFile, writeFile } from "node:fs/promises";
16
- import { resolve } from "node:path";
17
- import { porfforRoot } from "./toolchain";
18
-
19
- // Each edit below is independent and idempotent, with its own marker: a file
20
- // patched by an older version of this file must still receive the newer edits.
21
- const ANCHOR = "f64 porf_native_fetch_get_port(void) {\n";
22
-
23
- /** Port from $PORT (the supervisor and `sproutboat dev` set it). */
24
- const ENV_INJECT =
25
- ' const char* __sb_port = getenv("PORT");\n' +
26
- " 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";
27
- const ENV_MARKER = 'getenv("PORT")';
28
-
29
- // #15 — no `--port` flag: Porffor's native-fetch entry point calls
30
- // `porf_init(0, NULL)` (see porf_native_fetch_runtime_init in render.js), so a
31
- // native-fetch binary never sees argv at all. A standalone binary takes its
32
- // port and data directory from the environment instead, which is what systemd
33
- // and docker set anyway. Worth an upstream note alongside the $PORT ask.
34
-
35
- /**
36
- * #15 — let the build add objects to the native-fetch link line.
37
- *
38
- * Porffor builds `linkArgs` as a fixed array, so an embedded backend that needs
39
- * SQLite compiled into the sprout has nowhere to put it. `CXX` is not a way in:
40
- * a musl (deploy) build overrides it outright. This splices one spread of
41
- * `SB_EXTRA_LINK` before `-lm`, inert unless the variable is set.
42
- */
43
- const LINK_ANCHOR = " uSocketsArchive,\n '-lm'\n";
44
- const LINK_INJECT =
45
- " ...(process.env.SB_EXTRA_LINK ? process.env.SB_EXTRA_LINK.split(' ').filter(Boolean) : []),\n";
46
- const LINK_MARKER = "SB_EXTRA_LINK";
47
-
48
- /**
49
- * #15 — and the same for the compile step, so the prelude's inline C can
50
- * `#include <bearssl.h>`. The link patch alone is not enough: Porffor compiles
51
- * the generated C from stdin with a fixed argument list, so there is otherwise
52
- * no way to add an include path.
53
- */
54
- const CFLAGS_ANCHOR = " '-xc', '-', '-c',\n";
55
- const CFLAGS_INJECT =
56
- " ...(process.env.SB_EXTRA_CFLAGS ? process.env.SB_EXTRA_CFLAGS.split(' ').filter(Boolean) : []),\n";
57
- const CFLAGS_MARKER = "SB_EXTRA_CFLAGS";
58
-
59
- /**
60
- * #56 — make the inbound request-body limit configurable.
61
- *
62
- * Porffor's uWebSockets shim hardcodes 1 MiB and answers anything larger with a
63
- * bare `413 request body too large` before the handler runs, so a project can
64
- * neither accept a bigger upload nor say anything useful about the refusal.
65
- *
66
- * The default stays 1 MiB: a larger body is held in memory whole, so raising it
67
- * is a decision about this deployment's memory, not something to inherit.
68
- */
69
- const BODY_ANCHOR = "static const size_t REQUEST_BODY_MAX_BYTES = 1024u * 1024u;";
70
- const BODY_INJECT = `static size_t sb_request_body_max(void) {
71
- static size_t cached = 0;
72
- if (cached == 0) {
73
- const char* raw = getenv("SB_REQUEST_BODY_MAX");
74
- long parsed = raw && *raw ? atol(raw) : 0;
75
- cached = parsed > 0 ? (size_t)parsed : 1024u * 1024u;
76
- }
77
- return cached;
78
- }
79
- #define REQUEST_BODY_MAX_BYTES sb_request_body_max()`;
80
- const BODY_MARKER = "sb_request_body_max";
81
-
82
- let done = false;
83
-
84
- /** The uWebSockets shim source, which is where the body limit lives. */
85
- async function patchBodyLimit(): Promise<void> {
86
- const file = resolve(porfforRoot(), "compiler/uwebsockets.js");
87
- const src = await readFile(file, "utf8");
88
- if (src.includes(BODY_MARKER)) return;
89
- if (!src.includes(BODY_ANCHOR)) {
90
- throw new Error(
91
- `could not patch Porffor's request body limit: anchor not found in ${file}. ` +
92
- "Porffor's uWebSockets shim changed — check patches/UPSTREAM.md.",
93
- );
94
- }
95
- await writeFile(file, src.replace(BODY_ANCHOR, BODY_INJECT));
96
- }
97
-
98
- async function patchCompilerArgs(): Promise<void> {
99
- const file = resolve(porfforRoot(), "compiler/index.js");
100
- let src = await readFile(file, "utf8");
101
- let changed = false;
102
- for (const [marker, anchor, inject, what] of [
103
- [LINK_MARKER, LINK_ANCHOR, LINK_INJECT, "extra link args"],
104
- [CFLAGS_MARKER, CFLAGS_ANCHOR, CFLAGS_INJECT, "extra compiler flags"],
105
- ] as const) {
106
- if (src.includes(marker)) continue;
107
- const at = src.indexOf(anchor);
108
- if (at === -1) {
109
- throw new Error(
110
- `could not patch Porffor for ${what}: anchor not found in ${file}. ` +
111
- "Porffor's native-fetch build changed — check patches/UPSTREAM.md.",
112
- );
113
- }
114
- // After the anchor for cflags (the args follow it), before it for the link
115
- // line (the object list ends with it).
116
- src =
117
- marker === CFLAGS_MARKER
118
- ? src.slice(0, at + anchor.length) + inject + src.slice(at + anchor.length)
119
- : src.slice(0, at) + inject + src.slice(at);
120
- changed = true;
121
- }
122
- if (changed) await writeFile(file, src);
123
- }
124
-
125
- export async function ensurePorfforPatched(): Promise<void> {
126
- if (done) return;
127
- await patchCompilerArgs();
128
- await patchBodyLimit();
129
- const file = resolve(porfforRoot(), "compiler/render.js");
130
- const src = await readFile(file, "utf8");
131
- if (src.includes(ENV_MARKER)) {
132
- done = true;
133
- return;
134
- }
135
- const anchorAt = src.indexOf(ANCHOR);
136
- if (anchorAt === -1) {
137
- throw new Error(
138
- `could not patch Porffor for $PORT: anchor not found in ${file}. ` +
139
- "Porffor's native-fetch renderer changed — check patches/UPSTREAM.md.",
140
- );
141
- }
142
- await writeFile(file, src.slice(0, anchorAt + ANCHOR.length) + ENV_INJECT + src.slice(anchorAt + ANCHOR.length));
143
- done = true;
144
- }
1
+ // Re-exported from @sproutboat/toolchain (moved verbatim). This shim keeps
2
+ // every `./patch-porffor` importer working; the patch passes now live in the
3
+ // shared package (`@sproutboat/toolchain/patch`) so the CLI and the monorepo
4
+ // apply the same set. Mirrors src/broker.ts -> @sproutboat/wire.
5
+ export { ensurePorfforPatched, patchRenderJs, patchUwebsockets } from "@sproutboat/toolchain";
@@ -0,0 +1,5 @@
1
+ // Re-exported from @sproutboat/toolchain (moved verbatim). This shim keeps
2
+ // every `./porffor-toolchain` importer working; the pin, the patches and the
3
+ // fetch/verify/cache logic now live in the shared package so the CLI and the
4
+ // monorepo cannot drift apart. Mirrors src/broker.ts -> @sproutboat/wire.
5
+ export * from "@sproutboat/toolchain";
package/src/report.ts CHANGED
@@ -1,15 +1,11 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { gzipSync } from "bun";
3
2
  import { resourceRefs, type SproutboatConfig } from "./config";
4
3
  import type { ArtifactManifest } from "./manifest";
5
4
  import { bold, dim, leaf, sprout } from "./style";
6
5
 
7
- // Read from package.json so the banner never drifts from the published version.
8
- // SAFETY: our own package.json, shipped beside src/ by the `files` field; npm requires `version`.
9
- const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
10
- version: string;
11
- };
12
- export const CLI_VERSION = packageJson.version;
6
+ // `bun build --compile` has no package-relative filesystem at runtime. The
7
+ // release build injects this value; source development keeps the package value.
8
+ export const CLI_VERSION = process.env.SPROUTBOAT_CLI_VERSION ?? "0.9.0";
13
9
 
14
10
  function bytes(n: number): string {
15
11
  if (n < 1024) return `${n} B`;
@@ -65,6 +61,8 @@ function bindingRows(config: SproutboatConfig): string[][] {
65
61
  list(config.analytics_engine_datasets, "analytics");
66
62
  for (const [name, className] of Object.entries(config.durable_objects ?? {}))
67
63
  rows.push([`env.${name}`, "durable object", className]);
64
+ for (const rl of config.ratelimiters ?? [])
65
+ rows.push([`env.${rl.binding}`, "rate limiter", `${rl.limit} / ${rl.period}s`]);
68
66
  for (const host of config.outbound ?? []) rows.push([`fetch()`, "outbound", host]);
69
67
  for (const cron of config.triggers?.crons ?? []) rows.push([`scheduled()`, "cron", cron]);
70
68
  if (config.assets?.binding) rows.push([`env.${config.assets.binding}`, "assets", config.assets.directory ?? ""]);
package/src/source.ts CHANGED
@@ -1,44 +1,4 @@
1
- import { neutraliseExports } from "./wrap";
2
-
3
- export type SourceValidation = { ok: true } | { ok: false; errors: string[] };
4
-
5
- // Checked against the *bundled* module (#89), not the entry file: after
6
- // bundling there are no imports left to reject, and a dependency reaching for a
7
- // Node API has to fail exactly as hand-written code would. A bare specifier
8
- // that resolves to nothing never gets this far — the bundler fails first.
9
- const alwaysForbidden: Array<[RegExp, string]> = [
10
- [/^\s*import\s/m, "an import survived bundling — only static imports can be resolved at build time"],
11
- [/\bimport\s*\(/, "dynamic import() is not supported: nothing can resolve it at build time"],
12
- [/\brequire\s*\(/, "CommonJS require is not supported"],
13
- [/\b(WebSocket|XMLHttpRequest)\s*\(/, "WebSocket / XMLHttpRequest are not supported"],
14
- [/\b(process|Bun|Deno|Buffer|node:)\b/, "Node, Bun, and Deno APIs are not supported"],
15
- // Porffor alpha-4 compiles `new Proxy(...)` and then ignores the handler: a
16
- // trapped property reads back as `undefined`, with no throw. Rejecting it
17
- // here is the difference between a build error and a 502 nobody can explain.
18
- // It is why itty-router and other Proxy-based routers do not work yet.
19
- [
20
- /\bnew\s+Proxy\s*\(|\bProxy\s*\.\s*revocable\s*\(/,
21
- "Proxy is not supported by the compiler: its traps are silently ignored and the property reads back as undefined",
22
- ],
23
- ];
24
-
25
- const fetchWithoutAllowlist: [RegExp, string] = [
26
- /(?:\breturn\s+|\bawait\s+|=\s*)fetch\s*\(/,
27
- "outbound networking needs an `outbound` host allowlist in sproutboat.jsonc",
28
- ];
29
-
30
- export function validateHttpSyncSource(source: string, outboundAllowed = false): SourceValidation {
31
- const errors: string[] = [];
32
- // The default export must be an object literal with a `fetch` method. A module
33
- // may also declare Durable Object classes / helpers before it, so this is not
34
- // anchored to the start of the file.
35
- // A hand-written file exports inline; a bundled one re-exports at the end.
36
- // `neutraliseExports` is the same reader the compiler uses, so `check` cannot
37
- // accept a module the build would then reject.
38
- if (neutraliseExports(source) === null || !/\bfetch\s*\(/.test(source)) {
39
- errors.push("handler must default-export an object with fetch(request)");
40
- }
41
- for (const [pattern, message] of alwaysForbidden) if (pattern.test(source)) errors.push(message);
42
- if (!outboundAllowed && fetchWithoutAllowlist[0].test(source)) errors.push(fetchWithoutAllowlist[1]);
43
- return errors.length ? { ok: false, errors } : { ok: true };
44
- }
1
+ // Re-exported from @sproutboat/runtime (moved verbatim with wrap.ts).
2
+ // This shim keeps every `./source` importer and the
3
+ // `sproutboat/runtime/source` export path working.
4
+ export * from "@sproutboat/runtime";
package/src/surface.ts CHANGED
@@ -206,6 +206,22 @@ export const ENV_VARS: readonly EnvVar[] = [
206
206
  },
207
207
  { name: "AR", purpose: "Archiver used to assemble uSockets.a for a host build (default `ar`)." },
208
208
  { name: "SPROUTBOAT_COMPILE_TIMEOUT_MS", purpose: "Porffor compile timeout in ms (default 600000)." },
209
+ {
210
+ name: "SPROUTBOAT_TOOLCHAIN_CACHE",
211
+ purpose: "Managed Porffor and compiler cache root (default ~/.cache/sproutboat).",
212
+ },
213
+ {
214
+ name: "SPROUTBOAT_PORFFOR_DIR",
215
+ purpose: "Contributor override for an existing Porffor source checkout. Required files are validated before use.",
216
+ },
217
+ {
218
+ name: "SPROUTBOAT_BUILD_UWS_FROM_SOURCE",
219
+ purpose: "Set to 1 to explicitly allow the contributor-only Git and Make uWebSockets fallback.",
220
+ },
221
+ {
222
+ name: "SPROUTBOAT_CLI_VERSION",
223
+ purpose: "CLI version embedded by the release executable build. Not normally set by users.",
224
+ },
209
225
  {
210
226
  name: "SPROUTBOAT_VARS_JSON",
211
227
  purpose: