astroidjs 0.15.0 → 0.16.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 CHANGED
@@ -339,11 +339,13 @@ Astro app—in one step.
339
339
 
340
340
  ## Roadmap
341
341
 
342
- 1. ✅ **Config surface** (`defineAstroid`)—single brand per project.
343
- 2. ✅ Config → generated Drizzle schema.
344
- 3. ✅ Config → generated `worker.ts` + middleware (no hand-wired route ordering).
345
- 4. ✅ `<Section>` / `<Editable>` / `<Collection>` component primitives.
346
- 5. ✅ **CLI**—`astroid generate / doctor / dev / build / deploy`; `create-astroid`
342
+ Every item on the first roadmap has shipped:
343
+
344
+ 1. **Config surface** (`defineAstroid`)—single brand per project.
345
+ 2. Config → generated Drizzle schema.
346
+ 3. Config → generated `worker.ts` + middleware (no hand-wired route ordering).
347
+ 4. `<Section>` / `<Editable>` / `<Collection>` component primitives.
348
+ 5. **CLI**—`astroid generate / doctor / dev / build / deploy`; `create-astroid`
347
349
  scaffold (`pnpm create astroid`).
348
350
 
349
351
  ## License
package/bin/astroid.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  // astroid dev [...astro args] generate, then `astro dev`
9
9
  // astroid build [...astro args] generate, then `astro build`
10
10
  // astroid deploy [--dry-run] [--yes] [--local] provision + migrate + secrets + deploy
11
+ // astroid ship production | preview migrate, then deploy or preview (Workers Builds)
11
12
  //
12
13
  // It loads the project's `astroid.config.ts` with Node's native TypeScript
13
14
  // stripping (the config only imports the built `astroidjs`, so it resolves), and
@@ -23,6 +24,16 @@ import { pathToFileURL } from "node:url";
23
24
 
24
25
  const GENERATORS_URL = new URL("../dist/index.js", import.meta.url).href;
25
26
 
27
+ /** The repository root, where `.github/` lives, or null outside a git checkout.
28
+ * A site's Astroid project can sit below it (`workers/site`). */
29
+ function gitRoot(cwd) {
30
+ try {
31
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" }).trim();
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
26
37
  // --- tiny arg parser -------------------------------------------------------
27
38
  // Splits at the first non-flag token into { command, flags, rest }. `rest` is
28
39
  // everything after the command, preserved verbatim so `dev`/`build` can forward
@@ -77,7 +88,12 @@ async function loadConfig(cwd, explicit) {
77
88
 
78
89
  // --- commands --------------------------------------------------------------
79
90
  async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
80
- const { generateAstroidProject, generateAstroidScaffoldFiles } = await import(GENERATORS_URL);
91
+ const {
92
+ generateAstroidProject,
93
+ generateAstroidScaffoldFiles,
94
+ generateAstroidReleaseWorkflow,
95
+ ASTROID_RELEASE_WORKFLOW_PATH,
96
+ } = await import(GENERATORS_URL);
81
97
  const { config } = await loadConfig(cwd, flags.config);
82
98
  const files = generateAstroidProject(config);
83
99
  for (const file of files) {
@@ -87,6 +103,16 @@ async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
87
103
  if (!quiet) out(` ✓ ${file.path}`);
88
104
  }
89
105
 
106
+ // The release workflow, at the repository root rather than the project,
107
+ // because GitHub reads workflows only from there. Regenerated like the trio.
108
+ const root = gitRoot(cwd);
109
+ if (root) {
110
+ const abs = join(root, ASTROID_RELEASE_WORKFLOW_PATH);
111
+ mkdirSync(dirname(abs), { recursive: true });
112
+ writeFileSync(abs, generateAstroidReleaseWorkflow());
113
+ if (!quiet) out(` ✓ ${ASTROID_RELEASE_WORKFLOW_PATH} (repository root)`);
114
+ }
115
+
90
116
  // Scaffold-once files for whatever modules the config switched on.
91
117
  //
92
118
  // Written only when ABSENT—each is a seam the project owns, so overwriting
@@ -124,8 +150,15 @@ async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
124
150
  }
125
151
 
126
152
  async function cmdDoctor(cwd, flags) {
127
- const { generateAstroidProject, generateAstroidScaffoldFiles, astroidUsesQueues, astroidCrons } =
128
- await import(GENERATORS_URL);
153
+ const {
154
+ generateAstroidProject,
155
+ generateAstroidScaffoldFiles,
156
+ astroidUsesQueues,
157
+ astroidCrons,
158
+ checkWranglerPreviews,
159
+ generateAstroidReleaseWorkflow,
160
+ ASTROID_RELEASE_WORKFLOW_PATH,
161
+ } = await import(GENERATORS_URL);
129
162
  const { config, path: configPath } = await loadConfig(cwd, flags.config);
130
163
 
131
164
  const problems = []; // { level: "error" | "warn", msg }
@@ -264,6 +297,31 @@ async function cmdDoctor(cwd, flags) {
264
297
  }
265
298
  }
266
299
 
300
+ // 1c. The release workflow at the repository root: a tag on `main` moves
301
+ // `deploy/production`, which Workers Builds deploys. Stale is an error for
302
+ // the same reason the trio's is: it decides which commits reach production.
303
+ const root = gitRoot(cwd);
304
+ if (!root) {
305
+ warn(`not in a git checkout, so ${ASTROID_RELEASE_WORKFLOW_PATH} can't be checked.`);
306
+ } else {
307
+ const abs = join(root, ASTROID_RELEASE_WORKFLOW_PATH);
308
+ if (!existsSync(abs))
309
+ err(`${ASTROID_RELEASE_WORKFLOW_PATH} is missing — run \`astroid generate\`.`);
310
+ else if (readFileSync(abs, "utf8") !== generateAstroidReleaseWorkflow())
311
+ err(`${ASTROID_RELEASE_WORKFLOW_PATH} is stale — run \`astroid generate\`.`);
312
+ else ok(`${ASTROID_RELEASE_WORKFLOW_PATH} is up to date`);
313
+ }
314
+
315
+ // 2b. Staging: the `previews` block (louise-toolkit ADR 0017). A Preview
316
+ // inherits nothing, so a binding left out crashes it and one copied from
317
+ // production writes production data; see src/project/previews.ts.
318
+ if (existsSync(wranglerPath)) {
319
+ const previews = checkWranglerPreviews(readFileSync(wranglerPath, "utf8"));
320
+ for (const m of previews.ok) ok(m);
321
+ for (const m of previews.warnings) warn(m);
322
+ for (const m of previews.errors) err(m);
323
+ }
324
+
267
325
  // 3. migrations directory: the D1 `migrations_dir` wrangler.jsonc declares,
268
326
  // or `migrations/`, the generated default, when it names none. A site that
269
327
  // keeps its migrations under another name (drizzle/) isn't missing them.
@@ -403,7 +461,10 @@ function provisionPlan(facts) {
403
461
  }
404
462
  for (const { binding, id } of facts.kv) {
405
463
  if (isPlaceholder(id)) {
406
- steps.push({ kind: "kv", name: binding, args: ["kv", "namespace", "create", binding] });
464
+ // The placeholder names the namespace (`<run: wrangler kv namespace
465
+ // create acme-rl>`); an older one without a name falls back to the binding.
466
+ const title = id.match(/kv namespace create ([^\s>]+)/)?.[1] ?? binding;
467
+ steps.push({ kind: "kv", name: title, binding, args: ["kv", "namespace", "create", title] });
407
468
  }
408
469
  }
409
470
  // Queues carry no id, so there's no placeholder to test—creating one that
@@ -531,7 +592,7 @@ async function cmdDeploy(cwd, flags, rest) {
531
592
  (rows) => rows.find((r) => typeof r.title === "string" && r.title.endsWith(s.name))?.id,
532
593
  );
533
594
  if (id) {
534
- wrangler = patchKvId(wrangler, s.name, id);
595
+ wrangler = patchKvId(wrangler, s.binding, id);
535
596
  writeFileSync(wranglerPath, wrangler);
536
597
  out(` ↳ ${s.name} id = ${id}`);
537
598
  } else {
@@ -597,10 +658,172 @@ Usage:
597
658
  astroid dev [...astro args] regenerate, then run \`astro dev\`
598
659
  astroid build [...astro args] regenerate, then run \`astro build\`
599
660
  astroid deploy [--dry-run] [--yes] [--local] provision bindings + migrate + secrets + deploy
661
+ astroid ship production | preview migrate D1, then deploy or preview (Workers Builds runs this)
662
+ astroid provision [--dry-run] [--yes] create the resources wrangler.jsonc names by placeholder, staging included
600
663
 
601
664
  New project: pnpm create astroid@latest
602
665
  `;
603
666
 
667
+ // `astroid provision` creates what `wrangler.jsonc` still names by placeholder,
668
+ // top level and `previews` alike, and writes each new ID back in place of its
669
+ // placeholder. It never deploys, so it's safe to run before a site's first
670
+ // release, and re-running it only creates what's still missing. Secrets and
671
+ // dashboard settings need a person, so it prints those instead.
672
+ async function cmdProvision(cwd, rest) {
673
+ const dryRun = rest.includes("--dry-run");
674
+ const assumeYes = rest.includes("--yes") || rest.includes("-y");
675
+ const wranglerPath = join(cwd, "wrangler.jsonc");
676
+ if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
677
+
678
+ const { provisionPlan, applyProvisionedId } = await import(GENERATORS_URL);
679
+ let text = readFileSync(wranglerPath, "utf8");
680
+ const plan = provisionPlan(text);
681
+
682
+ out("astroid provision — plan:\n");
683
+ if (plan.steps.length === 0) out(" (nothing to create: every binding has an ID)");
684
+ for (const s of plan.steps) {
685
+ const note = s.kind === "r2" ? " (an existing bucket is fine)" : "";
686
+ out(` wrangler ${s.args.join(" ")}${note}`);
687
+ }
688
+ if (!plan.hasAccount && !process.env.CLOUDFLARE_ACCOUNT_ID) {
689
+ out(
690
+ "\n ! No account_id in wrangler.jsonc and no CLOUDFLARE_ACCOUNT_ID, so wrangler picks one.",
691
+ );
692
+ }
693
+ const printSecrets = () => {
694
+ if (plan.secrets.length === 0) return;
695
+ out("\nSecrets Store secrets it binds; create any that don't exist yet:");
696
+ for (const s of plan.secrets) {
697
+ out(
698
+ ` [${s.environment}] wrangler secrets-store secret create ${s.storeId} ` +
699
+ `--name ${s.secretName} --scopes workers --remote`,
700
+ );
701
+ }
702
+ };
703
+
704
+ if (dryRun) {
705
+ printSecrets();
706
+ out("\n(dry run — nothing created)");
707
+ return;
708
+ }
709
+ if (plan.steps.length > 0 && !assumeYes) {
710
+ if (!process.stdin.isTTY)
711
+ fail("Refusing to create resources non-interactively. Re-run with --yes.");
712
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
713
+ const answer = (await rl.question("\nCreate the above? [y/N] ")).trim().toLowerCase();
714
+ rl.close();
715
+ if (answer !== "y" && answer !== "yes") {
716
+ out("Aborted.");
717
+ return;
718
+ }
719
+ }
720
+
721
+ const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
722
+ if (!wranglerBin) fail("Could not find `wrangler` in this project.");
723
+ for (const s of plan.steps) {
724
+ out(`\n▸ wrangler ${s.args.join(" ")}`);
725
+ // A create that fails because the resource exists is fine: the lookup
726
+ // below finds it, so a re-run after a partial one picks up where it was.
727
+ spawnSync(process.execPath, [wranglerBin, ...s.args], { cwd, stdio: "inherit" });
728
+ if (s.kind === "r2") continue;
729
+ const id =
730
+ s.kind === "d1"
731
+ ? lookupId(
732
+ wranglerBin,
733
+ cwd,
734
+ ["d1", "list", "--json"],
735
+ (rows) => rows.find((r) => r.name === s.name)?.uuid,
736
+ )
737
+ : lookupId(
738
+ wranglerBin,
739
+ cwd,
740
+ ["kv", "namespace", "list"],
741
+ (rows) => rows.find((r) => r.title === s.name)?.id,
742
+ );
743
+ if (!id) fail(`Couldn't find the ID of ${s.name} after creating it. Fill it in by hand.`);
744
+ text = applyProvisionedId(text, s.placeholder, id);
745
+ writeFileSync(wranglerPath, text);
746
+ out(` ↳ ${s.name} = ${id}`);
747
+ }
748
+ printSecrets();
749
+ out("\n✓ Provisioned. Commit wrangler.jsonc; the dashboard steps are in the site's RUNBOOK.");
750
+ }
751
+
752
+ // `astroid ship` is what Workers Builds runs, so the deploy logic lives in the
753
+ // repository instead of a dashboard field. An account move once rewrote a
754
+ // site's dashboard deploy command to a bare `wrangler deploy`, and migrations
755
+ // silently stopped. Migrations run first, so new code never meets an old schema.
756
+ //
757
+ // astroid ship production the deploy/production build: migrate D1, then deploy
758
+ // astroid ship preview every other branch: migrate the staging D1, then
759
+ // `wrangler preview`, named for the branch
760
+ async function cmdShip(cwd, target) {
761
+ if (target !== "production" && target !== "preview") {
762
+ fail("Usage: astroid ship production | preview");
763
+ }
764
+ const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
765
+ if (!wranglerBin) fail("Could not find `wrangler` in this project.");
766
+ const run = (args) => {
767
+ out(`\n▸ wrangler ${args.join(" ")}`);
768
+ const res = spawnSync(process.execPath, [wranglerBin, ...args], { cwd, stdio: "inherit" });
769
+ if (res.status !== 0) process.exit(res.status ?? 1);
770
+ };
771
+ const wranglerPath = join(cwd, "wrangler.jsonc");
772
+ if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
773
+
774
+ if (target === "production") {
775
+ run(["d1", "migrations", "apply", "DB", "--remote"]);
776
+ run(["deploy"]);
777
+ return;
778
+ }
779
+
780
+ // The staging database is declared only inside `previews`, and wrangler's
781
+ // migrations command reads top-level `d1_databases`. So write a throwaway
782
+ // config naming it, derived from wrangler.jsonc on every run, rather than a
783
+ // second committed file that could drift from the binding.
784
+ const { parseJsonc } = await import(GENERATORS_URL);
785
+ const config = parseJsonc(readFileSync(wranglerPath, "utf8"));
786
+ const prodDb = (config.d1_databases ?? []).find((d) => d.binding === "DB");
787
+ const stagingDb = (config.previews?.d1_databases ?? []).find((d) => d.binding === "DB");
788
+ if (stagingDb && prodDb) {
789
+ const migrationsDir = resolve(cwd, prodDb.migrations_dir ?? "migrations");
790
+ const tmp = join(cwd, ".wrangler", "astroid-preview-migrations.jsonc");
791
+ mkdirSync(dirname(tmp), { recursive: true });
792
+ writeFileSync(
793
+ tmp,
794
+ JSON.stringify(
795
+ {
796
+ d1_databases: [
797
+ {
798
+ binding: "PREVIEW_DB",
799
+ database_name: stagingDb.database_name,
800
+ database_id: stagingDb.database_id,
801
+ migrations_dir: migrationsDir,
802
+ },
803
+ ],
804
+ },
805
+ null,
806
+ 2,
807
+ ),
808
+ );
809
+ run(["d1", "migrations", "apply", "PREVIEW_DB", "--remote", "--config", tmp]);
810
+ } else {
811
+ out("\n(no staging D1 in `previews`, so no staging migrations to apply)");
812
+ }
813
+
814
+ // Workers Builds names the branch in WORKERS_CI_BRANCH; a Preview name is a
815
+ // DNS label, so a branch like feature/12-login becomes feature-12-login.
816
+ const branch = process.env.WORKERS_CI_BRANCH;
817
+ const name = branch
818
+ ? branch
819
+ .toLowerCase()
820
+ .replace(/[^a-z0-9-]+/g, "-")
821
+ .replace(/^-+|-+$/g, "")
822
+ .slice(0, 63)
823
+ : undefined;
824
+ run(["preview", ...(name ? ["--name", name] : [])]);
825
+ }
826
+
604
827
  async function main() {
605
828
  const { command, flags, rest } = parseArgs(process.argv.slice(2));
606
829
  const cwd = flags.cwd ? resolve(flags.cwd) : process.cwd();
@@ -622,6 +845,12 @@ async function main() {
622
845
  case "deploy":
623
846
  await cmdDeploy(cwd, flags, rest);
624
847
  break;
848
+ case "ship":
849
+ await cmdShip(cwd, rest[0]);
850
+ break;
851
+ case "provision":
852
+ await cmdProvision(cwd, rest);
853
+ break;
625
854
  case "help":
626
855
  case "--help":
627
856
  case "-h":
@@ -62,8 +62,12 @@ export function generateAstroidGalleryPage(config) {
62
62
  " // No DB binding yet (pre-provision)—render the empty state.",
63
63
  "}",
64
64
  "",
65
+ "// This environment's media base: production's, or a staging Preview's own",
66
+ "// `/media`, where the Preview's uploads live.",
67
+ `const mediaBase = (env.MEDIA_URL || ${JSON.stringify(mediaBase)}).replace(/\\/+$/, "");`,
68
+ "",
65
69
  "const items: GalleryItem[] = rows.map((row) => ({",
66
- ` src: \`${mediaBase}/\${row.key}\`,`,
70
+ " src: `${mediaBase}/${row.key}`,",
67
71
  ' // An asset with no alt gets "" rather than its filename: an empty alt makes',
68
72
  " // a screen reader skip a decorative tile, while a filename is read aloud",
69
73
  " // character by character and tells the listener nothing.",
@@ -61,6 +61,7 @@ export function generateAstroidActions(config) {
61
61
  // as a literal, and an unused import is a lint error in its own file.
62
62
  ...(columnsOverride ? [] : [" ASTROID_SETTINGS_COLUMNS,"]),
63
63
  " ASTROID_SETTINGS_IMAGE_KEYS,",
64
+ " astroidMediaBase,",
64
65
  " astroidPagesCollection,",
65
66
  '} from "astroidjs";',
66
67
  'import astroidConfig from "../../astroid.config.js";',
@@ -117,7 +118,11 @@ export function generateAstroidActions(config) {
117
118
  " // than copied—a second literal here is a list that drifts from the",
118
119
  " // one the routes check against, and nothing would fail when it did.",
119
120
  ...settingsExtra,
120
- ' mediaBase: astroidConfig.deploy?.mediaBase ?? "/media",',
121
+ " // Read on each save, so a staging Preview accepts images from its own",
122
+ " // `/media` (see astroidMediaBase).",
123
+ " get mediaBase() {",
124
+ " return astroidMediaBase(astroidConfig);",
125
+ " },",
121
126
  " }),",
122
127
  " ),",
123
128
  " },",
@@ -209,10 +209,11 @@ export function generateAstroidWrangler(config) {
209
209
  p(" // KV: RL = the security rate limiter (it also holds the daily site-health");
210
210
  p(" // summary under its own key—one small singleton blob, not worth a binding");
211
211
  p(" // someone has to remember to provision); DRAFTS = the autosave write-buffer.");
212
- p(" // Create each: `wrangler kv namespace create <RL|DRAFTS>`.");
212
+ p(" // Named for the project, so two sites in one account don't collide.");
213
+ p(" // `astroid provision` creates each and fills in its id.");
213
214
  p(' "kv_namespaces": [');
214
- p(' { "binding": "RL", "id": "<run: wrangler kv namespace create RL>" },');
215
- p(' { "binding": "DRAFTS", "id": "<run: wrangler kv namespace create DRAFTS>" },');
215
+ p(` { "binding": "RL", "id": "<run: wrangler kv namespace create ${key}-rl>" },`);
216
+ p(` { "binding": "DRAFTS", "id": "<run: wrangler kv namespace create ${key}-drafts>" },`);
216
217
  p(" ],");
217
218
  // Email Sending. NOT optional decoration: `src/env.d.ts` declares EMAIL as a
218
219
  // required member, and Better Auth's magic-link path console-logs the link in
@@ -2,3 +2,6 @@ export * from "./generate.js";
2
2
  export * from "./actions.js";
3
3
  export * from "./scaffold.js";
4
4
  export * from "./seed.js";
5
+ export * from "./previews.js";
6
+ export * from "./release.js";
7
+ export * from "./provision.js";
@@ -6,3 +6,6 @@ export * from "./generate.js";
6
6
  export * from "./actions.js";
7
7
  export * from "./scaffold.js";
8
8
  export * from "./seed.js";
9
+ export * from "./previews.js";
10
+ export * from "./release.js";
11
+ export * from "./provision.js";
@@ -0,0 +1,18 @@
1
+ /** One finding, in the order `doctor` prints them. */
2
+ export interface PreviewsFindings {
3
+ ok: string[];
4
+ errors: string[];
5
+ warnings: string[];
6
+ }
7
+ /**
8
+ * Parse JSONC: strip `//` and block comments outside strings, then trailing
9
+ * commas. `wrangler.jsonc` is written by hand and commented heavily, which is
10
+ * why `doctor` reads the rest of it by regex; this check needs the structure.
11
+ */
12
+ export declare function parseJsonc(text: string): unknown;
13
+ /**
14
+ * Check the `previews` block of a `wrangler.jsonc` against its production
15
+ * settings. Returns what passed and what didn't; an error means a Preview
16
+ * would crash or touch production.
17
+ */
18
+ export declare function checkWranglerPreviews(text: string): PreviewsFindings;
@@ -0,0 +1,215 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The `previews` block check `astroid doctor` runs (louise-toolkit ADR 0017).
4
+ //
5
+ // A site's staging is Cloudflare's Worker Previews: `main` and every pull
6
+ // request run as Previews of the one Worker, with the settings in the
7
+ // `previews` block of `wrangler.jsonc`. Previews inherit NOTHING from the top
8
+ // level, which is the point and also the trap:
9
+ //
10
+ // - A binding the code reads that the block leaves out is `undefined` on a
11
+ // Preview, and the Worker throws (Cloudflare's error 1101).
12
+ // - A binding the block copies verbatim points a Preview at production's
13
+ // database or bucket, so a branch writes production data. That's the
14
+ // failure staging exists to prevent, and nothing else would catch it.
15
+ // - Crons, routes, and queue consumers don't target Previews, so putting
16
+ // them in the block does nothing and reads as if it did.
17
+ //
18
+ // Pure: text in, findings out, so it's tested directly and the CLI only prints.
19
+ /**
20
+ * Parse JSONC: strip `//` and block comments outside strings, then trailing
21
+ * commas. `wrangler.jsonc` is written by hand and commented heavily, which is
22
+ * why `doctor` reads the rest of it by regex; this check needs the structure.
23
+ */
24
+ export function parseJsonc(text) {
25
+ let out = "";
26
+ let inString = false;
27
+ for (let i = 0; i < text.length; i++) {
28
+ const c = text[i];
29
+ const next = text[i + 1];
30
+ if (inString) {
31
+ out += c;
32
+ if (c === "\\")
33
+ out += text[++i] ?? "";
34
+ else if (c === '"')
35
+ inString = false;
36
+ }
37
+ else if (c === '"') {
38
+ inString = true;
39
+ out += c;
40
+ }
41
+ else if (c === "/" && next === "/") {
42
+ while (i < text.length && text[i] !== "\n")
43
+ i++;
44
+ out += "\n";
45
+ }
46
+ else if (c === "/" && next === "*") {
47
+ i += 2;
48
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
49
+ i++;
50
+ i++;
51
+ }
52
+ else {
53
+ out += c;
54
+ }
55
+ }
56
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
57
+ }
58
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
59
+ const list = (v) => (Array.isArray(v) ? v.filter(isObject) : []);
60
+ const STORAGE = [
61
+ {
62
+ what: "D1 database",
63
+ get: (c) => list(c.d1_databases),
64
+ name: (b) => b.binding,
65
+ resource: (b) => String(b.database_id ?? b.database_name ?? ""),
66
+ },
67
+ {
68
+ what: "KV namespace",
69
+ get: (c) => list(c.kv_namespaces),
70
+ name: (b) => b.binding,
71
+ resource: (b) => String(b.id ?? ""),
72
+ },
73
+ {
74
+ what: "R2 bucket",
75
+ get: (c) => list(c.r2_buckets),
76
+ name: (b) => b.binding,
77
+ resource: (b) => String(b.bucket_name ?? ""),
78
+ },
79
+ {
80
+ what: "Secrets Store secret",
81
+ get: (c) => list(c.secrets_store_secrets),
82
+ name: (b) => b.binding,
83
+ resource: (b) => `${String(b.store_id ?? "")}/${String(b.secret_name ?? "")}`,
84
+ },
85
+ {
86
+ what: "Analytics Engine dataset",
87
+ get: (c) => list(c.analytics_engine_datasets),
88
+ name: (b) => b.binding,
89
+ resource: (b) => String(b.dataset ?? ""),
90
+ },
91
+ {
92
+ what: "Vectorize index",
93
+ get: (c) => list(c.vectorize),
94
+ name: (b) => b.binding,
95
+ resource: (b) => String(b.index_name ?? ""),
96
+ },
97
+ {
98
+ what: "queue producer",
99
+ get: (c) => list(isObject(c.queues) ? c.queues.producers : undefined),
100
+ name: (b) => b.binding,
101
+ resource: (b) => String(b.queue ?? ""),
102
+ optional: "queue consumers can't target a Preview, so a Preview leaves the queue unbound and the side effects run inline",
103
+ },
104
+ {
105
+ what: "Workflow",
106
+ get: (c) => list(c.workflows),
107
+ name: (b) => b.binding,
108
+ resource: (b) => String(b.name ?? ""),
109
+ optional: "a Preview calls the production Workflow's code and bindings, so it leaves the binding out and falls back",
110
+ },
111
+ ];
112
+ /** Bindings with no staging resource behind them: a Preview needs the key, and
113
+ * nothing to provision. A Durable Object namespace is one: Cloudflare gives
114
+ * each Preview its own instances of the class. */
115
+ const API_BINDINGS = [
116
+ "ai",
117
+ "images",
118
+ "browser",
119
+ "send_email",
120
+ "version_metadata",
121
+ "durable_objects",
122
+ ];
123
+ /**
124
+ * Check the `previews` block of a `wrangler.jsonc` against its production
125
+ * settings. Returns what passed and what didn't; an error means a Preview
126
+ * would crash or touch production.
127
+ */
128
+ export function checkWranglerPreviews(text) {
129
+ const findings = { ok: [], errors: [], warnings: [] };
130
+ let config;
131
+ try {
132
+ const parsed = parseJsonc(text);
133
+ if (!isObject(parsed))
134
+ throw new Error("not an object");
135
+ config = parsed;
136
+ }
137
+ catch (err) {
138
+ findings.errors.push(`wrangler.jsonc doesn't parse (${err.message}).`);
139
+ return findings;
140
+ }
141
+ const previews = config.previews;
142
+ if (!isObject(previews)) {
143
+ findings.warnings.push("wrangler.jsonc has no `previews` block, so this site has no staging. Branch builds " +
144
+ "either fail or run with production's data (louise-toolkit ADR 0017).");
145
+ return findings;
146
+ }
147
+ for (const key of ["triggers", "routes"]) {
148
+ if (key in previews) {
149
+ findings.errors.push(`\`previews.${key}\` does nothing: ${key === "triggers" ? "Cron Triggers" : "routes"} ` +
150
+ "target production only. Remove it; Preview hosts come from a route with " +
151
+ "`previews_enabled` at the top level.");
152
+ }
153
+ }
154
+ if (isObject(previews.queues) && "consumers" in previews.queues) {
155
+ findings.errors.push("`previews.queues.consumers` does nothing: queue consumers can't target a Preview. Remove it.");
156
+ }
157
+ for (const kind of STORAGE) {
158
+ const staging = new Map(kind.get(previews).map((b) => [kind.name(b), b]));
159
+ for (const prod of kind.get(config)) {
160
+ const name = String(kind.name(prod));
161
+ const preview = staging.get(kind.name(prod));
162
+ if (!preview) {
163
+ if (kind.optional)
164
+ findings.ok.push(`previews: ${kind.what} \`${name}\` left out (${kind.optional})`);
165
+ else
166
+ findings.errors.push(`\`previews\` has no ${kind.what} \`${name}\`. A Preview inherits nothing, so code ` +
167
+ `that reads \`env.${name}\` throws there. Bind it to a staging ${kind.what}.`);
168
+ }
169
+ else if (kind.resource(preview) === kind.resource(prod)) {
170
+ findings.errors.push(`\`previews\` binds ${kind.what} \`${name}\` to production's (${kind.resource(prod)}), ` +
171
+ "so every branch would read and write production data. Bind it to a staging one.");
172
+ }
173
+ else {
174
+ findings.ok.push(`previews: ${kind.what} \`${name}\` bound to a staging resource`);
175
+ }
176
+ }
177
+ }
178
+ for (const key of API_BINDINGS) {
179
+ if (!(key in config))
180
+ continue;
181
+ if (key in previews)
182
+ findings.ok.push(`previews: \`${key}\` binding present`);
183
+ else
184
+ findings.errors.push(`\`previews\` has no \`${key}\` binding. A Preview inherits nothing, so code that uses ` +
185
+ "it throws there. Copy the binding into `previews`; it needs no staging resource.");
186
+ }
187
+ const prodVars = isObject(config.vars) ? config.vars : {};
188
+ const stagingVars = isObject(previews.vars) ? previews.vars : {};
189
+ const missingVars = Object.keys(prodVars).filter((key) => !(key in stagingVars));
190
+ if (missingVars.length) {
191
+ findings.errors.push(`\`previews.vars\` is missing ${missingVars.map((v) => `\`${v}\``).join(", ")}. Vars aren't ` +
192
+ "inherited, so each is undefined on a Preview. Give each a staging value.");
193
+ }
194
+ else if (Object.keys(prodVars).length) {
195
+ findings.ok.push(`previews: all ${Object.keys(prodVars).length} vars have a staging value`);
196
+ }
197
+ for (const key of ["SITE_URL", "MEDIA_URL"]) {
198
+ const value = prodVars[key];
199
+ // A path such as `/media` resolves against whichever host serves it, so a
200
+ // Preview sharing production's path still serves its own bucket.
201
+ const isOrigin = typeof value === "string" && /^https?:\/\//.test(value);
202
+ if (key in prodVars && isOrigin && stagingVars[key] === value) {
203
+ findings.errors.push(`\`previews.vars.${key}\` is production's (${String(prodVars[key])}), so a Preview ` +
204
+ "links to or serves from production. Point it at staging.");
205
+ }
206
+ }
207
+ const previewHost = list(config.routes).find((r) => r.previews_enabled === true);
208
+ if (previewHost)
209
+ findings.ok.push(`previews: served on \`<name>.${String(previewHost.pattern)}\``);
210
+ else
211
+ findings.warnings.push("No route has `previews_enabled`, so Previews are reachable only on workers.dev. Add a " +
212
+ 'preview-only custom domain: `{ "pattern": "staging.example.com", "custom_domain": true, ' +
213
+ '"previews_enabled": true, "enabled": false }`.');
214
+ return findings;
215
+ }
@@ -0,0 +1,26 @@
1
+ /** One resource to create. `placeholder` is the text its ID replaces. */
2
+ export interface ProvisionStep {
3
+ kind: "d1" | "kv" | "r2";
4
+ name: string;
5
+ /** The `wrangler` arguments that create it. */
6
+ args: string[];
7
+ placeholder?: string;
8
+ }
9
+ /** A Secrets Store secret the config binds, which only a person can set. */
10
+ export interface ProvisionSecret {
11
+ binding: string;
12
+ storeId: string;
13
+ secretName: string;
14
+ /** `production` for a top-level binding, `staging` for one in `previews`. */
15
+ environment: "production" | "staging";
16
+ }
17
+ export interface ProvisionPlan {
18
+ steps: ProvisionStep[];
19
+ secrets: ProvisionSecret[];
20
+ /** Whether `account_id` is set, so wrangler doesn't have to pick one. */
21
+ hasAccount: boolean;
22
+ }
23
+ /** Build the plan from a `wrangler.jsonc`, top level and `previews` alike. */
24
+ export declare function provisionPlan(text: string): ProvisionPlan;
25
+ /** Put a created resource's ID in place of its placeholder, everywhere. */
26
+ export declare function applyProvisionedId(text: string, placeholder: string, id: string): string;
@@ -0,0 +1,65 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The plan `astroid provision` runs: which Cloudflare resources a site's
4
+ // `wrangler.jsonc` still names by placeholder, and which secrets it binds.
5
+ //
6
+ // A placeholder names the command that creates its resource, such as
7
+ // `<run: wrangler d1 create acme-staging>`, so the plan reads it rather than
8
+ // guessing a name. The same placeholder can stand for two bindings that share
9
+ // one namespace, and replacing the text everywhere it appears fills both.
10
+ // Buckets carry a name instead of an ID, so every bucket the file names is
11
+ // created, and one that already exists is fine.
12
+ //
13
+ // Pure: text in, plan out, so it's tested directly and the CLI only runs it.
14
+ import { parseJsonc } from "./previews.js";
15
+ const PLACEHOLDER = /<run:\s*wrangler\s+(d1\s+create|kv\s+namespace\s+create)\s+([^\s>]+)\s*>/g;
16
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
17
+ const list = (v) => (Array.isArray(v) ? v.filter(isObject) : []);
18
+ /** Build the plan from a `wrangler.jsonc`, top level and `previews` alike. */
19
+ export function provisionPlan(text) {
20
+ const steps = [];
21
+ const seen = new Set();
22
+ for (const match of text.matchAll(PLACEHOLDER)) {
23
+ const placeholder = match[0];
24
+ if (seen.has(placeholder))
25
+ continue;
26
+ seen.add(placeholder);
27
+ const name = match[2];
28
+ steps.push(match[1].startsWith("d1")
29
+ ? { kind: "d1", name, args: ["d1", "create", name], placeholder }
30
+ : { kind: "kv", name, args: ["kv", "namespace", "create", name], placeholder });
31
+ }
32
+ const config = parseJsonc(text);
33
+ const top = isObject(config) ? config : {};
34
+ const previews = isObject(top.previews) ? top.previews : {};
35
+ const buckets = new Set([...list(top.r2_buckets), ...list(previews.r2_buckets)]
36
+ .map((b) => b.bucket_name)
37
+ .filter((name) => typeof name === "string" && !name.startsWith("<")));
38
+ for (const name of buckets) {
39
+ steps.push({ kind: "r2", name, args: ["r2", "bucket", "create", name] });
40
+ }
41
+ const secrets = [];
42
+ for (const [environment, section] of [
43
+ ["production", top],
44
+ ["staging", previews],
45
+ ]) {
46
+ for (const s of list(section.secrets_store_secrets)) {
47
+ secrets.push({
48
+ binding: String(s.binding ?? ""),
49
+ storeId: String(s.store_id ?? ""),
50
+ secretName: String(s.secret_name ?? ""),
51
+ environment,
52
+ });
53
+ }
54
+ }
55
+ const account = top.account_id;
56
+ return {
57
+ steps,
58
+ secrets,
59
+ hasAccount: typeof account === "string" && account.length > 0 && !account.startsWith("<"),
60
+ };
61
+ }
62
+ /** Put a created resource's ID in place of its placeholder, everywhere. */
63
+ export function applyProvisionedId(text, placeholder, id) {
64
+ return text.split(placeholder).join(id);
65
+ }
@@ -0,0 +1,6 @@
1
+ /** The branch Workers Builds deploys to production from. */
2
+ export declare const ASTROID_DEPLOY_BRANCH = "deploy/production";
3
+ /** Where the workflow lives, relative to the repository root. */
4
+ export declare const ASTROID_RELEASE_WORKFLOW_PATH = ".github/workflows/release.yml";
5
+ /** The release workflow's contents. Pure, and the same for every site. */
6
+ export declare function generateAstroidReleaseWorkflow(): string;
@@ -0,0 +1,79 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The release workflow every site runs (louise-toolkit ADR 0017, amended).
4
+ //
5
+ // Releases are trunk-based: a `v<version>` tag on a commit of `main` is the
6
+ // release, and a `release/<version>` branch exists only when a released version
7
+ // needs a patch and `main` has moved on. Workers Builds deploys on branch
8
+ // pushes and can't watch tags, so the tag's workflow moves one branch,
9
+ // `deploy/production`, to the tagged commit, and Workers Builds deploys that.
10
+ // Nobody commits to the branch; it's a tag carried as a branch, and GitHub never
11
+ // holds a Cloudflare credential.
12
+ //
13
+ // A regenerated file, like the worker trio: `astroid generate` rewrites it and
14
+ // `astroid doctor` fails when it drifts, so a hand edit can't quietly change
15
+ // which commits reach production.
16
+ /** The branch Workers Builds deploys to production from. */
17
+ export const ASTROID_DEPLOY_BRANCH = "deploy/production";
18
+ /** Where the workflow lives, relative to the repository root. */
19
+ export const ASTROID_RELEASE_WORKFLOW_PATH = ".github/workflows/release.yml";
20
+ /** The release workflow's contents. Pure, and the same for every site. */
21
+ export function generateAstroidReleaseWorkflow() {
22
+ return `# Generated by astroid. Don't edit: \`astroid generate\` rewrites this file and
23
+ # \`astroid doctor\` fails when it drifts.
24
+ #
25
+ # A release is a tag, v<major>.<minor>.<patch>, on a commit of main, or of a
26
+ # release/<version> branch when a released version needs a patch. This moves
27
+ # ${ASTROID_DEPLOY_BRANCH} to the tagged commit, and Workers Builds deploys it to
28
+ # production. Nobody commits to ${ASTROID_DEPLOY_BRANCH}; a repository ruleset lets
29
+ # only this workflow update it. To roll back, tag the earlier commit with the
30
+ # next patch version.
31
+ name: Release
32
+
33
+ on:
34
+ push:
35
+ tags: ["v*"]
36
+
37
+ permissions:
38
+ contents: write
39
+
40
+ # One release at a time, in the order the tags arrived.
41
+ concurrency:
42
+ group: release
43
+ cancel-in-progress: false
44
+
45
+ jobs:
46
+ release:
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+ with:
51
+ fetch-depth: 0
52
+
53
+ - name: Check the tag
54
+ run: |
55
+ tag="$GITHUB_REF_NAME"
56
+ if ! [[ "$tag" =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then
57
+ echo "::error::A release tag is v<major>.<minor>.<patch>, such as v1.4.0. Got $tag."
58
+ exit 1
59
+ fi
60
+ sha="$(git rev-list -n 1 "$tag")"
61
+ if git merge-base --is-ancestor "$sha" origin/main; then
62
+ echo "$tag is on main."
63
+ elif git branch -r --contains "$sha" | grep -q "origin/release/"; then
64
+ echo "$tag is on a release branch."
65
+ else
66
+ echo "::error::$tag isn't on main or a release/ branch, so it can't be released."
67
+ exit 1
68
+ fi
69
+ echo "SHA=$sha" >> "$GITHUB_ENV"
70
+
71
+ - name: Move ${ASTROID_DEPLOY_BRANCH} to the tag
72
+ run: git push --force origin "$SHA:refs/heads/${ASTROID_DEPLOY_BRANCH}"
73
+
74
+ - name: Create the GitHub release
75
+ env:
76
+ GH_TOKEN: \${{ github.token }}
77
+ run: gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes
78
+ `;
79
+ }
@@ -196,10 +196,14 @@ export function generateAstroidWebhookRoute(config, forProvider) {
196
196
  "// Unprovisioned (the secret is absent or still the placeholder) answers 503,",
197
197
  "// which keeps the provider retrying—so events delivered before you set the",
198
198
  "// secret land afterwards instead of being lost.",
199
+ "//",
200
+ "// A staging Preview has no queue, since a consumer can't target one, so",
201
+ "// there the event runs in the request through the same handler instead.",
199
202
  'import type { APIRoute } from "astro";',
200
203
  'import { handleWebhook, readModuleSecret } from "astroidjs";',
201
204
  'import { env } from "cloudflare:workers";',
202
205
  `import { ${p.verifier} } from ${JSON.stringify(p.module)};`,
206
+ 'import { handleQueueMessage } from "../../../queue";',
203
207
  "",
204
208
  "export const prerender = false;",
205
209
  "",
@@ -211,6 +215,7 @@ export function generateAstroidWebhookRoute(config, forProvider) {
211
215
  ` provider: ${JSON.stringify(provider)},`,
212
216
  ` secret: await readModuleSecret(env.${COMMERCE_PROVIDER_SECRETS[provider].webhook}),`,
213
217
  ` queue: env.${ASTROID_QUEUE_BINDING},`,
218
+ " inline: (message) => handleQueueMessage(env, message),",
214
219
  ` verify: ({ raw, secret }) => ${p.call},`,
215
220
  " });",
216
221
  "};",
@@ -30,6 +30,22 @@ export interface WebhookRouteOptions {
30
30
  verify: (input: WebhookVerifyInput) => boolean | Promise<boolean>;
31
31
  /** The queue binding, or null/undefined when Queues aren't provisioned. */
32
32
  queue?: QueueProducer | null;
33
+ /**
34
+ * Run a message in the request instead, when `queue` is absent. A staging
35
+ * Preview leaves the queue unbound, because a queue consumer can't target a
36
+ * Preview (louise-toolkit ADR 0017), so without this every webhook a Preview
37
+ * receives answers 503 and the provider retries it forever.
38
+ *
39
+ * Pass the same handler the queue consumer runs: `(message) =>
40
+ * handleQueueMessage(env, message)`. Throwing answers 503, so the provider
41
+ * redelivers, which is the retry the queue would have given it.
42
+ *
43
+ * It's a fallback, not an alternative: a sync that outlasts the provider's
44
+ * delivery timeout reads to the provider as a failure, which is why
45
+ * production enqueues. On staging, with a sandbox catalog, that's a fair
46
+ * trade, and the queue wins whenever both are there.
47
+ */
48
+ inline?: (message: AstroidQueueMessage) => Promise<void>;
33
49
  /**
34
50
  * Pull the event type out of the parsed payload. Defaults to a `type` field;
35
51
  * override for providers that name it differently (Fourthwall's `testMode`
@@ -65,10 +65,27 @@ export async function handleWebhook(request, url, options) {
65
65
  if (options.accept && !options.accept(type, payload)) {
66
66
  return text("Ignored", 202);
67
67
  }
68
- if (!options.queue)
69
- return text("Queue not configured", 503);
68
+ const message = {
69
+ kind: "webhook",
70
+ provider: options.provider,
71
+ type,
72
+ payload,
73
+ };
74
+ if (!options.queue) {
75
+ if (!options.inline)
76
+ return text("Queue not configured", 503);
77
+ try {
78
+ await options.inline(message);
79
+ }
80
+ catch {
81
+ // Same contract as a failed send: the event is real, so ask for it again.
82
+ return text("Processing failed", 503);
83
+ }
84
+ // 200, not 202: the work already happened.
85
+ return text("Processed", 200);
86
+ }
70
87
  try {
71
- await options.queue.send({ kind: "webhook", provider: options.provider, type, payload });
88
+ await options.queue.send(message);
72
89
  }
73
90
  catch {
74
91
  // The signature was good, so this event is real and worth keeping. 503 asks
@@ -21,6 +21,7 @@
21
21
  // `louise-toolkit/src/...`—that resolves only because the workspace aliases the
22
22
  // package to its source, and would break the moment astroid consumes a published
23
23
  // tarball (#327).
24
+ import { astroidMediaBase } from "./media-base.js";
24
25
  import { defineCollection, } from "louise-toolkit/content/define";
25
26
  import { assertValidSections, sanitizeSectionsRichText } from "louise-toolkit/content/sections";
26
27
  import { sanitizeRichHtml } from "louise-toolkit/security";
@@ -39,10 +40,9 @@ import { astroidSectionCatalog } from "../components/sections.js";
39
40
  * Validated by `defineCollection` at build time, so a malformed field shape throws
40
41
  * here rather than at codegen.
41
42
  */
42
- /** The media base a `pages` write sanitizes rich content against (config, `/media` default). */
43
- function pageMediaBase(config) {
44
- return config.deploy?.mediaBase ?? "/media";
45
- }
43
+ /** The media base a `pages` write sanitizes rich content against, read when the
44
+ * write runs so a Preview checks against its own base (see media-base.ts). */
45
+ const pageMediaBase = astroidMediaBase;
46
46
  /**
47
47
  * The section catalog a `pages` write is validated + sanitized against: the
48
48
  * site's own (`config.sectionCatalog`) when it registered bespoke sections, else
@@ -113,12 +113,11 @@ export const ASTROID_RESERVED_SLUGS = [
113
113
  "sitemap.xml",
114
114
  ];
115
115
  export function astroidPagesWriteHooks(config, site = {}) {
116
- const mediaBase = pageMediaBase(config);
117
116
  return {
118
117
  // `body` is a richField, so it goes through pagesRoute's own sanitize seam—with
119
118
  // the project media base, matching the hook rather than the toolkit
120
119
  // default sanitizer that knows no media base.
121
- sanitize: (html) => sanitizeRichHtml(html, { mediaBase }),
120
+ sanitize: (html) => sanitizeRichHtml(html, { mediaBase: pageMediaBase(config) }),
122
121
  // `sections` is not a richField, so it's sanitized here in the transform,
123
122
  // which pagesRoute runs BEFORE validate—the hook's sanitize-then-validate
124
123
  // order. The site's own transform runs first, so Astroid's sanitize sees
@@ -136,7 +135,6 @@ export function astroidPagesCollection(config) {
136
135
  // staged as a draft, so sanitize it on every write—never store raw HTML. A
137
136
  // pasted `<img>` pointing off-origin (a hotlink) is dropped: body images must
138
137
  // live in the media library. Mirrors the reference site's pages-collection hook.
139
- const mediaBase = pageMediaBase(config);
140
138
  const fields = {};
141
139
  fields.slug = { type: "text", required: true };
142
140
  fields.title = { type: "text", required: true };
@@ -158,7 +156,10 @@ export function astroidPagesCollection(config) {
158
156
  async ({ data }) => {
159
157
  let next = data;
160
158
  if (typeof next.body === "string") {
161
- next = { ...next, body: sanitizeRichHtml(next.body, { mediaBase }) };
159
+ next = {
160
+ ...next,
161
+ body: sanitizeRichHtml(next.body, { mediaBase: pageMediaBase(config) }),
162
+ };
162
163
  }
163
164
  // Sanitize BEFORE validating: a richText field stores HTML, and
164
165
  // validating the raw value would pass content the sanitizer is about
@@ -1,3 +1,4 @@
1
1
  export * from "./collections.js";
2
2
  export * from "./framework.js";
3
3
  export * from "./generate.js";
4
+ export * from "./media-base.js";
@@ -4,3 +4,4 @@
4
4
  export * from "./collections.js";
5
5
  export * from "./framework.js";
6
6
  export * from "./generate.js";
7
+ export * from "./media-base.js";
@@ -0,0 +1,13 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /**
3
+ * Record the running deployment's media base, `vars.MEDIA_URL`. The generated
4
+ * worker calls this at startup; an empty or absent value leaves the config's
5
+ * base in effect.
6
+ */
7
+ export declare function setAstroidMediaBase(base: string | undefined): void;
8
+ /**
9
+ * The media base a write is checked against: the running deployment's when the
10
+ * worker recorded one, else `deploy.mediaBase`, else `/media`. Call it when the
11
+ * check runs, not when it's built.
12
+ */
13
+ export declare function astroidMediaBase(config: AstroidConfig): string;
@@ -0,0 +1,33 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The media base the running Worker serves from, for the checks that can't read
4
+ // a request's `env`.
5
+ //
6
+ // The page sanitizers and the settings action are built once, from the config,
7
+ // and drizzle-kit loads the schema that builds them outside a Worker. So they
8
+ // can't take `env.MEDIA_URL` the way the generated routes do. Left on the fixed
9
+ // `deploy.mediaBase`, a staging Preview, which serves media from its own
10
+ // `/media`, drops every image uploaded on it as a hotlink.
11
+ //
12
+ // So the generated worker records `MEDIA_URL` once, at startup, and these checks
13
+ // read it when they run. That's safe as module state because a Worker isolate
14
+ // runs one deployment, and `MEDIA_URL` is fixed per deployment: production's
15
+ // isolates hold production's base, and a Preview's hold the Preview's.
16
+ let runtimeMediaBase;
17
+ /**
18
+ * Record the running deployment's media base, `vars.MEDIA_URL`. The generated
19
+ * worker calls this at startup; an empty or absent value leaves the config's
20
+ * base in effect.
21
+ */
22
+ export function setAstroidMediaBase(base) {
23
+ const trimmed = base?.replace(/\/+$/, "");
24
+ runtimeMediaBase = trimmed ? trimmed : undefined;
25
+ }
26
+ /**
27
+ * The media base a write is checked against: the running deployment's when the
28
+ * worker recorded one, else `deploy.mediaBase`, else `/media`. Call it when the
29
+ * check runs, not when it's built.
30
+ */
31
+ export function astroidMediaBase(config) {
32
+ return runtimeMediaBase ?? (config.deploy?.mediaBase ?? "/media").replace(/\/+$/, "");
33
+ }
@@ -122,7 +122,9 @@ export function generateAstroidWorker(config) {
122
122
  // The site's sanitize + read hooks (config.settings.hooks), spread last
123
123
  // so they add to the call rather than replace any part of it.
124
124
  const hooksArg = config.settings?.hooks ? ", ...settingsHooks" : "";
125
- return `settingsRoute({ table: siteSettings, resolveEditor, columns: SETTINGS_COLUMNS, imageKeys: SETTINGS_IMAGE_KEYS, mediaBase: MEDIA_BASE${customArg}${hooksArg} })`;
125
+ // Built per request, because the media base is per environment: a
126
+ // Preview's `MEDIA_URL` differs from production's (see `mediaBaseOf`).
127
+ return `(request, env, ctx) => settingsRoute({ table: siteSettings, resolveEditor, columns: SETTINGS_COLUMNS, imageKeys: SETTINGS_IMAGE_KEYS, mediaBase: mediaBaseOf(env)${customArg}${hooksArg} })(request, env, ctx)`;
126
128
  }
127
129
  // `aiRunner` rather than `(env) => env.AI`: it reads the binding AND the
128
130
  // LOUISE_AI kill switch, so all three assists share one definition of
@@ -201,6 +203,7 @@ export function generateAstroidWorker(config) {
201
203
  "astroidPagesCollection",
202
204
  "astroidPagesWriteHooks",
203
205
  "readModuleSecret",
206
+ "setAstroidMediaBase",
204
207
  ...(inquiries ? ["sendInquiryMail"] : []),
205
208
  ...(queues ? ["type AstroidQueueMessage"] : []),
206
209
  ].sort();
@@ -228,7 +231,18 @@ export function generateAstroidWorker(config) {
228
231
  p('import { handleQueueMessage } from "./queue.js";');
229
232
  }
230
233
  p();
231
- p(`const MEDIA_BASE = ${JSON.stringify(mediaBase)};`);
234
+ p(`const DEFAULT_MEDIA_BASE = ${JSON.stringify(mediaBase)};`);
235
+ p("// The public media base for this request's environment. `vars.MEDIA_URL`");
236
+ p('// wins, so a Preview sets its own (a path such as "/media", served from the');
237
+ p("// Preview's own host, since a Preview can't know its hostname in advance).");
238
+ p("// Read per request, not baked in: one build serves production and every");
239
+ p("// Preview, so nothing that differs between them can be a constant.");
240
+ p("const mediaBaseOf = (env: CloudflareEnv): string =>");
241
+ p(' (env.MEDIA_URL || DEFAULT_MEDIA_BASE).replace(/\\/+$/, "");');
242
+ p("// The checks built once from the config (the page sanitizers, the settings");
243
+ p("// action) read the base when they run. Recorded at startup, since an isolate");
244
+ p("// runs one deployment and `MEDIA_URL` is fixed per deployment.");
245
+ p("setAstroidMediaBase(env.MEDIA_URL);");
232
246
  p("const pagesCollection = astroidPagesCollection(astroidConfig);");
233
247
  p("// Sanitize + section-catalog validation for the raw pagesRoute, which runs");
234
248
  p("// no collection hook—the same contract versionsRoute gets from the config.");
@@ -294,7 +308,7 @@ export function generateAstroidWorker(config) {
294
308
  p("// failed crawl or a failed COUNT yields zero rather than aborting the scan,");
295
309
  p("// because a partial health report is worth strictly more than none.");
296
310
  p("async function runHealthScan(env: CloudflareEnv) {");
297
- p(" const origin = env.SITE_URL ?? MEDIA_BASE;");
311
+ p(" const origin = env.SITE_URL ?? mediaBaseOf(env);");
298
312
  p(" const [brokenLinks, missingAlt, seoGaps] = await Promise.all([");
299
313
  p(' checkLinks({ base: origin, paths: ["/"] }).catch(() => []),');
300
314
  p(" countRows(env, \"SELECT COUNT(*) AS n FROM media WHERE alt IS NULL OR alt = ''\"),");
@@ -376,17 +390,26 @@ export function generateAstroidWorker(config) {
376
390
  }
377
391
  p("];");
378
392
  p();
379
- p("// Stream uploaded media back from R2 at MEDIA_BASE (self-hosted, no public bucket).");
393
+ p("// Stream uploaded media back from R2 at the media base (self-hosted, no public");
394
+ p("// bucket). The base takes two shapes, and each is matched its own way:");
380
395
  p("//");
381
- p("// Scoped by ORIGIN, not by path prefix: MEDIA_BASE is an origin, and a");
382
- p('// `url.pathname` ("/web/foo.jpg") can never start with one. Comparing the two');
383
- p("// against each other made the guard permanently false, so the route never ran");
384
- p("// and every uploaded asset fell through to the site's 404 page. On the media");
385
- p("// host the whole pathname is the R2 key.");
396
+ p('// - An ORIGIN ("https://media.example.com") is a media host, where the whole');
397
+ p("// pathname is the R2 key. Compared by origin, never by path prefix: a");
398
+ p('// `url.pathname` ("/web/foo.jpg") can never start with an origin, and a guard');
399
+ p("// that compared the two once never ran, so every upload 404'd.");
400
+ p('// - A PATH ("/media") is a prefix on the site\'s own host, which is how a');
401
+ p("// Preview serves media, since it can't know its hostname in advance.");
386
402
  p("const mediaAssetRoute: WorkerRoute<CloudflareEnv> = async (request, env) => {");
387
403
  p(" const url = new URL(request.url);");
388
- p(" if (url.origin !== MEDIA_BASE) return undefined;");
389
- p(" const key = decodeURIComponent(url.pathname.slice(1));");
404
+ p(" const base = mediaBaseOf(env);");
405
+ p(' const path = base.startsWith("/")');
406
+ p(" ? url.pathname.startsWith(`${base}/`)");
407
+ p(" ? url.pathname.slice(base.length + 1)");
408
+ p(' : ""');
409
+ p(" : url.origin === base");
410
+ p(" ? url.pathname.slice(1)");
411
+ p(' : "";');
412
+ p(" const key = decodeURIComponent(path);");
390
413
  p(" if (!key) return undefined;");
391
414
  p(" const obj = await env.MEDIA.get(key);");
392
415
  p(' if (!obj) return new Response("Not found", { status: 404 });');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astroidjs",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Astroid — an opinionated meta-framework over Louise Toolkit and Astro for building editable, multi-editor sites on Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "astro",