@saastemly/voidcommerce 0.4.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.
@@ -1,57 +1,75 @@
1
1
  import { DIST_BRANCH, DIST_DIR } from "../dist";
2
2
  import type { Manifest } from "../manifest";
3
+ import { PRIVATE_KEY_VAR, SECRETS_FILE } from "../deploy/secrets";
3
4
 
4
5
  /**
5
- * The workflow that turns a push into a deploy.
6
+ * The workflow that turns a push into a live shop.
6
7
  *
7
- * ── Why a branch and not a build ─────────────────────────────────────────
8
+ * ── Why GitHub Actions deploys, and not Cloudflare's own build ───────────
8
9
  *
9
- * Cloudflare's Workers Builds watches a repository and builds what it finds.
10
- * A strict repository has no app in it `.vc/app` is generated and
11
- * gitignored — and Cloudflare documents nothing about a build command that
12
- * writes the source it then builds. The install step's ordering against a
13
- * generated `package.json` is undocumented, which is not a thing to guess at
14
- * in the path that puts a shop on the internet.
10
+ * Cloudflare's Workers Builds can watch a repository, and for a while that
11
+ * was the plan. Three things make it the wrong end to drive from.
15
12
  *
16
- * So the generator runs in GitHub Actions, where it is ordinary, and pushes
17
- * the result to its own branch. `main` stays the manifest and the data;
18
- * `void-dist` is a plain Void app with a committed `package.json`, lockfile
19
- * and `wrangler.jsonc` that anything can build from a cold checkout.
20
- * Cloudflare is pointed at that branch and needs to know nothing about
21
- * voidcommerce which also means the deploy path is one a person can run by
22
- * hand when CI is not the answer.
13
+ * The build secret. `.env.secrets` is ciphertext, so something must hold
14
+ * the key that opens it. Workers Builds takes build variables ONLY from the
15
+ * dashboard — there is no repository file for them so the one credential
16
+ * that makes push-to-deploy work would have to be pasted into a web page.
17
+ *
18
+ * The token. The API token Cloudflare generates for its own builds has no
19
+ * D1 and no Queues permission, so it cannot create this shop's database.
20
+ * Replacing it is another dashboard visit.
21
+ *
22
+ * The bootstrap. Connecting the repository is itself a dashboard step, on a
23
+ * Worker that has to exist first.
24
+ *
25
+ * GitHub already has what is needed. A person who can push is logged into
26
+ * `gh`, and `gh` can write repository secrets from the terminal — so
27
+ * `vc link` puts the key and the token there in one command, and this
28
+ * workflow spends them. Nothing is typed into a web page except the token
29
+ * itself, once, because Cloudflare will not issue one any other way.
30
+ *
31
+ * ── The `void-dist` branch is still built ────────────────────────────────
32
+ *
33
+ * It is no longer the deploy path, but it stays: a plain Void app with a
34
+ * committed lockfile and `wrangler.jsonc` that anything can build from a
35
+ * cold checkout. It is what makes the deploy reproducible by hand, and it
36
+ * is the escape hatch for anyone who does want Cloudflare's build after all.
37
+ *
38
+ * @see https://developers.cloudflare.com/workers/ci-cd/builds/configuration/#api-token
23
39
  */
24
40
  export function renderDistWorkflow(manifest: Manifest): string {
25
- const worker = manifest.shop.domain.split(".")[0];
26
- return `name: Build the deployable app
41
+ return `name: Deploy the shop
27
42
 
28
43
  # Generated by \`vc init\` from voidcommerce.json.
29
44
  #
30
- # Every push to main regenerates the Void app from the manifest and force-pushes
31
- # it to \`${DIST_BRANCH}\`. Cloudflare's Workers Builds watches THAT branch and
32
- # deploys it, so a push here is a deploy — and no Cloudflare credential is
33
- # stored in GitHub.
45
+ # A push to main regenerates the Void app from the manifest, publishes it to
46
+ # \`${DIST_BRANCH}\` as a standalone tree, and deploys it to Cloudflare.
34
47
  #
35
- # The Cloudflare side is configured once, in the dashboard, for the Worker
36
- # named \`${worker}\` (Settings → Builds). DEPLOY.md has the settings and the
37
- # one non-obvious part: the build's API token needs D1:Edit, which the token
38
- # Cloudflare generates for you does not have.
48
+ # The credentials come from the repository, set once by \`vc link\`:
49
+ #
50
+ # secrets.CLOUDFLARE_API_TOKEN deploys, and creates D1 + the queue
51
+ # secrets.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE}
52
+ # vars.CLOUDFLARE_ACCOUNT_ID which account (an id, not a credential)
53
+ #
54
+ # Nothing here needs a Cloudflare dashboard visit and nothing needs wrangler
55
+ # on your machine. See DEPLOY.md.
39
56
  on:
40
57
  push:
41
58
  branches: [main, master]
42
59
  workflow_dispatch:
43
60
 
44
- # A later push wins: an older tree must never overwrite a newer one.
61
+ # A later push wins: an older tree must never overwrite a newer one, and two
62
+ # deploys must never race for the same worker.
45
63
  concurrency:
46
- group: ${DIST_BRANCH}-\${{ github.repository }}
64
+ group: deploy-\${{ github.repository }}
47
65
  cancel-in-progress: true
48
66
 
49
- # contents: write is what lets GITHUB_TOKEN force-push the branch. Nothing else.
50
67
  permissions:
51
68
  contents: write
52
69
 
53
70
  jobs:
54
- build:
71
+ # ── Regenerate, and publish the standalone tree ────────────────────────
72
+ dist:
55
73
  runs-on: ubuntu-latest
56
74
  steps:
57
75
  - uses: actions/checkout@v6
@@ -92,6 +110,50 @@ jobs:
92
110
  git add -A
93
111
  git commit -q -m "\${{ github.sha }} — ${manifest.shop.domain}"
94
112
  git push -f "https://x-access-token:\${GITHUB_TOKEN}@github.com/\${{ github.repository }}.git" ${DIST_BRANCH}
113
+
114
+ # ── Deploy it ──────────────────────────────────────────────────────────
115
+ deploy:
116
+ needs: dist
117
+ runs-on: ubuntu-latest
118
+ environment:
119
+ name: production
120
+ url: https://${manifest.shop.domain}
121
+ env:
122
+ CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
123
+ CLOUDFLARE_ACCOUNT_ID: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
124
+ ${PRIVATE_KEY_VAR}: \${{ secrets.${PRIVATE_KEY_VAR} }}
125
+ steps:
126
+ - uses: actions/checkout@v6
127
+ - uses: oven-sh/setup-bun@v2
128
+
129
+ # Checked before anything is built, so a repository that was never
130
+ # linked says so in five seconds rather than four minutes.
131
+ - name: Are the credentials here?
132
+ run: |
133
+ set -euo pipefail
134
+ missing=""
135
+ [ -n "\${CLOUDFLARE_API_TOKEN:-}" ] || missing="$missing CLOUDFLARE_API_TOKEN"
136
+ [ -n "\${${PRIVATE_KEY_VAR}:-}" ] || missing="$missing ${PRIVATE_KEY_VAR}"
137
+ if [ -n "$missing" ]; then
138
+ echo "::error::this repository has no$missing. Run 'vc link' once, from a checkout."
139
+ exit 1
140
+ fi
141
+
142
+ - run: bun install --frozen-lockfile
143
+
144
+ # One command: preflight, provision D1 and the queue if they are not
145
+ # there, build, strip the values Void bakes into the worker's plaintext
146
+ # vars, apply the committed migrations, and deploy with the decrypted
147
+ # secrets attached to the version. Provisioning is idempotent — it looks
148
+ # a resource up by name before creating it — so this is safe every run.
149
+ - name: vc deploy --cloudflare --provision
150
+ run: bunx vc deploy --cloudflare --provision
151
+
152
+ # An upsert, so it is safe on every deploy and costs one pass when
153
+ # nothing in data/ has changed. A failure here does not un-deploy a
154
+ # working shop, so it warns rather than failing the run.
155
+ - name: Push the catalogue
156
+ run: bunx vc import || echo "::warning::the catalogue did not import; the shop is up but its products may be stale"
95
157
  `;
96
158
  }
97
159
 
@@ -103,131 +165,124 @@ export function renderDeployReadme(manifest: Manifest, zone: string, hosts: stri
103
165
  \`${manifest.shop.domain}\` on Cloudflare Workers. Generated by \`vc init\`;
104
166
  regenerate with \`vc generate\`.
105
167
 
106
- ## The loop, once it is set up
107
-
108
- Push to \`main\`. GitHub Actions regenerates the app from \`voidcommerce.json\`
109
- and force-pushes it to \`${DIST_BRANCH}\`; Cloudflare's Workers Builds builds that
110
- branch and deploys it. Nothing else runs.
111
-
112
- Your secrets survive every deploy — \`wrangler deploy\` never deletes a secret.
113
- Plaintext \`vars\` are replaced from \`.env.production\` on each deploy, which is
114
- why no credential is allowed in that file.
115
-
116
- ## Once, before the first push
117
-
118
- Each of these either creates something in your Cloudflare account or holds a
119
- credential, so none of them can live in a repository.
120
-
121
- ### 1. The zone
122
-
123
- \`${zone}\` must be on Cloudflare. A Worker custom domain is a record Cloudflare
124
- creates in its own zone, so a domain hosted anywhere else cannot have one. The
125
- worker answers on ${hosts.map((h) => `\`${h}\``).join(" and ")}.
126
-
127
- ### 2. The database, the queue, and the account id
168
+ ## The loop
128
169
 
129
170
  \`\`\`sh
130
- vc deploy --cloudflare --provision
171
+ git push
131
172
  \`\`\`
132
173
 
133
- Idempotent: it creates nothing that already exists. It pins your account id,
134
- creates the D1 database and the queue, and records both in \`wrangler.jsonc\`
135
- and \`voidcommerce.json\` so every later generate carries the real ids.
136
- **Commit that change.**
174
+ That is the deploy. GitHub Actions regenerates the app from
175
+ \`voidcommerce.json\`, publishes a standalone copy to \`${DIST_BRANCH}\`, creates the
176
+ database and queue if they are not there, applies the migrations, and deploys
177
+ the worker with this repository's secrets attached.
137
178
 
138
- ### 3. The secrets
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.
139
186
 
140
- They live in the repository, encrypted:
187
+ You do not need wrangler on your machine, and you do not need to be logged
188
+ into it. You do not need to open the Cloudflare dashboard after the one step
189
+ below.
190
+
191
+ ## Once, before the first push
141
192
 
142
193
  \`\`\`sh
143
- vc secrets --init # every required key, as \`unset\`
144
- # put the real values in, then
145
- bunx dotenvx encrypt -f .env.secrets # ciphertext; commit this
194
+ gh repo create --source=. --private --push # if there is no repo yet
195
+ vc link
146
196
  \`\`\`
147
197
 
148
- \`.env.secrets\` is committed and \`.env.keys\` is not. The deploy decrypts it and
149
- hands the values to \`wrangler deploy --secrets-file\`, which stores them as
150
- real Worker secrets — not as plaintext \`vars\`, which anyone with dashboard
151
- access can read.
152
-
153
- That turns one \`wrangler secret put\` per value into one build variable, set
154
- once in step 5. It also means the shop rebuilds from a checkout.
198
+ \`vc link\` does three things and stores all of them on the GitHub repository:
155
199
 
156
- **Know the tradeoff.** Ciphertext in git is permanent: if the private key ever
157
- leaks, every secret in the history is readable, including ones you rotated.
158
- \`wrangler secret put\` does not have that property, and stays available for
159
- anything you would rather never commit. \`vc preflight\` counts a secret the
160
- repository declares as present, and refuses any value committed in the clear.
200
+ | what | where | why |
201
+ |---|---|---|
202
+ | \`${PRIVATE_KEY_VAR}\` | repository **secret** | opens \`${SECRETS_FILE}\`. Generated by \`vc link\`, never written to disk |
203
+ | \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
204
+ | \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
161
205
 
162
- ### 4. An API token the build can use
206
+ It will ask you to paste a Cloudflare API token. That is the only manual step
207
+ in the whole setup, and it is worth saying exactly why it cannot be removed:
163
208
 
164
- This is the one non-obvious step. Cloudflare generates an API token for
165
- Workers Builds automatically, and **that token has no D1 permission** its
166
- scopes are Workers Scripts, KV, R2, Workers Routes and account/user reads. The
167
- deploy command below applies database migrations, so it needs more.
209
+ > **GitHub cannot mint a Cloudflare credential.** There is no OIDC or workload
210
+ > identity federation between them the feature request has been open since
211
+ > 2025 with no commitment, and Cloudflare's own CI guidance still says to store
212
+ > a token in your CI provider's secrets. The Cloudflare GitHub App does not
213
+ > help either: it grants *Cloudflare* access to your *repository*, not the
214
+ > reverse. Something has to authorise creating a database in your account, and
215
+ > only Cloudflare can issue that authorisation.
168
216
 
169
- Create a token at **My Profile → API Tokens** with:
217
+ Create the token at **My Profile → API Tokens → Create Token → Custom token**:
170
218
 
171
- | scope | permission |
172
- |---|---|
173
- | Account | Workers Scripts: Edit |
174
- | Account | D1: Edit |
175
- | Account | Workers KV Storage: Edit, Workers R2 Storage: Edit |
176
- | Account | Queues: Edit |
177
- | Account | Account Settings: Read |
178
- | Zone | Workers Routes: Edit (${zone}) |
179
- | User | User Details: Read, Memberships: Read |
219
+ | scope | permission | for |
220
+ |---|---|---|
221
+ | Account | Workers Scripts: Edit | deploying the worker |
222
+ | Account | D1: Edit | creating the database, applying migrations |
223
+ | Account | Queues: Edit | the order queue |
224
+ | Account | Workers KV Storage: Edit | sessions and caches |
225
+ | Account | Workers R2 Storage: Edit | product images |
226
+ | Account | Account Settings: Read | confirming which account |
227
+ | Zone | Workers Routes: Edit (${zone}) | answering on your domain |
228
+ | User | User Details: Read, Memberships: Read | wrangler asks at startup |
180
229
 
181
- Then select it in the build settings below. Use the same token for every
182
- deploy of this Worker; permissions are whatever that token has.
230
+ The token Cloudflare generates for its own Workers Builds will **not** do: it
231
+ has no D1 and no Queues permission, so it cannot create this shop's database.
183
232
 
184
- ### 5. Connect the repository
233
+ ### The zone
185
234
 
186
- In the Cloudflare dashboard, on the Worker named \`${worker}\`, under
187
- **Settings Builds Connect**. The Worker's name must equal the \`name\` in
188
- the \`wrangler.jsonc\` at the root directory, or the build fails.
235
+ \`${zone}\` must be on Cloudflare. A Worker custom domain is a record Cloudflare
236
+ creates in its own zone, so a domain hosted anywhere else cannot have one. The
237
+ worker answers on ${hosts.map((h) => `\`${h}\``).join(" and ")}.
189
238
 
190
- | setting | value |
191
- |---|---|
192
- | branch | \`${DIST_BRANCH}\` |
193
- | root directory | \`/\` |
194
- | build command | \`bun install && bunx void prepare && bunx vp build\` |
195
- | deploy command | see below |
196
- | build variable | \`DOTENV_PRIVATE_KEY_SECRETS\`, marked as a secret — the one value that is not in the repository |
197
- | API token | the one from step 4 |
239
+ ## Secrets
198
240
 
199
- The deploy command, on one line:
241
+ They live in the repository, encrypted, in \`${SECRETS_FILE}\`:
200
242
 
201
243
  \`\`\`sh
202
- bunx dotenvx decrypt -f .env.secrets --stdout > .vc-secrets.env && bunx wrangler d1 migrations apply DB --remote && bunx wrangler deploy -c dist/ssr/wrangler.json --secrets-file .vc-secrets.env
244
+ vc secrets set STRIPE_SECRET_KEY # prompts; never touches your shell history
245
+ vc secrets # what is set, what is missing
203
246
  \`\`\`
204
247
 
205
- Plain \`sh\`, so it does not rely on process substitution. The decrypted file
206
- exists only inside the build sandbox, and \`--secrets-file\` applies additively:
207
- a secret it does not name is left alone rather than deleted.
248
+ **This needs no credential.** dotenvx is asymmetric: encryption uses the public
249
+ key committed at the top of the file, so anyone with a clone can set or rotate
250
+ a secret. Nobody with a clone can read one. Only the deploy decrypts, with the
251
+ private key that lives in GitHub Actions.
208
252
 
209
- The migration command names the **binding** (\`DB\`), not the database, so it
210
- still points at the right database if the name ever differs.
253
+ A pre-commit hook refuses a commit that would put a value in the clear, because
254
+ that cannot be undone by a later commit — the value stays in the history.
211
255
 
212
- Cloudflare's build image has Bun; pin its version with a \`BUN_VERSION\` build
213
- variable if you ever need to. The free plan allows 3,000 build minutes a month
214
- and one build at a time.
256
+ **Know the tradeoff.** Ciphertext in git is permanent: if the private key ever
257
+ leaks, every secret in the history is readable, including ones you rotated.
258
+ \`wrangler secret put\` does not have that property, and stays available for
259
+ anything you would rather never commit at all.
215
260
 
216
- ### 6. The catalogue
261
+ ## The catalogue
217
262
 
218
- \`vc import\` pushes \`data/\` into the running shop. It is an upsert, so it is
219
- safe on every deploy and costs one pass when nothing changed.
263
+ \`vc import\` pushes \`data/\` into the running shop, and the workflow runs it on
264
+ every deploy. It is an upsert, so it costs one pass when nothing has changed.
220
265
 
221
- ## Without Cloudflare's build
266
+ ## Doing it by hand
222
267
 
223
- The same thing by hand, from a checkout of either branch:
268
+ The same thing, from a checkout, if CI is ever not the answer:
224
269
 
225
270
  \`\`\`sh
226
- vc deploy --cloudflare
271
+ export CLOUDFLARE_API_TOKEN=…
272
+ export ${PRIVATE_KEY_VAR}=… # only if you kept a copy
273
+ vc deploy --cloudflare --provision
227
274
  \`\`\`
228
275
 
229
- which preflights, builds, strips the baked development values out of the
230
- worker's vars, applies the remote migrations, and deploys exactly what it
231
- verified.
276
+ which preflights, provisions, builds, strips the baked development values out
277
+ of the worker's vars, applies the remote migrations, and deploys exactly what
278
+ it verified.
279
+
280
+ ## If you would rather use Cloudflare's own build
281
+
282
+ Point Workers Builds at the \`${DIST_BRANCH}\` branch — it is a plain Void app with
283
+ a committed lockfile. The Worker must be named \`${worker}\`, matching the \`name\`
284
+ in its \`wrangler.jsonc\`. You will need to set \`${PRIVATE_KEY_VAR}\` as a
285
+ build secret in the dashboard, and replace the auto-generated API token with
286
+ one that has D1 and Queues. That is the path \`vc link\` exists to avoid.
232
287
  `;
233
288
  }
@@ -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]
@@ -1,8 +1,8 @@
1
- import { cp, mkdir, readFile, readdir, symlink, writeFile } from "node:fs/promises";
1
+ import { cp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
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,
@@ -75,6 +75,8 @@ import { renderDomainTs, renderErpTs, renderMintTs, renderNotificationsTs, rende
75
75
  export interface GenerateResult {
76
76
  written: string[];
77
77
  kept: string[];
78
+ /** Generated files that no longer belong, and were deleted. */
79
+ retired: string[];
78
80
  packages: string[];
79
81
  }
80
82
 
@@ -98,6 +100,20 @@ async function put(root: string, file: string, content: string, result: Generate
98
100
  result.written.push(file);
99
101
  }
100
102
 
103
+ /**
104
+ * Delete a file this generator used to write and no longer should.
105
+ *
106
+ * Renaming a generated file leaves the old one behind, and a stale GitHub
107
+ * workflow is not inert — it still runs on every push. So a rename has to
108
+ * be a write AND a delete, and the delete has to be reported.
109
+ */
110
+ async function retire(root: string, file: string, result: GenerateResult) {
111
+ const path = join(root, file);
112
+ if (!(await exists(path))) return;
113
+ await rm(path, { force: true });
114
+ result.retired.push(file);
115
+ }
116
+
101
117
  /** Read a JSON file if present, let the caller add to it, write it back. Everything else in it is kept. */
102
118
  async function mergeJson(root: string, file: string, fallback: Record<string, unknown>, mutate: (json: Record<string, unknown>) => void, result: GenerateResult) {
103
119
  const path = join(root, file);
@@ -125,7 +141,7 @@ async function link(root: string, file: string, target: string, result: Generate
125
141
  const sorted = (record: Record<string, string>) => Object.fromEntries(Object.entries(record).sort());
126
142
 
127
143
  export async function generate(root: string, manifest: Manifest): Promise<GenerateResult> {
128
- const result: GenerateResult = { written: [], kept: [], packages: packagesOf(manifest) };
144
+ const result: GenerateResult = { written: [], kept: [], retired: [], packages: packagesOf(manifest) };
129
145
 
130
146
  await writeManifest(root, manifest);
131
147
  result.written.push(MANIFEST_FILE);
@@ -239,7 +255,7 @@ async function generateApi(root: string, dir: string, manifest: Manifest, result
239
255
  );
240
256
  }
241
257
 
242
- /** 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. */
243
259
  async function generateFrontend(root: string, dir: string, manifest: Manifest, result: GenerateResult) {
244
260
  const at = (file: string) => join(dir, file);
245
261
 
@@ -249,7 +265,14 @@ async function generateFrontend(root: string, dir: string, manifest: Manifest, r
249
265
  await put(root, at("void.json"), renderFrontendVoidJson(), result, "own");
250
266
  await put(root, at("tsconfig.json"), renderFrontendTsconfig(), result, "own");
251
267
  await put(root, at("pages/index.tsx"), renderStorefrontPage(manifest), result, "own");
252
- 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
+ }
253
276
 
254
277
  // A monorepo's frontend is its own workspace; strict's shares the root's
255
278
  // one package.json, which generateStrictRoot has already written.
@@ -357,7 +380,10 @@ bunx vc guard
357
380
  result,
358
381
  "regenerate",
359
382
  );
360
- await put(root, ".github/workflows/void-dist.yml", renderDistWorkflow(manifest), result, "regenerate");
383
+ await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
384
+ // Renamed when GitHub Actions took over the deploy from Cloudflare's own
385
+ // build. Two workflows on the same push would deploy the shop twice.
386
+ await retire(root, ".github/workflows/void-dist.yml", result);
361
387
  await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
362
388
  await put(root, ".env", renderEnvLocal(manifest), result, "own");
363
389
  await put(root, "data/README.md", DATA_README, result, "own");
@@ -385,8 +411,20 @@ async function generateStrictApp(root: string, dir: string, manifest: Manifest,
385
411
  await put(root, at("db/seed.ts"), renderDbSeed(), result, gen);
386
412
  await put(root, at("pages/layout.tsx"), renderLayoutTsx(), result, gen);
387
413
  await put(root, at("pages/app.css"), renderAppCss(), result, gen);
388
- await put(root, at("pages/index.server.ts"), renderIndexServer(), result, gen);
389
- 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
+ }
390
428
  await put(root, at("pages/api-dashboard/index.tsx"), renderDashboardPage(manifest), result, gen);
391
429
  await put(root, at("pages/api-dashboard/index.server.ts"), CLIENT_ONLY, result, gen);
392
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);
@@ -141,6 +141,7 @@ export async function init(args: string[]): Promise<number> {
141
141
  [
142
142
  ...result.written.map((file) => `${color.green("+")} ${file}`),
143
143
  ...result.kept.map((file) => `${color.dim("=")} ${file} ${color.dim("(kept — yours)")}`),
144
+ ...result.retired.map((file) => `${color.red("-")} ${file} ${color.dim("(retired — no longer generated)")}`),
144
145
  ].join("\n"),
145
146
  "Files",
146
147
  );