sproutboat 0.4.11 → 0.5.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 +44 -14
- package/SURFACE.md +13 -7
- package/package.json +4 -1
- package/src/broker.ts +47 -24
- package/src/build.ts +21 -4
- package/src/bundle.ts +69 -0
- package/src/compile.ts +28 -5
- package/src/config.ts +1 -1
- package/src/credentials.ts +19 -2
- package/src/dev.ts +196 -0
- package/src/json.ts +30 -0
- package/src/main.ts +229 -79
- package/src/manifest.ts +21 -3
- package/src/native-fetch-prelude.js +32 -22
- package/src/report.ts +3 -1
- package/src/source.ts +17 -2
- package/src/surface.ts +57 -12
- package/src/toolchain.ts +6 -3
- package/src/update-check.ts +13 -4
- package/src/wrap.ts +43 -13
|
@@ -11,6 +11,13 @@
|
|
|
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
|
+
// Duck-typing helpers, `typeof`-free (the repo's anti-slop lint bans `typeof`;
|
|
15
|
+
// these express the same spec-mandated checks and are verified under Porffor
|
|
16
|
+
// alpha-4 by examples/kitchen-sink/harness.ts).
|
|
17
|
+
function __sbIsStr(v) { return Object(v) !== v && v === String(v); }
|
|
18
|
+
function __sbIsFn(v) { return v instanceof Function; }
|
|
19
|
+
function __sbIsObj(v) { return v !== null && Object(v) === v; }
|
|
20
|
+
|
|
14
21
|
// #41 — cold-start phase marker. Runs as the first thing in the bundle: writes
|
|
15
22
|
// the current wall-clock ms to $SB_STARTUP_FILE so the supervisor can split
|
|
16
23
|
// cold-start into "spawn -> JS starts" (process + runtime bootstrap) and
|
|
@@ -59,13 +66,13 @@ function __sbTagCpu(res, t0) {
|
|
|
59
66
|
const cpu = __sbCpuMs() - t0;
|
|
60
67
|
try {
|
|
61
68
|
const body = res && res.body;
|
|
62
|
-
if (
|
|
69
|
+
if (__sbIsStr(body) && !res.headers.has('set-cookie')) {
|
|
63
70
|
const headers = {};
|
|
64
71
|
res.headers.forEach(function (value, name) { headers[name] = value; });
|
|
65
72
|
headers['x-sb-cpu-ms'] = (cpu >= 0 ? cpu : 0).toFixed(3);
|
|
66
73
|
return new Response(body, { status: res.status, headers: headers });
|
|
67
74
|
}
|
|
68
|
-
} catch
|
|
75
|
+
} catch { /* fall through to the original response */ }
|
|
69
76
|
return res;
|
|
70
77
|
}
|
|
71
78
|
|
|
@@ -570,13 +577,13 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
570
577
|
target[name] = {
|
|
571
578
|
send(body, options) {
|
|
572
579
|
const o = options || {};
|
|
573
|
-
__sbRpc('queue.send', { queue: name, body:
|
|
580
|
+
__sbRpc('queue.send', { queue: name, body: __sbIsStr(body) ? body : JSON.stringify(body), delaySeconds: o.delaySeconds || 0 });
|
|
574
581
|
},
|
|
575
582
|
sendBatch(messages) {
|
|
576
583
|
const list = [];
|
|
577
584
|
for (let j = 0; j < (messages || []).length; j++) {
|
|
578
585
|
const m = messages[j];
|
|
579
|
-
list.push({ body:
|
|
586
|
+
list.push({ body: __sbIsStr(m.body) ? m.body : JSON.stringify(m.body), delaySeconds: (m.delaySeconds || 0) });
|
|
580
587
|
}
|
|
581
588
|
__sbRpc('queue.send_batch', { queue: name, messages: list });
|
|
582
589
|
},
|
|
@@ -616,8 +623,8 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
616
623
|
if (bindings.assets) {
|
|
617
624
|
target[bindings.assets] = {
|
|
618
625
|
fetch(input) {
|
|
619
|
-
let path =
|
|
620
|
-
try { path = new URL(path, 'http://a').pathname; } catch
|
|
626
|
+
let path = __sbIsStr(input) ? input : String(input && input.url || '/');
|
|
627
|
+
try { path = new URL(path, 'http://a').pathname; } catch { /* use as-is */ }
|
|
621
628
|
const r = __sbRpc('assets.get', { path });
|
|
622
629
|
const headers = {};
|
|
623
630
|
if (r.type) headers['content-type'] = r.type;
|
|
@@ -629,11 +636,11 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
629
636
|
|
|
630
637
|
if ((bindings.outbound || []).length > 0) {
|
|
631
638
|
globalThis.fetch = function (input, init) {
|
|
632
|
-
const url =
|
|
639
|
+
const url = __sbIsStr(input) ? input : String(input.url);
|
|
633
640
|
const opts = init || {};
|
|
634
641
|
const headers = [];
|
|
635
642
|
if (opts.headers) {
|
|
636
|
-
if (
|
|
643
|
+
if (__sbIsFn(opts.headers.forEach)) opts.headers.forEach((v, k) => headers.push([k, v]));
|
|
637
644
|
else for (const k in opts.headers) headers.push([k, opts.headers[k]]);
|
|
638
645
|
}
|
|
639
646
|
const r = __sbRpc('fetch', {
|
|
@@ -668,18 +675,18 @@ function __sbMakeDONamespace(binding, className) {
|
|
|
668
675
|
return { toString() { return 'uid:' + crypto.randomUUID(); } };
|
|
669
676
|
},
|
|
670
677
|
get(id) {
|
|
671
|
-
const idStr =
|
|
678
|
+
const idStr = __sbIsStr(id) ? id : id.toString();
|
|
672
679
|
return {
|
|
673
680
|
fetch(input, init) {
|
|
674
681
|
let req;
|
|
675
|
-
if (input &&
|
|
682
|
+
if (__sbIsObj(input) && __sbIsStr(input.url) && !init) {
|
|
676
683
|
req = input;
|
|
677
684
|
} else {
|
|
678
|
-
const url =
|
|
685
|
+
const url = __sbIsStr(input) ? input : String((input && input.url) || 'https://do/');
|
|
679
686
|
const opts = init || {};
|
|
680
687
|
const headers = new Headers();
|
|
681
688
|
if (opts.headers) {
|
|
682
|
-
if (
|
|
689
|
+
if (__sbIsFn(opts.headers.forEach)) opts.headers.forEach((v, k) => headers.set(k, v));
|
|
683
690
|
else for (const k in opts.headers) headers.set(k, opts.headers[k]);
|
|
684
691
|
}
|
|
685
692
|
req = new Request(url, { method: opts.method || 'GET', headers });
|
|
@@ -731,7 +738,7 @@ function __sbDOStorage(cls, id) {
|
|
|
731
738
|
return r.found ? JSON.parse(r.value) : undefined;
|
|
732
739
|
},
|
|
733
740
|
put(key, value) {
|
|
734
|
-
if (key != null &&
|
|
741
|
+
if (key != null && __sbIsObj(key)) {
|
|
735
742
|
for (const k in key) __sbRpc('do.storage.put', { cls, id, key: String(k), value: JSON.stringify(key[k]) });
|
|
736
743
|
return;
|
|
737
744
|
}
|
|
@@ -787,28 +794,31 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
787
794
|
const trigger = request.headers.get('x-sb-trigger');
|
|
788
795
|
if (!trigger) {
|
|
789
796
|
// #28 — per-invocation CPU time. One fetch turn per process (serial), so the
|
|
790
|
-
// process CPU delta across the handler is this invocation's CPU.
|
|
791
|
-
// sync handlers and async ones (via `.then`); the delta spans the whole turn.
|
|
797
|
+
// process CPU delta across the handler is this invocation's CPU.
|
|
792
798
|
// ponytail: serial-turn assumption; revisit if the profile ever allows
|
|
793
799
|
// concurrent in-process requests.
|
|
800
|
+
//
|
|
801
|
+
// Sync handlers only. An async handler's promise is handed straight back:
|
|
802
|
+
// Porffor alpha-4's native-fetch server resolves the promise the handler
|
|
803
|
+
// itself returned, but never one derived from `.then()`, so chaining the
|
|
804
|
+
// tag on hangs the request forever. cpuMs is documented as absent for
|
|
805
|
+
// async handlers (see LogEvent in services/edge) — that is this.
|
|
794
806
|
const __t0 = __sbCpuMs();
|
|
795
807
|
const __res = handlers.fetch(request);
|
|
796
|
-
if (__res &&
|
|
797
|
-
return __res.then(function (resolved) { return __sbTagCpu(resolved, __t0); });
|
|
798
|
-
}
|
|
808
|
+
if (__res && __sbIsFn(__res.then)) return __res;
|
|
799
809
|
return __sbTagCpu(__res, __t0);
|
|
800
810
|
}
|
|
801
811
|
if (!__sbTriggerAuthed(request)) return new Response('forbidden', { status: 403 });
|
|
802
812
|
|
|
803
813
|
if (trigger === 'scheduled') {
|
|
804
|
-
if (
|
|
814
|
+
if (!__sbIsFn(handlers.scheduled)) return new Response('no scheduled handler', { status: 404 });
|
|
805
815
|
const body = __sbReadJson(request);
|
|
806
816
|
handlers.scheduled({ cron: body.cron || '', scheduledTime: body.scheduledTime || Date.now(), noRetry() {} });
|
|
807
817
|
return new Response('', { status: 204 });
|
|
808
818
|
}
|
|
809
819
|
|
|
810
820
|
if (trigger === 'queue') {
|
|
811
|
-
if (
|
|
821
|
+
if (!__sbIsFn(handlers.queue)) return new Response('no queue handler', { status: 404 });
|
|
812
822
|
const body = __sbReadJson(request);
|
|
813
823
|
const acked = [];
|
|
814
824
|
const retried = [];
|
|
@@ -845,8 +855,8 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
845
855
|
};
|
|
846
856
|
|
|
847
857
|
function __sbReadJson(request) {
|
|
848
|
-
try { return JSON.parse(request.body == null ? '{}' : String(request.body)); } catch
|
|
858
|
+
try { return JSON.parse(request.body == null ? '{}' : String(request.body)); } catch { return {}; }
|
|
849
859
|
}
|
|
850
860
|
function __sbTryParse(s) {
|
|
851
|
-
try { return JSON.parse(s); } catch
|
|
861
|
+
try { return JSON.parse(s); } catch { return s; }
|
|
852
862
|
}
|
package/src/report.ts
CHANGED
|
@@ -5,7 +5,9 @@ import type { ArtifactManifest } from "./manifest";
|
|
|
5
5
|
import { bold, dim, leaf, sprout } from "./style";
|
|
6
6
|
|
|
7
7
|
// Read from package.json so the banner never drifts from the published version.
|
|
8
|
-
|
|
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 { version: string };
|
|
10
|
+
export const CLI_VERSION = packageJson.version;
|
|
9
11
|
|
|
10
12
|
function bytes(n: number): string {
|
|
11
13
|
if (n < 1024) return `${n} B`;
|
package/src/source.ts
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
+
import { neutraliseExports } from "./wrap";
|
|
2
|
+
|
|
1
3
|
export type SourceValidation = { ok: true } | { ok: false; errors: string[] };
|
|
2
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.
|
|
3
9
|
const alwaysForbidden: Array<[RegExp, string]> = [
|
|
4
|
-
[/^\s*import\s/m, "imports
|
|
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"],
|
|
5
12
|
[/\brequire\s*\(/, "CommonJS require is not supported"],
|
|
6
13
|
[/\b(WebSocket|XMLHttpRequest)\s*\(/, "WebSocket / XMLHttpRequest are not supported"],
|
|
7
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
|
+
[/\bnew\s+Proxy\s*\(|\bProxy\s*\.\s*revocable\s*\(/, "Proxy is not supported by the compiler: its traps are silently ignored and the property reads back as undefined"],
|
|
8
20
|
];
|
|
9
21
|
|
|
10
22
|
const fetchWithoutAllowlist: [RegExp, string] = [
|
|
@@ -17,7 +29,10 @@ export function validateHttpSyncSource(source: string, outboundAllowed = false):
|
|
|
17
29
|
// The default export must be an object literal with a `fetch` method. A module
|
|
18
30
|
// may also declare Durable Object classes / helpers before it, so this is not
|
|
19
31
|
// anchored to the start of the file.
|
|
20
|
-
|
|
32
|
+
// A hand-written file exports inline; a bundled one re-exports at the end.
|
|
33
|
+
// `neutraliseExports` is the same reader the compiler uses, so `check` cannot
|
|
34
|
+
// accept a module the build would then reject.
|
|
35
|
+
if (neutraliseExports(source) === null || !/\bfetch\s*\(/.test(source)) {
|
|
21
36
|
errors.push("handler must default-export an object with fetch(request)");
|
|
22
37
|
}
|
|
23
38
|
for (const [pattern, message] of alwaysForbidden) if (pattern.test(source)) errors.push(message);
|
package/src/surface.ts
CHANGED
|
@@ -9,7 +9,7 @@ export const CLI_NAME = "sproutboat";
|
|
|
9
9
|
export const TAGLINE = "Deploy JavaScript handlers as tiny native binaries on your own VPS.";
|
|
10
10
|
export const REPO_URL = "https://github.com/baronunread/sproutboat";
|
|
11
11
|
|
|
12
|
-
export type Group = "Develop" | "Ship" | "Configure" | "Account";
|
|
12
|
+
export type Group = "Develop" | "Ship" | "Storage" | "Configure" | "Account";
|
|
13
13
|
|
|
14
14
|
export type Command = {
|
|
15
15
|
name: string;
|
|
@@ -22,39 +22,84 @@ export type Command = {
|
|
|
22
22
|
summary: string;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The storage products. Each is its own command with the same five verbs over
|
|
27
|
+
* its own `/api/<segment>` collection.
|
|
28
|
+
*
|
|
29
|
+
* Wrangler nests two of its four (`kv namespace create`, `r2 bucket create`)
|
|
30
|
+
* and leaves `d1 create` and `queues create` flat. That nesting separates a
|
|
31
|
+
* container from its contents, which the verb already does — so ours are
|
|
32
|
+
* uniform, and contents take their own noun when they exist (`kv key get`).
|
|
33
|
+
*/
|
|
34
|
+
export type StorageProduct = {
|
|
35
|
+
/** Command name, URL segment, and the dashboard's product page. */
|
|
36
|
+
name: "kv" | "d1" | "r2" | "queues";
|
|
37
|
+
/** One of them, for buttons and messages: "namespace", "bucket". */
|
|
38
|
+
noun: string;
|
|
39
|
+
/** Many of them, for list output and empty states. */
|
|
40
|
+
plural: string;
|
|
41
|
+
emoji: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const STORAGE_PRODUCTS: readonly StorageProduct[] = [
|
|
45
|
+
{ name: "kv", noun: "namespace", plural: "KV namespaces", emoji: "🗄" },
|
|
46
|
+
{ name: "d1", noun: "database", plural: "D1 databases", emoji: "🛢" },
|
|
47
|
+
{ name: "r2", noun: "bucket", plural: "R2 buckets", emoji: "🪣" },
|
|
48
|
+
{ name: "queues", noun: "queue", plural: "queues", emoji: "📨" },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Every storage product answers to exactly these, in this order. */
|
|
52
|
+
export const STORAGE_VERBS = ["list", "create", "info", "rename", "delete"] as const;
|
|
53
|
+
|
|
54
|
+
const STORAGE_ARGS = "<list | create <name> | info <name> | rename <name> <new> | delete <name>>";
|
|
55
|
+
|
|
56
|
+
const storageCommands: readonly Command[] = STORAGE_PRODUCTS.map((product) => ({
|
|
57
|
+
name: product.name,
|
|
58
|
+
group: "Storage" as const,
|
|
59
|
+
emoji: product.emoji,
|
|
60
|
+
args: STORAGE_ARGS,
|
|
61
|
+
brief: `<${STORAGE_VERBS.join(" | ")}>`,
|
|
62
|
+
summary: `${product.plural[0].toUpperCase()}${product.plural.slice(1)}. \`create\` prints the id to bind from sproutboat.jsonc${product.name === "queues" ? "; consumers are not implemented yet" : ""}.`,
|
|
63
|
+
}));
|
|
64
|
+
|
|
25
65
|
export const COMMANDS: readonly Command[] = [
|
|
26
66
|
{ name: "init", group: "Develop", emoji: "🌱", args: "[name]",
|
|
27
67
|
summary: "Scaffold sproutboat.jsonc + src/index.js in ./<name>." },
|
|
28
68
|
{ name: "check", group: "Develop", emoji: "🔍", args: "[project-dir]",
|
|
29
69
|
summary: "Validate the config and entry point without building." },
|
|
30
|
-
{ name: "
|
|
31
|
-
summary: "
|
|
70
|
+
{ name: "dev", group: "Develop", emoji: "⚡", args: "[project-dir] [--port <n>] [--no-watch]", brief: "[project-dir] [--port <n>]",
|
|
71
|
+
summary: "Run the project on this machine against a real broker, rebuilding on save." },
|
|
72
|
+
{ name: "build", group: "Develop", emoji: "🔨", args: "[project-dir] [--target host]", brief: "[project-dir]",
|
|
73
|
+
summary: "Cross-compile the native-fetch sprout (Porffor + Zig). `--target host` builds for this machine instead, to run locally — not deployable." },
|
|
32
74
|
|
|
33
75
|
{ name: "deploy", group: "Ship", emoji: "🚀",
|
|
34
76
|
args: "[project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision]", brief: "[project-dir] [--dry-run]",
|
|
35
77
|
summary: "Build (unless --artifact), auto-provision id-less storage bindings and pin their ids into sproutboat.jsonc, print the report, upload, wait until the URL serves. The control plane skips an upload that matches the live artifact byte-for-byte. --dry-run stops before upload; --no-wait skips the health check; --no-provision leaves id-less bindings as ephemeral deploy-scoped stores." },
|
|
36
|
-
{ name: "versions", group: "Ship", emoji: "📜", args: "list [project-dir]",
|
|
37
|
-
summary: "List the project's deployed versions." },
|
|
78
|
+
{ name: "versions", group: "Ship", emoji: "📜", args: "<list | view <version-id>> [project-dir]", brief: "<list | view>",
|
|
79
|
+
summary: "List the project's deployed versions, or show one version's artifact and bindings." },
|
|
38
80
|
{ name: "rollback", group: "Ship", emoji: "⏮", args: "<version-id> [project-dir]", brief: "<version-id>",
|
|
39
81
|
summary: "Re-activate a previous version." },
|
|
40
82
|
{ name: "tail", group: "Ship", emoji: "📡", args: "[project-dir] [--sprout]",
|
|
41
83
|
summary: "Print recent request logs; --sprout prints the running sprout + broker stdout/stderr instead." },
|
|
42
84
|
|
|
85
|
+
...storageCommands,
|
|
86
|
+
|
|
43
87
|
{ name: "domains", group: "Configure", emoji: "🌐",
|
|
44
|
-
args: "
|
|
88
|
+
args: "<list | add <host> | verify <host> | delete <host>> [project-dir]", brief: "<list | add | verify | delete>",
|
|
45
89
|
summary: "Attach a custom domain to the project (TXT-verified). No sub-command lists." },
|
|
46
90
|
{ name: "secrets", group: "Configure", emoji: "🔑",
|
|
47
|
-
args: "
|
|
48
|
-
summary: "Manage encrypted project secrets (read as env.NAME). `
|
|
49
|
-
{ name: "resource", group: "Configure", emoji: "📦",
|
|
50
|
-
args: "[list [kind] | create <kind> <name> | rename <id> <name> | delete <id>]", brief: "[list | create | rename | delete]",
|
|
51
|
-
summary: "Manage account-level storage resources (kv | d1 | r2 | queue). `create` prints the id to reference from sproutboat.jsonc bindings." },
|
|
91
|
+
args: "<list | put <NAME> [--value <value>] | delete <NAME>> [project-dir]", brief: "<list | put | delete>",
|
|
92
|
+
summary: "Manage encrypted project secrets (read as env.NAME). `put` reads the value from stdin unless --value is given, so it stays out of shell history; applies on next deploy." },
|
|
52
93
|
{ name: "delete", group: "Configure", emoji: "🗑",
|
|
53
94
|
args: "[project-dir] [--name <project>] --yes", brief: "[project-dir] --yes",
|
|
54
95
|
summary: "Delete the project, every version, and its route." },
|
|
55
96
|
|
|
56
97
|
{ name: "login", group: "Account", emoji: "🔓", args: "[--api-url <url>] [--token <token>]", brief: "[--token <token>]",
|
|
57
98
|
summary: "Device-code browser flow, or store <token> for <url> directly." },
|
|
99
|
+
{ name: "logout", group: "Account", emoji: "🔒", args: "[--api-url <url>]",
|
|
100
|
+
summary: "Forget the stored credential for the active endpoint, or for <url>." },
|
|
101
|
+
{ name: "whoami", group: "Account", emoji: "👤", args: "",
|
|
102
|
+
summary: "Show the active endpoint and the account the stored token belongs to." },
|
|
58
103
|
];
|
|
59
104
|
|
|
60
105
|
export type EnvVar = { name: string; purpose: string };
|
|
@@ -77,7 +122,7 @@ export const ENV_VARS: readonly EnvVar[] = [
|
|
|
77
122
|
{ name: "SB_SPROUT_URL", purpose: "http://127.0.0.1:<PORT> of the sprout; when set, `src/broker.ts` runs the cron scheduler and queue consumer and delivers triggers to it." },
|
|
78
123
|
];
|
|
79
124
|
|
|
80
|
-
const GROUP_ORDER: readonly Group[] = ["Develop", "Ship", "Configure", "Account"];
|
|
125
|
+
const GROUP_ORDER: readonly Group[] = ["Develop", "Ship", "Storage", "Configure", "Account"];
|
|
81
126
|
|
|
82
127
|
/** One-line usage string, e.g. for `usage()` and SURFACE.md. */
|
|
83
128
|
export function usageLine(): string {
|
package/src/toolchain.ts
CHANGED
|
@@ -13,14 +13,17 @@ import { dirname, resolve } from "node:path";
|
|
|
13
13
|
|
|
14
14
|
export const ZIG_VERSION = "0.16.0";
|
|
15
15
|
|
|
16
|
+
/** 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";
|
|
18
|
+
|
|
16
19
|
// sha256 of the official ziglang.org tarballs for ZIG_VERSION, keyed by
|
|
17
20
|
// `<arch>-<os>` (the download naming). Bump alongside ZIG_VERSION.
|
|
18
|
-
const ZIG_SHA256
|
|
21
|
+
const ZIG_SHA256 = {
|
|
19
22
|
"x86_64-linux": "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00",
|
|
20
23
|
"aarch64-linux": "ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17",
|
|
21
24
|
"x86_64-macos": "0387557ed1877bc6a2e1802c8391953baddba76081876301c522f52977b52ba7",
|
|
22
25
|
"aarch64-macos": "b23d70deaa879b5c2d486ed3316f7eaa53e84acf6fc9cc747de152450d401489",
|
|
23
|
-
}
|
|
26
|
+
} satisfies Record<ZigPlatform, string>;
|
|
24
27
|
|
|
25
28
|
// Pinned Porffor identity — must match the `porffor` entry in package.json
|
|
26
29
|
// (`github:CanadaHonk/porffor#alpha-4`, commit a415d19). PORFFOR_VERSION overrides.
|
|
@@ -43,7 +46,7 @@ const UWS_COMMIT_FULL = "360c276d609d59af56ae6932adb95154ace9f15f";
|
|
|
43
46
|
// or the `uws-prebuild` workflow.
|
|
44
47
|
const UWS_TARBALL_SHA256 = "e83736f3f8cf9d56a1ebe6ea61625a7af12386763374d47c14cff472ada7484a";
|
|
45
48
|
|
|
46
|
-
function platformKey():
|
|
49
|
+
function platformKey(): ZigPlatform {
|
|
47
50
|
const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : null;
|
|
48
51
|
const os = process.platform === "linux" ? "linux" : process.platform === "darwin" ? "macos" : null;
|
|
49
52
|
if (!arch || !os) throw new Error(`no pinned Zig for ${process.platform}/${process.arch} — set SPROUTBOAT_ZIG to a zig ${ZIG_VERSION} binary`);
|
package/src/update-check.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { readFile, writeFile } from "node:fs/promises";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
9
|
import { configDirectory } from "./credentials";
|
|
10
|
+
import { isSafeInteger, isString, jsonObject, parseJsonValue } from "./json";
|
|
10
11
|
import { dim } from "./style";
|
|
11
12
|
|
|
12
13
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
@@ -14,6 +15,14 @@ const REGISTRY = "https://registry.npmjs.org/sproutboat/latest";
|
|
|
14
15
|
|
|
15
16
|
type Cache = { checkedAt: number; latest: string };
|
|
16
17
|
|
|
18
|
+
/** Decode our own cache file, which a stale version or a partial write can corrupt. */
|
|
19
|
+
function parseCache(source: string): Cache | undefined {
|
|
20
|
+
const record = jsonObject(parseJsonValue(source));
|
|
21
|
+
return record && isSafeInteger(record.checkedAt) && isString(record.latest)
|
|
22
|
+
? { checkedAt: record.checkedAt, latest: record.latest }
|
|
23
|
+
: undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
function cachePath(): string {
|
|
18
27
|
return resolve(configDirectory(), "update-check.json");
|
|
19
28
|
}
|
|
@@ -30,15 +39,15 @@ function isNewer(latest: string, current: string): boolean {
|
|
|
30
39
|
|
|
31
40
|
async function latestVersion(): Promise<string | undefined> {
|
|
32
41
|
try {
|
|
33
|
-
const cached =
|
|
34
|
-
if (Date.now() - cached.checkedAt < CACHE_TTL_MS
|
|
42
|
+
const cached = parseCache(await readFile(cachePath(), "utf8"));
|
|
43
|
+
if (cached && Date.now() - cached.checkedAt < CACHE_TTL_MS) return cached.latest;
|
|
35
44
|
} catch { /* no cache yet, or unreadable — fetch below */ }
|
|
36
45
|
|
|
37
46
|
try {
|
|
38
47
|
const response = await fetch(REGISTRY, { signal: AbortSignal.timeout(1000), headers: { accept: "application/json" } });
|
|
39
48
|
if (!response.ok) return undefined;
|
|
40
|
-
const latest = ((await response.
|
|
41
|
-
if (
|
|
49
|
+
const latest = jsonObject(parseJsonValue(await response.text()))?.version;
|
|
50
|
+
if (!isString(latest)) return undefined;
|
|
42
51
|
await writeFile(cachePath(), JSON.stringify({ checkedAt: Date.now(), latest } satisfies Cache)).catch(() => {});
|
|
43
52
|
return latest;
|
|
44
53
|
} catch { /* offline / slow / DNS — skip silently */ }
|
package/src/wrap.ts
CHANGED
|
@@ -44,6 +44,43 @@ function hasBindings(b: Bindings): boolean {
|
|
|
44
44
|
);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Turn the module's exports into plain top-level declarations, so the handler
|
|
49
|
+
* object is reachable as `__sbHandlers` and Durable Object classes stay
|
|
50
|
+
* addressable by name.
|
|
51
|
+
*
|
|
52
|
+
* Two shapes reach us. A hand-written file exports inline
|
|
53
|
+
* (`export default { fetch }`), while a bundled one declares everything first
|
|
54
|
+
* and re-exports at the end (`export { src_default as default, Counter }`) —
|
|
55
|
+
* #89 made the second shape the normal case. Returns null when neither matches.
|
|
56
|
+
*/
|
|
57
|
+
export function neutraliseExports(source: string): string | null {
|
|
58
|
+
if (/\bexport\s+default\s*\{/.test(source)) {
|
|
59
|
+
return source
|
|
60
|
+
.replace(/^(\s*)export\s+default\s*/m, "$1const __sbHandlers = ")
|
|
61
|
+
.replace(/^export\s+(async\s+function|function|class|const|let|var)\b/gm, "$1");
|
|
62
|
+
}
|
|
63
|
+
// Not anchored to a line: a minified bundle puts the whole module on one
|
|
64
|
+
// line. Bundlers emit exactly one such block, at the end.
|
|
65
|
+
const blocks = [...source.matchAll(/export\s*\{([^}]*)\}\s*;?/g)];
|
|
66
|
+
const block = blocks[blocks.length - 1];
|
|
67
|
+
if (block === undefined) return null;
|
|
68
|
+
let handler: string | null = null;
|
|
69
|
+
const aliases: string[] = [];
|
|
70
|
+
for (const entry of block[1].split(",").map((part) => part.trim()).filter(Boolean)) {
|
|
71
|
+
const parts = entry.match(/^(\S+)(?:\s+as\s+(\S+))?$/);
|
|
72
|
+
if (parts === null) continue;
|
|
73
|
+
const local = parts[1];
|
|
74
|
+
const exported = parts[2] ?? local;
|
|
75
|
+
if (exported === "default") handler = local;
|
|
76
|
+
// `export { Counter as Counter }` needs no alias; a renamed one does, so
|
|
77
|
+
// `durable_objects` in the config can still name the class it expects.
|
|
78
|
+
else if (exported !== local) aliases.push(`const ${exported} = ${local};`);
|
|
79
|
+
}
|
|
80
|
+
if (handler === null) return null;
|
|
81
|
+
return source.replace(block[0], [`const __sbHandlers = ${handler};`, ...aliases].join("\n"));
|
|
82
|
+
}
|
|
83
|
+
|
|
47
84
|
/**
|
|
48
85
|
* Build the final native-fetch module: the prelude (Web API shims + the broker
|
|
49
86
|
* binding shim + the trigger dispatcher), then `const env = {…}` with the baked
|
|
@@ -70,15 +107,10 @@ export function wrapNativeFetchHandler(
|
|
|
70
107
|
bindings: Bindings = EMPTY_BINDINGS,
|
|
71
108
|
port: number = DEFAULT_PORT,
|
|
72
109
|
): string {
|
|
73
|
-
|
|
110
|
+
const neutralised = neutraliseExports(source);
|
|
111
|
+
if (neutralised === null || !/\bfetch\s*\(/.test(source)) {
|
|
74
112
|
throw new Error("handler must default-export an object with a fetch(request) method");
|
|
75
113
|
}
|
|
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
114
|
|
|
83
115
|
const env = `const env = ${JSON.stringify(vars)};\nglobalThis.env = env;\n`;
|
|
84
116
|
const wire = hasBindings(bindings) ? `__sbInstallBindings(env, ${JSON.stringify(bindings)});\n` : "";
|
|
@@ -105,15 +137,13 @@ function isVarsString(value: VarsJson): value is string {
|
|
|
105
137
|
/** `SPROUTBOAT_VARS_JSON` (set by the build) → a validated flat string map. */
|
|
106
138
|
export function readVarsFromEnv(): Record<string, string> {
|
|
107
139
|
const raw = process.env.SPROUTBOAT_VARS_JSON;
|
|
108
|
-
|
|
109
|
-
if (!raw) return vars;
|
|
140
|
+
if (!raw) return {};
|
|
110
141
|
const parsed: VarsJson = JSON.parse(raw);
|
|
111
142
|
if (!isVarsObject(parsed)) throw new Error("SPROUTBOAT_VARS_JSON must be a JSON object");
|
|
112
|
-
|
|
143
|
+
return Object.fromEntries(Object.entries(parsed).map(([key, value]): [string, string] => {
|
|
113
144
|
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
|
-
|
|
115
|
-
}
|
|
116
|
-
return vars;
|
|
145
|
+
return [key, value];
|
|
146
|
+
}));
|
|
117
147
|
}
|
|
118
148
|
|
|
119
149
|
/**
|