@saastemly/voidcommerce 0.5.0 → 0.6.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/dist/cli.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  runVoid,
22
22
  version,
23
23
  voidAppsIn
24
- } from "./index-5m3t0zfc.js";
24
+ } from "./index-f1wds190.js";
25
25
  import {
26
26
  PRIVATE_KEY_VAR,
27
27
  SECRETS_FILE,
@@ -42,14 +42,15 @@ import {
42
42
  setVariable,
43
43
  variableNames,
44
44
  verifyCloudflareToken
45
- } from "./index-b9b4dawy.js";
45
+ } from "./index-2qt2yh3z.js";
46
46
  import {
47
47
  LAYOUTS,
48
+ oneOrigin,
48
49
  readManifest,
49
50
  validate,
50
51
  writeManifest,
51
52
  zoneOf
52
- } from "./index-pz6m2hkm.js";
53
+ } from "./index-xx5p4b8d.js";
53
54
  import {
54
55
  GROUPS
55
56
  } from "./index-844b3qn9.js";
@@ -436,6 +437,7 @@ ${color3.dim(`Included: ${required.map((choice) => choice.label).join(", ")}`)}`
436
437
  async function runWizard(existing, layout) {
437
438
  const monorepo = layout === "monorepo";
438
439
  const frontend = layout !== "app";
440
+ const onPages = !oneOrigin(layout);
439
441
  const shop = unwrap(await p.group({
440
442
  name: () => p.text({
441
443
  message: "Shop name",
@@ -492,7 +494,7 @@ async function runWizard(existing, layout) {
492
494
  initialValue: existing?.shop.locale ?? "",
493
495
  validate: (value) => /^[a-z]{2}(-[A-Z]{2})?$/.test(value.trim()) ? undefined : "like da, or en-GB."
494
496
  }),
495
- ...frontend ? {
497
+ ...onPages ? {
496
498
  pagesHost: () => p.text({
497
499
  message: `GitHub Pages host ${color3.dim("— where the storefront is published; leave blank if you serve the branch elsewhere")}`,
498
500
  placeholder: "northwind.github.io",
@@ -523,7 +525,7 @@ ${color3.dim("The generated storefront and panel. Strict has no custom code to h
523
525
  locale: shop.locale.trim(),
524
526
  taxRegistered: shop.taxRegistered === "yes",
525
527
  zone: shop.zone?.trim().toLowerCase() || undefined,
526
- pagesHost: frontend ? shop.pagesHost?.trim().toLowerCase() || undefined : undefined
528
+ pagesHost: onPages ? shop.pagesHost?.trim().toLowerCase() || undefined : undefined
527
529
  },
528
530
  chosen
529
531
  };
@@ -611,7 +613,7 @@ async function init(args) {
611
613
  } else {
612
614
  const parts = [
613
615
  ["api", "the D1 starter — this is the worker"],
614
- ["frontend", "Static Pagesthis is prerendered for GitHub Pages"]
616
+ ["frontend", "the storefrontprerendered and served from the worker's own assets"]
615
617
  ];
616
618
  for (const [dir, starter] of parts) {
617
619
  const target = join(root, dir);
@@ -1,4 +1,4 @@
1
- import type { Manifest } from "../manifest";
1
+ import { type Manifest } from "../manifest";
2
2
  /**
3
3
  * The storefront: a static Void site that talks to the worker over HTTP.
4
4
  *
@@ -3,7 +3,7 @@ import {
3
3
  hasFrontend,
4
4
  isApex,
5
5
  zone
6
- } from "./index-pz6m2hkm.js";
6
+ } from "./index-xx5p4b8d.js";
7
7
  import {
8
8
  __require,
9
9
  __toESM
@@ -368,7 +368,7 @@ async function decryptSecrets(project) {
368
368
  }
369
369
  const expected = committedPublicKey(root);
370
370
  if (expected) {
371
- const { publicKeyFor } = await import("./keys-b91c72zp.js");
371
+ const { publicKeyFor } = await import("./keys-qwvt324e.js");
372
372
  const derived = await publicKeyFor(privateKey);
373
373
  if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
374
374
  return {
@@ -9,19 +9,20 @@ import {
9
9
  renderEnvLocal,
10
10
  renderEnvProduction,
11
11
  renderEnvTs
12
- } from "./index-b9b4dawy.js";
12
+ } from "./index-2qt2yh3z.js";
13
13
  import {
14
14
  MANIFEST_FILE,
15
15
  has,
16
16
  hasFrontend,
17
17
  isSingleApp,
18
+ oneOrigin,
18
19
  packagesOf,
19
20
  readManifest,
20
21
  specifier,
21
22
  workerHosts,
22
23
  writeManifest,
23
24
  zone
24
- } from "./index-pz6m2hkm.js";
25
+ } from "./index-xx5p4b8d.js";
25
26
  import {
26
27
  CHOICES
27
28
  } from "./index-844b3qn9.js";
@@ -363,6 +364,22 @@ var camel = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
363
364
  var FRONTEND_OUT = "dist/client";
364
365
  var FRONTEND_BRANCH = "frontend-static";
365
366
  function renderFrontendApiTs(manifest) {
367
+ if (oneOrigin(manifest.layout)) {
368
+ return `import { createAuthClient } from "better-auth/react";
369
+ import { commerceClient } from "@saastemly/better-commerce/client";
370
+
371
+ /**
372
+ * The one client the storefront talks through.
373
+ *
374
+ * SAME ORIGIN. One worker serves this page and the API it calls, so there is
375
+ * no address to configure, nothing to bake at build time, and nothing that
376
+ * can point at the wrong place. Cookies are first-party, so no SameSite=None
377
+ * and no CORS preflight on any request.
378
+ */
379
+ export const API_ORIGIN = "";
380
+ export const api = createAuthClient({ plugins: [commerceClient()] });
381
+ `;
382
+ }
366
383
  return `/// <reference types="vite/client" />
367
384
  import { createAuthClient } from "better-auth/react";
368
385
  import { commerceClient } from "@saastemly/better-commerce/client";
@@ -383,6 +400,13 @@ export const api = createAuthClient({ baseURL: API_ORIGIN, plugins: [commerceCli
383
400
  `;
384
401
  }
385
402
  function renderFrontendEnvProduction(manifest) {
403
+ if (oneOrigin(manifest.layout)) {
404
+ return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
405
+ #
406
+ # The API is same-origin, so there is no VITE_API_ORIGIN: one worker serves
407
+ # this storefront and the endpoints it calls.
408
+ `;
409
+ }
386
410
  return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
387
411
  VITE_API_ORIGIN=https://api.${manifest.shop.domain}
388
412
  `;
@@ -506,9 +530,8 @@ function renderFrontendWorkflow(manifest, dir) {
506
530
  # Generated by \`vc init\` from voidcommerce.json.
507
531
  #
508
532
  # Builds the storefront and force-pushes the prerendered tree to
509
- # \`${FRONTEND_BRANCH}\`. Point GitHub Pages (or Cloudflare Pages) at that branch
510
- # and a push here is a live storefront. The worker deploys separately, from
511
- # \`void-dist\`.
533
+ # \`${FRONTEND_BRANCH}\`, which GitHub Pages serves. The worker deploys
534
+ # separately, from deploy.yml.
512
535
  on:
513
536
  push:
514
537
  branches: [main, master]
@@ -1310,7 +1333,7 @@ import color from "picocolors";
1310
1333
  // package.json
1311
1334
  var package_default = {
1312
1335
  name: "@saastemly/voidcommerce",
1313
- version: "0.5.0",
1336
+ version: "0.6.0",
1314
1337
  description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
1315
1338
  type: "module",
1316
1339
  license: "MIT",
@@ -1775,6 +1798,14 @@ That is the deploy. GitHub Actions regenerates the app from
1775
1798
  database and queue if they are not there, applies the migrations, and deploys
1776
1799
  the worker with this repository's secrets attached.
1777
1800
 
1801
+ **One worker serves everything.** The storefront is prerendered and folded
1802
+ into the worker's own static assets, so \`${manifest.shop.domain}\` answers with
1803
+ the shop, the admin panel and the API from a single origin. Cloudflare creates
1804
+ the hostname's DNS record and certificate itself, because it is a Worker
1805
+ custom domain. There is nothing to configure in any dashboard, no second
1806
+ branch to keep in step, and no CORS — the storefront's requests are
1807
+ first-party.
1808
+
1778
1809
  You do not need wrangler on your machine, and you do not need to be logged
1779
1810
  into it. You do not need to open the Cloudflare dashboard after the one step
1780
1811
  below.
@@ -1914,7 +1945,7 @@ export function check(present: Map<string, string | undefined>): Requirement[] {
1914
1945
 
1915
1946
  // src/generate/support.ts
1916
1947
  function renderDomainTs(layout) {
1917
- const derive = layout === "app" ? ` // One app: the worker IS the site. No separate API host, and no other
1948
+ const derive = layout !== "monorepo" ? ` // One origin: the worker IS the site. No separate API host, and no other
1918
1949
  // origin to trust. It answers on www too, but only at a zone's apex —
1919
1950
  // www.shop.example.com is not a name anybody types.
1920
1951
  const hosts = apex ? [host, www] : [host];
@@ -1925,7 +1956,7 @@ function renderDomainTs(layout) {
1925
1956
  return `/**
1926
1957
  * The shop's domain, and everything derived from it.
1927
1958
  *
1928
- * One variable. \`SHOP_DOMAIN\` gives ${layout === "app" ? "the worker's hostnames" : `api.<domain> for the worker and the
1959
+ * One variable. \`SHOP_DOMAIN\` gives ${layout !== "monorepo" ? "the worker's hostnames" : `api.<domain> for the worker and the
1929
1960
  * storefront's origins`}, the app's public origin, and the
1930
1961
  * CORS and CSRF allow-list. They were four settings that had to agree and
1931
1962
  * nothing checked that they did.
@@ -1968,11 +1999,13 @@ export const GITHUB_PAGES_AAAA = ["2606:50c0:8000::153", "2606:50c0:8001::153",
1968
1999
  /**
1969
2000
  * The storefront's records. DNS-only, never proxied: GitHub issues the
1970
2001
  * certificate itself and validates by reaching the origin. The API hostname
1971
- * is NOT here — it is a Cloudflare custom domain in wrangler.jsonc.${layout === "app" ? `
1972
- * One app has no storefront elsewhere, so this is always empty.` : ""}
2002
+ * is NOT here — it is a Cloudflare custom domain in wrangler.jsonc.${layout !== "monorepo" ? `
2003
+ * One worker serves the storefront, so this is always empty.` : ""}
1973
2004
  */
1974
2005
  export function storefrontRecords(domain: ShopDomain, pagesHost: string) {
1975
- if (!pagesHost${layout === "app" ? " || domain.frontendOrigins.length === 0" : ""}) return [];
2006
+ // Empty whenever one worker serves the storefront: Cloudflare creates the
2007
+ // hostname's record and certificate itself for a custom domain.
2008
+ if (!pagesHost || domain.frontendOrigins.length === 0) return [];
1976
2009
  // The record's name is relative to the zone: "@" at the apex, otherwise the
1977
2010
  // labels in front of it.
1978
2011
  const name = domain.apex ? "@" : domain.host.slice(0, -(domain.zone.length + 1));
@@ -2338,7 +2371,11 @@ async function generateFrontend(root, dir, manifest, result) {
2338
2371
  await put(root, at("void.json"), renderFrontendVoidJson(), result, "own");
2339
2372
  await put(root, at("tsconfig.json"), renderFrontendTsconfig(), result, "own");
2340
2373
  await put(root, at("pages/index.tsx"), renderStorefrontPage(manifest), result, "own");
2341
- await put(root, ".github/workflows/frontend-static.yml", renderFrontendWorkflow(manifest, dir), result, "regenerate");
2374
+ if (oneOrigin(manifest.layout)) {
2375
+ await retire(root, ".github/workflows/frontend-static.yml", result);
2376
+ } else {
2377
+ await put(root, ".github/workflows/frontend-static.yml", renderFrontendWorkflow(manifest, dir), result, "regenerate");
2378
+ }
2342
2379
  if (dir === "")
2343
2380
  return;
2344
2381
  await mergeJson(root, at("package.json"), { name: `${manifest.shop.domain.split(".")[0]}-frontend`, type: "module", private: true }, (pkg) => {
@@ -2440,8 +2477,13 @@ async function generateStrictApp(root, dir, manifest, result) {
2440
2477
  await put(root, at("db/seed.ts"), renderDbSeed(), result, gen);
2441
2478
  await put(root, at("pages/layout.tsx"), renderLayoutTsx(), result, gen);
2442
2479
  await put(root, at("pages/app.css"), renderAppCss(), result, gen);
2443
- await put(root, at("pages/index.server.ts"), renderIndexServer(), result, gen);
2444
- await put(root, at("pages/index.tsx"), INDEX_PAGE, result, gen);
2480
+ if (!oneOrigin(manifest.layout) || isSingleApp(manifest.layout)) {
2481
+ await put(root, at("pages/index.server.ts"), renderIndexServer(), result, gen);
2482
+ await put(root, at("pages/index.tsx"), INDEX_PAGE, result, gen);
2483
+ } else {
2484
+ await retire(root, at("pages/index.server.ts"), result);
2485
+ await retire(root, at("pages/index.tsx"), result);
2486
+ }
2445
2487
  await put(root, at("pages/api-dashboard/index.tsx"), renderDashboardPage(manifest), result, gen);
2446
2488
  await put(root, at("pages/api-dashboard/index.server.ts"), CLIENT_ONLY, result, gen);
2447
2489
  await put(root, at("pages/sign-in/index.tsx"), renderSignInPage(manifest), result, gen);
@@ -2909,7 +2951,7 @@ ${color3.red("NOT ready to go live.")}
2909
2951
  }
2910
2952
 
2911
2953
  // src/deploy/cloudflare.ts
2912
- import { existsSync as existsSync8, readFileSync as readFileSync3, writeFileSync } from "node:fs";
2954
+ import { copyFileSync, existsSync as existsSync8, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync } from "node:fs";
2913
2955
  import { dirname as dirname5, join as join8 } from "node:path";
2914
2956
  import color4 from "picocolors";
2915
2957
  var fail = (message) => {
@@ -2999,11 +3041,32 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
2999
3041
  const builder = findBuilder(app);
3000
3042
  if (!builder)
3001
3043
  return fail("no build tool: neither vite-plus nor vite is installed.");
3044
+ const merging = oneOrigin(project.manifest.layout) && !isSingleApp(project.manifest.layout);
3045
+ if (merging) {
3046
+ console.log(`
3047
+ ▸ ${builder.label} ${color4.dim("(the storefront)")}`);
3048
+ const storefront = await wrangler(builder.cmd, ["build"], project.root, true);
3049
+ if (storefront.code !== 0)
3050
+ return fail(`the storefront build failed (exit ${storefront.code}).`);
3051
+ }
3002
3052
  console.log(`
3003
- ▸ ${builder.label}`);
3053
+ ▸ ${builder.label}${merging ? color4.dim(" (the worker)") : ""}`);
3004
3054
  const built = await wrangler(builder.cmd, ["build"], app, true);
3005
3055
  if (built.code !== 0)
3006
3056
  return fail(`the build failed (exit ${built.code}).`);
3057
+ if (merging) {
3058
+ const from = join8(project.root, "dist", "client");
3059
+ const into = join8(app, "dist", "client");
3060
+ if (!existsSync8(from))
3061
+ return fail(`the storefront build emitted no ${from} — is the root a Void app with output "static"?`);
3062
+ if (!existsSync8(into))
3063
+ return fail(`the worker build emitted no ${into}; there is nothing to merge into.`);
3064
+ const merged = mergeTree(from, into, new Set([".vite"]));
3065
+ if (!existsSync8(join8(into, "index.html"))) {
3066
+ return fail("the storefront produced no index.html, so the shop has no front page.");
3067
+ }
3068
+ console.log(`${color4.green("✓")} folded ${merged} storefront file${merged === 1 ? "" : "s"} into the worker's assets`);
3069
+ }
3007
3070
  const emittedPath = join8(app, "dist", "ssr", "wrangler.json");
3008
3071
  if (!existsSync8(emittedPath))
3009
3072
  return fail(`the build emitted no ${emittedPath} — is this a Void app on the Cloudflare target?`);
@@ -3046,10 +3109,28 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3046
3109
  const url = `https://${workerHosts(project.manifest)[0]}`;
3047
3110
  console.log(`
3048
3111
  ${color4.green("Live:")} ${url}`);
3049
- if (hasFrontend(project.manifest.layout))
3112
+ if (hasFrontend(project.manifest.layout) && !oneOrigin(project.manifest.layout)) {
3050
3113
  console.log(color4.dim("The storefront deploys itself from GitHub Actions on push."));
3114
+ }
3051
3115
  return 0;
3052
3116
  }
3117
+ function mergeTree(from, into, skip) {
3118
+ let count = 0;
3119
+ for (const entry of readdirSync2(from, { withFileTypes: true })) {
3120
+ if (skip.has(entry.name))
3121
+ continue;
3122
+ const source = join8(from, entry.name);
3123
+ const target = join8(into, entry.name);
3124
+ if (entry.isDirectory()) {
3125
+ mkdirSync(target, { recursive: true });
3126
+ count += mergeTree(source, target, skip);
3127
+ } else {
3128
+ copyFileSync(source, target);
3129
+ count += 1;
3130
+ }
3131
+ }
3132
+ return count;
3133
+ }
3053
3134
 
3054
3135
  // src/import.ts
3055
3136
  import { readFile as readFile3 } from "node:fs/promises";
@@ -21,11 +21,12 @@ var LAYOUTS = [
21
21
  {
22
22
  id: "strict",
23
23
  label: "Strict (experimental)",
24
- hint: "the root IS the storefront, sharing one package.json; the worker is generated under .vc/app from the manifest and never hand-edited"
24
+ hint: "the root IS the storefront, sharing one package.json; the worker is generated under .vc/app and serves the storefront from its own assets — one origin, one deploy"
25
25
  }
26
26
  ];
27
27
  var isSingleApp = (layout) => layout === "app";
28
28
  var hasFrontend = (layout) => layout !== "app";
29
+ var oneOrigin = (layout) => layout !== "monorepo";
29
30
  var TWO_LABEL_SUFFIXES = new Set(["co", "com", "net", "org", "ac", "gov", "edu", "or", "ne", "in"]);
30
31
  function zoneOf(domain) {
31
32
  const labels = domain.trim().toLowerCase().replace(/\.$/, "").split(".");
@@ -42,7 +43,7 @@ function isApex(manifest) {
42
43
  }
43
44
  function workerHosts(manifest) {
44
45
  const { domain } = manifest.shop;
45
- if (!isSingleApp(manifest.layout))
46
+ if (!oneOrigin(manifest.layout))
46
47
  return [`api.${domain}`];
47
48
  return isApex(manifest) ? [domain, `www.${domain}`] : [domain];
48
49
  }
@@ -163,10 +164,10 @@ function validate(manifest) {
163
164
  if (dns.includes("simply")) {
164
165
  problems.push(`DNS at Simply.com cannot host ${workerHosts(manifest)[0]} — a Worker custom domain is created by Cloudflare, in a zone on Cloudflare`);
165
166
  }
166
- if (hasFrontend(manifest.layout) && manifest.shop.pagesHost && !/^[a-z0-9-]+\.github\.io$/i.test(manifest.shop.pagesHost)) {
167
+ if (!oneOrigin(manifest.layout) && manifest.shop.pagesHost && !/^[a-z0-9-]+\.github\.io$/i.test(manifest.shop.pagesHost)) {
167
168
  problems.push(`Pages host "${manifest.shop.pagesHost}" should be <owner>.github.io`);
168
169
  }
169
170
  return problems;
170
171
  }
171
172
 
172
- export { MANIFEST_FILE, LAYOUTS, isSingleApp, hasFrontend, zoneOf, zone, isApex, workerHosts, withRequired, has, chosenIn, envKeysOf, OWN_PACKAGES, specifier, packagesOf, readManifest, writeManifest, validate };
173
+ export { MANIFEST_FILE, LAYOUTS, isSingleApp, hasFrontend, oneOrigin, zoneOf, zone, isApex, workerHosts, withRequired, has, chosenIn, envKeysOf, OWN_PACKAGES, specifier, packagesOf, readManifest, writeManifest, validate };
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  routeProblem,
52
52
  strictDependencies,
53
53
  upsertJsonc
54
- } from "./index-5m3t0zfc.js";
54
+ } from "./index-f1wds190.js";
55
55
  import {
56
56
  allEnvKeys,
57
57
  envSummary,
@@ -59,7 +59,7 @@ import {
59
59
  renderEnvLocal,
60
60
  renderEnvProduction,
61
61
  renderEnvTs
62
- } from "./index-b9b4dawy.js";
62
+ } from "./index-2qt2yh3z.js";
63
63
  import {
64
64
  LAYOUTS,
65
65
  MANIFEST_FILE,
@@ -70,6 +70,7 @@ import {
70
70
  hasFrontend,
71
71
  isApex,
72
72
  isSingleApp,
73
+ oneOrigin,
73
74
  packagesOf,
74
75
  readManifest,
75
76
  specifier,
@@ -79,7 +80,7 @@ import {
79
80
  writeManifest,
80
81
  zone,
81
82
  zoneOf
82
- } from "./index-pz6m2hkm.js";
83
+ } from "./index-xx5p4b8d.js";
83
84
  import {
84
85
  AUTH_PLUGINS,
85
86
  CARRIERS,
@@ -147,6 +148,7 @@ export {
147
148
  parseWhoAmI,
148
149
  parseJsonc,
149
150
  packagesOf,
151
+ oneOrigin,
150
152
  isSingleApp,
151
153
  isApex,
152
154
  importCommand,
@@ -5,8 +5,8 @@ import {
5
5
  keysCommand,
6
6
  provisionKey,
7
7
  publicKeyFor
8
- } from "./index-b9b4dawy.js";
9
- import"./index-pz6m2hkm.js";
8
+ } from "./index-2qt2yh3z.js";
9
+ import"./index-xx5p4b8d.js";
10
10
  import"./index-844b3qn9.js";
11
11
  import"./index-0v6na3yp.js";
12
12
  export {
@@ -24,9 +24,10 @@ export declare const MANIFEST_FILE = "voidcommerce.json";
24
24
  * strict EXPERIMENTAL. A monorepo turned inside out. The REPOSITORY
25
25
  * ROOT is the storefront — its package.json, its pages, yours to
26
26
  * edit — and the worker is generated underneath it at .vc/app
27
- * from the manifest, gitignored, never hand-edited. Hosted the
28
- * way a monorepo is: the worker on api.<domain>, the storefront
29
- * on <domain>.
27
+ * from the manifest, gitignored, never hand-edited. ONE worker
28
+ * serves both at <domain>: the storefront's prerendered tree is
29
+ * folded into the worker's assets at build time, so there is one
30
+ * origin, one deploy, and one certificate Cloudflare issues.
30
31
  *
31
32
  * The first question, because it decides where `void init` runs and what
32
33
  * the domain means. It cannot change after init: moving files is not a
@@ -47,8 +48,26 @@ export declare const LAYOUTS: Array<{
47
48
  * storefront on the domain itself.
48
49
  */
49
50
  export declare const isSingleApp: (layout: Layout) => boolean;
50
- /** Does this layout carry a storefront of its own, and so a second origin to trust? */
51
+ /** Does this layout carry a storefront of its own, in the repository? */
51
52
  export declare const hasFrontend: (layout: Layout) => boolean;
53
+ /**
54
+ * Does ONE worker serve both the storefront and the API?
55
+ *
56
+ * `app` always did — it generates its storefront into the worker. `strict`
57
+ * now does too: the root's prerendered tree is merged into the worker's own
58
+ * assets directory at build time, so there is one origin, one hostname, one
59
+ * deploy, and one certificate that Cloudflare issues itself.
60
+ *
61
+ * That deletes the whole GitHub Pages half — the A/AAAA records, the CNAME
62
+ * file, the base-path rewriting — and with it a trap worth naming: GitHub
63
+ * treats any Cloudflare IP as ineligible for a certificate, so a proxied
64
+ * record silently stops issuance AND renewal about ninety days later, while
65
+ * GitHub's own health check still reports green.
66
+ *
67
+ * `monorepo` keeps the split, because there the frontend is a separate app
68
+ * that a person may well want hosted separately.
69
+ */
70
+ export declare const oneOrigin: (layout: Layout) => boolean;
52
71
  /** The zone a hostname most likely belongs to: the registrable domain. */
53
72
  export declare function zoneOf(domain: string): string;
54
73
  /** The zone this shop's records go in. */
@@ -88,7 +107,10 @@ export interface Manifest {
88
107
  * default that is safe to assume.
89
108
  */
90
109
  taxRegistered?: boolean | undefined;
91
- /** `saastemly.github.io` — the monorepo's frontend lives on GitHub Pages; the www record points here. */
110
+ /**
111
+ * `saastemly.github.io` — only a MONOREPO has a storefront on GitHub
112
+ * Pages. In `app` and `strict` the worker serves it, so this is unused.
113
+ */
92
114
  pagesHost?: string | undefined;
93
115
  };
94
116
  /** Chosen ids from each group, keyed by group id. */
package/dist/manifest.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  hasFrontend,
9
9
  isApex,
10
10
  isSingleApp,
11
+ oneOrigin,
11
12
  packagesOf,
12
13
  readManifest,
13
14
  specifier,
@@ -17,7 +18,7 @@ import {
17
18
  writeManifest,
18
19
  zone,
19
20
  zoneOf
20
- } from "./index-pz6m2hkm.js";
21
+ } from "./index-xx5p4b8d.js";
21
22
  import"./index-844b3qn9.js";
22
23
  import"./index-0v6na3yp.js";
23
24
  export {
@@ -30,6 +31,7 @@ export {
30
31
  specifier,
31
32
  readManifest,
32
33
  packagesOf,
34
+ oneOrigin,
33
35
  isSingleApp,
34
36
  isApex,
35
37
  hasFrontend,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,8 +1,8 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import color from "picocolors";
4
4
  import { allEnvKeys } from "../generate/env";
5
- import { hasFrontend, workerHosts, writeManifest } from "../manifest";
5
+ import { hasFrontend, isSingleApp, oneOrigin, workerHosts, writeManifest } from "../manifest";
6
6
  import type { Project } from "../project";
7
7
  import { parseJsonc, upsertJsonc } from "./jsonc";
8
8
  import { preflight, printPreflight } from "./preflight";
@@ -130,10 +130,46 @@ export async function deployCloudflare(project: Project, opts: CloudflareOptions
130
130
  // 4. build.
131
131
  const builder = findBuilder(app);
132
132
  if (!builder) return fail("no build tool: neither vite-plus nor vite is installed.");
133
- console.log(`\n▸ ${builder.label}`);
133
+
134
+ // In strict the STOREFRONT is a second Void app, at the repository root,
135
+ // and it has to be built first: its prerendered tree is folded into the
136
+ // worker's assets below, and the worker's build is what emits the config
137
+ // that names that directory.
138
+ const merging = oneOrigin(project.manifest.layout) && !isSingleApp(project.manifest.layout);
139
+ if (merging) {
140
+ console.log(`\n▸ ${builder.label} ${color.dim("(the storefront)")}`);
141
+ const storefront = await wrangler(builder.cmd, ["build"], project.root, true);
142
+ if (storefront.code !== 0) return fail(`the storefront build failed (exit ${storefront.code}).`);
143
+ }
144
+
145
+ console.log(`\n▸ ${builder.label}${merging ? color.dim(" (the worker)") : ""}`);
134
146
  const built = await wrangler(builder.cmd, ["build"], app, true);
135
147
  if (built.code !== 0) return fail(`the build failed (exit ${built.code}).`);
136
148
 
149
+ // 4b. Fold the storefront into the worker's assets.
150
+ //
151
+ // Void's worker already serves static assets: it runs first for every
152
+ // request and calls `env.ASSETS.fetch` only when its own router 404s. So
153
+ // the storefront's prerendered files land in the same directory and are
154
+ // reached by exactly that fallback — no routing rules, no second origin.
155
+ //
156
+ // Filenames are content-hashed, so the two trees merge without collision.
157
+ // `.vite/manifest.json` is the one exception and is build metadata that
158
+ // should never be uploaded at all.
159
+ if (merging) {
160
+ const from = join(project.root, "dist", "client");
161
+ const into = join(app, "dist", "client");
162
+ if (!existsSync(from)) return fail(`the storefront build emitted no ${from} — is the root a Void app with output "static"?`);
163
+ if (!existsSync(into)) return fail(`the worker build emitted no ${into}; there is nothing to merge into.`);
164
+ const merged = mergeTree(from, into, new Set([".vite"]));
165
+ // The worker owns "/" only if it has a page for it. It must not, or the
166
+ // shop's front door is unreachable — so this is checked, not assumed.
167
+ if (!existsSync(join(into, "index.html"))) {
168
+ return fail("the storefront produced no index.html, so the shop has no front page.");
169
+ }
170
+ console.log(`${color.green("✓")} folded ${merged} storefront file${merged === 1 ? "" : "s"} into the worker's assets`);
171
+ }
172
+
137
173
  // 5. scrub.
138
174
  const emittedPath = join(app, "dist", "ssr", "wrangler.json");
139
175
  if (!existsSync(emittedPath)) return fail(`the build emitted no ${emittedPath} — is this a Void app on the Cloudflare target?`);
@@ -183,6 +219,34 @@ export async function deployCloudflare(project: Project, opts: CloudflareOptions
183
219
  const domain = project.manifest.shop.domain;
184
220
  const url = `https://${workerHosts(project.manifest)[0]}`;
185
221
  console.log(`\n${color.green("Live:")} ${url}`);
186
- if (hasFrontend(project.manifest.layout)) console.log(color.dim("The storefront deploys itself from GitHub Actions on push."));
222
+ if (hasFrontend(project.manifest.layout) && !oneOrigin(project.manifest.layout)) {
223
+ console.log(color.dim("The storefront deploys itself from GitHub Actions on push."));
224
+ }
187
225
  return 0;
188
226
  }
227
+
228
+
229
+ /**
230
+ * Copy one built tree over another, skipping named directories.
231
+ *
232
+ * Deliberately additive rather than a replace: the worker's own client
233
+ * bundles are already in the target and must survive. Returns how many files
234
+ * were written, so the deploy can say so rather than claiming success
235
+ * silently.
236
+ */
237
+ function mergeTree(from: string, into: string, skip: Set<string>): number {
238
+ let count = 0;
239
+ for (const entry of readdirSync(from, { withFileTypes: true })) {
240
+ if (skip.has(entry.name)) continue;
241
+ const source = join(from, entry.name);
242
+ const target = join(into, entry.name);
243
+ if (entry.isDirectory()) {
244
+ mkdirSync(target, { recursive: true });
245
+ count += mergeTree(source, target, skip);
246
+ } else {
247
+ copyFileSync(source, target);
248
+ count += 1;
249
+ }
250
+ }
251
+ return count;
252
+ }
@@ -176,6 +176,14 @@ That is the deploy. GitHub Actions regenerates the app from
176
176
  database and queue if they are not there, applies the migrations, and deploys
177
177
  the worker with this repository's secrets attached.
178
178
 
179
+ **One worker serves everything.** The storefront is prerendered and folded
180
+ into the worker's own static assets, so \`${manifest.shop.domain}\` answers with
181
+ the shop, the admin panel and the API from a single origin. Cloudflare creates
182
+ the hostname's DNS record and certificate itself, because it is a Worker
183
+ custom domain. There is nothing to configure in any dashboard, no second
184
+ branch to keep in step, and no CORS — the storefront's requests are
185
+ first-party.
186
+
179
187
  You do not need wrangler on your machine, and you do not need to be logged
180
188
  into it. You do not need to open the Cloudflare dashboard after the one step
181
189
  below.
@@ -1,4 +1,4 @@
1
- import type { Manifest } from "../manifest";
1
+ import { type Manifest, oneOrigin } from "../manifest";
2
2
 
3
3
  /**
4
4
  * The storefront: a static Void site that talks to the worker over HTTP.
@@ -24,6 +24,22 @@ export const FRONTEND_OUT = "dist/client";
24
24
  export const FRONTEND_BRANCH = "frontend-static";
25
25
 
26
26
  export function renderFrontendApiTs(manifest: Manifest): string {
27
+ if (oneOrigin(manifest.layout)) {
28
+ return `import { createAuthClient } from "better-auth/react";
29
+ import { commerceClient } from "@saastemly/better-commerce/client";
30
+
31
+ /**
32
+ * The one client the storefront talks through.
33
+ *
34
+ * SAME ORIGIN. One worker serves this page and the API it calls, so there is
35
+ * no address to configure, nothing to bake at build time, and nothing that
36
+ * can point at the wrong place. Cookies are first-party, so no SameSite=None
37
+ * and no CORS preflight on any request.
38
+ */
39
+ export const API_ORIGIN = "";
40
+ export const api = createAuthClient({ plugins: [commerceClient()] });
41
+ `;
42
+ }
27
43
  return `/// <reference types="vite/client" />
28
44
  import { createAuthClient } from "better-auth/react";
29
45
  import { commerceClient } from "@saastemly/better-commerce/client";
@@ -45,6 +61,13 @@ export const api = createAuthClient({ baseURL: API_ORIGIN, plugins: [commerceCli
45
61
  }
46
62
 
47
63
  export function renderFrontendEnvProduction(manifest: Manifest): string {
64
+ if (oneOrigin(manifest.layout)) {
65
+ return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
66
+ #
67
+ # The API is same-origin, so there is no VITE_API_ORIGIN: one worker serves
68
+ # this storefront and the endpoints it calls.
69
+ `;
70
+ }
48
71
  return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
49
72
  VITE_API_ORIGIN=https://api.${manifest.shop.domain}
50
73
  `;
@@ -183,9 +206,8 @@ export function renderFrontendWorkflow(manifest: Manifest, dir: string): string
183
206
  # Generated by \`vc init\` from voidcommerce.json.
184
207
  #
185
208
  # Builds the storefront and force-pushes the prerendered tree to
186
- # \`${FRONTEND_BRANCH}\`. Point GitHub Pages (or Cloudflare Pages) at that branch
187
- # and a push here is a live storefront. The worker deploys separately, from
188
- # \`void-dist\`.
209
+ # \`${FRONTEND_BRANCH}\`, which GitHub Pages serves. The worker deploys
210
+ # separately, from deploy.yml.
189
211
  on:
190
212
  push:
191
213
  branches: [main, master]
@@ -2,7 +2,7 @@ import { cp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/pr
2
2
  import { existsSync, lstatSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { CHOICES } from "../catalog";
5
- import { MANIFEST_FILE, type Manifest, has, isSingleApp, packagesOf, specifier, workerHosts, writeManifest, zone } from "../manifest";
5
+ import { MANIFEST_FILE, type Manifest, has, isSingleApp, oneOrigin, packagesOf, specifier, workerHosts, writeManifest, zone } from "../manifest";
6
6
  import { renderAuthTs } from "./auth";
7
7
  import {
8
8
  envSummary,
@@ -255,7 +255,7 @@ async function generateApi(root: string, dir: string, manifest: Manifest, result
255
255
  );
256
256
  }
257
257
 
258
- /** The static storefront on GitHub Pages. Only in a monorepo. */
258
+ /** The storefront: served by the worker in strict, by GitHub Pages in a monorepo. */
259
259
  async function generateFrontend(root: string, dir: string, manifest: Manifest, result: GenerateResult) {
260
260
  const at = (file: string) => join(dir, file);
261
261
 
@@ -265,7 +265,14 @@ async function generateFrontend(root: string, dir: string, manifest: Manifest, r
265
265
  await put(root, at("void.json"), renderFrontendVoidJson(), result, "own");
266
266
  await put(root, at("tsconfig.json"), renderFrontendTsconfig(), result, "own");
267
267
  await put(root, at("pages/index.tsx"), renderStorefrontPage(manifest), result, "own");
268
- await put(root, ".github/workflows/frontend-static.yml", renderFrontendWorkflow(manifest, dir), result, "regenerate");
268
+ // A separate publishing workflow exists only when the storefront has a
269
+ // separate host. In strict the worker serves it, so the branch, the
270
+ // workflow, the CNAME file and the base-path rewriting all go away.
271
+ if (oneOrigin(manifest.layout)) {
272
+ await retire(root, ".github/workflows/frontend-static.yml", result);
273
+ } else {
274
+ await put(root, ".github/workflows/frontend-static.yml", renderFrontendWorkflow(manifest, dir), result, "regenerate");
275
+ }
269
276
 
270
277
  // A monorepo's frontend is its own workspace; strict's shares the root's
271
278
  // one package.json, which generateStrictRoot has already written.
@@ -404,8 +411,20 @@ async function generateStrictApp(root: string, dir: string, manifest: Manifest,
404
411
  await put(root, at("db/seed.ts"), renderDbSeed(), result, gen);
405
412
  await put(root, at("pages/layout.tsx"), renderLayoutTsx(), result, gen);
406
413
  await put(root, at("pages/app.css"), renderAppCss(), result, gen);
407
- await put(root, at("pages/index.server.ts"), renderIndexServer(), result, gen);
408
- await put(root, at("pages/index.tsx"), INDEX_PAGE, result, gen);
414
+ // In strict the STOREFRONT owns "/": its prerendered index.html is merged
415
+ // into the worker's assets, and the worker reaches assets only by 404ing
416
+ // first. A worker index page here would shadow the shop's front door.
417
+ if (!oneOrigin(manifest.layout) || isSingleApp(manifest.layout)) {
418
+ await put(root, at("pages/index.server.ts"), renderIndexServer(), result, gen);
419
+ await put(root, at("pages/index.tsx"), INDEX_PAGE, result, gen);
420
+ } else {
421
+ // Deleted, not merely skipped. A shop regenerated from the older
422
+ // two-origin layout still has these on disk, and a leftover index page
423
+ // would answer "/" instead of 404ing through to the storefront — the
424
+ // shop would silently serve a blank redirect as its front door.
425
+ await retire(root, at("pages/index.server.ts"), result);
426
+ await retire(root, at("pages/index.tsx"), result);
427
+ }
409
428
  await put(root, at("pages/api-dashboard/index.tsx"), renderDashboardPage(manifest), result, gen);
410
429
  await put(root, at("pages/api-dashboard/index.server.ts"), CLIENT_ONLY, result, gen);
411
430
  await put(root, at("pages/sign-in/index.tsx"), renderSignInPage(manifest), result, gen);
@@ -11,8 +11,8 @@ import { type Layout, type Manifest, has } from "../manifest";
11
11
 
12
12
  export function renderDomainTs(layout: Layout): string {
13
13
  const derive =
14
- layout === "app"
15
- ? ` // One app: the worker IS the site. No separate API host, and no other
14
+ layout !== "monorepo"
15
+ ? ` // One origin: the worker IS the site. No separate API host, and no other
16
16
  // origin to trust. It answers on www too, but only at a zone's apex —
17
17
  // www.shop.example.com is not a name anybody types.
18
18
  const hosts = apex ? [host, www] : [host];
@@ -24,7 +24,7 @@ export function renderDomainTs(layout: Layout): string {
24
24
  return `/**
25
25
  * The shop's domain, and everything derived from it.
26
26
  *
27
- * One variable. \`SHOP_DOMAIN\` gives ${layout === "app" ? "the worker's hostnames" : "api.<domain> for the worker and the\n * storefront's origins"}, the app's public origin, and the
27
+ * One variable. \`SHOP_DOMAIN\` gives ${layout !== "monorepo" ? "the worker's hostnames" : "api.<domain> for the worker and the\n * storefront's origins"}, the app's public origin, and the
28
28
  * CORS and CSRF allow-list. They were four settings that had to agree and
29
29
  * nothing checked that they did.
30
30
  *
@@ -67,11 +67,13 @@ export const GITHUB_PAGES_AAAA = ["2606:50c0:8000::153", "2606:50c0:8001::153",
67
67
  * The storefront's records. DNS-only, never proxied: GitHub issues the
68
68
  * certificate itself and validates by reaching the origin. The API hostname
69
69
  * is NOT here — it is a Cloudflare custom domain in wrangler.jsonc.${
70
- layout === "app" ? "\n * One app has no storefront elsewhere, so this is always empty." : ""
70
+ layout !== "monorepo" ? "\n * One worker serves the storefront, so this is always empty." : ""
71
71
  }
72
72
  */
73
73
  export function storefrontRecords(domain: ShopDomain, pagesHost: string) {
74
- if (!pagesHost${layout === "app" ? " || domain.frontendOrigins.length === 0" : ""}) return [];
74
+ // Empty whenever one worker serves the storefront: Cloudflare creates the
75
+ // hostname's record and certificate itself for a custom domain.
76
+ if (!pagesHost || domain.frontendOrigins.length === 0) return [];
75
77
  // The record's name is relative to the zone: "@" at the apex, otherwise the
76
78
  // labels in front of it.
77
79
  const name = domain.apex ? "@" : domain.host.slice(0, -(domain.zone.length + 1));
package/src/init.ts CHANGED
@@ -94,7 +94,7 @@ export async function init(args: string[]): Promise<number> {
94
94
  } else {
95
95
  const parts: Array<[dir: string, starter: string]> = [
96
96
  ["api", "the D1 starter — this is the worker"],
97
- ["frontend", "Static Pagesthis is prerendered for GitHub Pages"],
97
+ ["frontend", "the storefrontprerendered and served from the worker's own assets"],
98
98
  ];
99
99
  for (const [dir, starter] of parts) {
100
100
  const target = join(root, dir);
package/src/manifest.ts CHANGED
@@ -30,9 +30,10 @@ export const MANIFEST_FILE = "voidcommerce.json";
30
30
  * strict EXPERIMENTAL. A monorepo turned inside out. The REPOSITORY
31
31
  * ROOT is the storefront — its package.json, its pages, yours to
32
32
  * edit — and the worker is generated underneath it at .vc/app
33
- * from the manifest, gitignored, never hand-edited. Hosted the
34
- * way a monorepo is: the worker on api.<domain>, the storefront
35
- * on <domain>.
33
+ * from the manifest, gitignored, never hand-edited. ONE worker
34
+ * serves both at <domain>: the storefront's prerendered tree is
35
+ * folded into the worker's assets at build time, so there is one
36
+ * origin, one deploy, and one certificate Cloudflare issues.
36
37
  *
37
38
  * The first question, because it decides where `void init` runs and what
38
39
  * the domain means. It cannot change after init: moving files is not a
@@ -54,7 +55,7 @@ export const LAYOUTS: Array<{ id: Layout; label: string; hint: string }> = [
54
55
  {
55
56
  id: "strict",
56
57
  label: "Strict (experimental)",
57
- hint: "the root IS the storefront, sharing one package.json; the worker is generated under .vc/app from the manifest and never hand-edited",
58
+ hint: "the root IS the storefront, sharing one package.json; the worker is generated under .vc/app and serves the storefront from its own assets — one origin, one deploy",
58
59
  },
59
60
  ];
60
61
 
@@ -68,9 +69,28 @@ export const LAYOUTS: Array<{ id: Layout; label: string; hint: string }> = [
68
69
  */
69
70
  export const isSingleApp = (layout: Layout): boolean => layout === "app";
70
71
 
71
- /** Does this layout carry a storefront of its own, and so a second origin to trust? */
72
+ /** Does this layout carry a storefront of its own, in the repository? */
72
73
  export const hasFrontend = (layout: Layout): boolean => layout !== "app";
73
74
 
75
+ /**
76
+ * Does ONE worker serve both the storefront and the API?
77
+ *
78
+ * `app` always did — it generates its storefront into the worker. `strict`
79
+ * now does too: the root's prerendered tree is merged into the worker's own
80
+ * assets directory at build time, so there is one origin, one hostname, one
81
+ * deploy, and one certificate that Cloudflare issues itself.
82
+ *
83
+ * That deletes the whole GitHub Pages half — the A/AAAA records, the CNAME
84
+ * file, the base-path rewriting — and with it a trap worth naming: GitHub
85
+ * treats any Cloudflare IP as ineligible for a certificate, so a proxied
86
+ * record silently stops issuance AND renewal about ninety days later, while
87
+ * GitHub's own health check still reports green.
88
+ *
89
+ * `monorepo` keeps the split, because there the frontend is a separate app
90
+ * that a person may well want hosted separately.
91
+ */
92
+ export const oneOrigin = (layout: Layout): boolean => layout !== "monorepo";
93
+
74
94
  /**
75
95
  * Public suffixes whose second label is not a registrable name, so
76
96
  * `shop.example.co.uk` is a subdomain of `example.co.uk`, not of `co.uk`.
@@ -100,7 +120,9 @@ export function isApex(manifest: Manifest): boolean {
100
120
  /** Every hostname the worker answers on — the wrangler routes, in order. */
101
121
  export function workerHosts(manifest: Manifest): string[] {
102
122
  const { domain } = manifest.shop;
103
- if (!isSingleApp(manifest.layout)) return [`api.${domain}`];
123
+ // Only a monorepo puts the worker on its own hostname; everything else
124
+ // serves the shop and its API from one origin.
125
+ if (!oneOrigin(manifest.layout)) return [`api.${domain}`];
104
126
  return isApex(manifest) ? [domain, `www.${domain}`] : [domain];
105
127
  }
106
128
 
@@ -145,7 +167,10 @@ export interface Manifest {
145
167
  * default that is safe to assume.
146
168
  */
147
169
  taxRegistered?: boolean | undefined;
148
- /** `saastemly.github.io` — the monorepo's frontend lives on GitHub Pages; the www record points here. */
170
+ /**
171
+ * `saastemly.github.io` — only a MONOREPO has a storefront on GitHub
172
+ * Pages. In `app` and `strict` the worker serves it, so this is unused.
173
+ */
149
174
  pagesHost?: string | undefined;
150
175
  };
151
176
  /** Chosen ids from each group, keyed by group id. */
@@ -296,7 +321,7 @@ export function validate(manifest: Manifest): string[] {
296
321
  `DNS at Simply.com cannot host ${workerHosts(manifest)[0]} — a Worker custom domain is created by Cloudflare, in a zone on Cloudflare`,
297
322
  );
298
323
  }
299
- if (hasFrontend(manifest.layout) && manifest.shop.pagesHost && !/^[a-z0-9-]+\.github\.io$/i.test(manifest.shop.pagesHost)) {
324
+ if (!oneOrigin(manifest.layout) && manifest.shop.pagesHost && !/^[a-z0-9-]+\.github\.io$/i.test(manifest.shop.pagesHost)) {
300
325
  problems.push(`Pages host "${manifest.shop.pagesHost}" should be <owner>.github.io`);
301
326
  }
302
327
  return problems;
package/src/wizard.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import color from "picocolors";
3
3
  import { GROUPS, type Group } from "./catalog";
4
- import { LAYOUTS, type Layout, type Manifest, zoneOf } from "./manifest";
4
+ import { LAYOUTS, type Layout, type Manifest, zoneOf , oneOrigin} from "./manifest";
5
5
 
6
6
  /**
7
7
  * The forms.
@@ -98,6 +98,10 @@ async function askGroup(group: Group, previous: string[] | undefined, alsoRequir
98
98
  export async function runWizard(existing: Manifest | null, layout: Layout): Promise<Manifest> {
99
99
  const monorepo = layout === "monorepo";
100
100
  const frontend = layout !== "app";
101
+ // Only a monorepo publishes its storefront to GitHub Pages. In `strict`
102
+ // the worker serves it from its own assets, so there is no Pages host to
103
+ // ask for — and asking would imply a second origin that does not exist.
104
+ const onPages = !oneOrigin(layout);
101
105
 
102
106
  const shop = unwrap(
103
107
  await p.group(
@@ -171,7 +175,7 @@ export async function runWizard(existing: Manifest | null, layout: Layout): Prom
171
175
  initialValue: existing?.shop.locale ?? "",
172
176
  validate: (value) => (/^[a-z]{2}(-[A-Z]{2})?$/.test(value.trim()) ? undefined : "like da, or en-GB."),
173
177
  }),
174
- ...(frontend
178
+ ...(onPages
175
179
  ? {
176
180
  pagesHost: () =>
177
181
  p.text({
@@ -211,7 +215,7 @@ export async function runWizard(existing: Manifest | null, layout: Layout): Prom
211
215
  locale: shop.locale.trim(),
212
216
  taxRegistered: (shop as { taxRegistered?: string }).taxRegistered === "yes",
213
217
  zone: (shop as { zone?: string }).zone?.trim().toLowerCase() || undefined,
214
- pagesHost: frontend ? (shop as { pagesHost?: string }).pagesHost?.trim().toLowerCase() || undefined : undefined,
218
+ pagesHost: onPages ? (shop as { pagesHost?: string }).pagesHost?.trim().toLowerCase() || undefined : undefined,
215
219
  },
216
220
  chosen,
217
221
  };