@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.
- package/dist/cli.js +269 -76
- package/dist/deploy/github.d.ts +85 -0
- package/dist/deploy/index.d.ts +3 -0
- package/dist/deploy/keys.d.ts +68 -25
- package/dist/deploy/link.d.ts +2 -0
- package/dist/deploy/secrets.d.ts +51 -8
- package/dist/generate/ci.d.ts +33 -18
- package/dist/generate/frontend.d.ts +1 -1
- package/dist/generate/index.d.ts +2 -0
- package/dist/index-2qt2yh3z.js +797 -0
- package/dist/{index-yzezvy5h.js → index-f1wds190.js} +322 -663
- package/dist/{index-pz6m2hkm.js → index-xx5p4b8d.js} +5 -4
- package/dist/index.js +12 -8
- package/dist/keys-qwvt324e.js +19 -0
- package/dist/manifest.d.ts +27 -5
- package/dist/manifest.js +3 -1
- package/package.json +1 -1
- package/src/cli.ts +14 -1
- package/src/deploy/cloudflare.ts +68 -4
- package/src/deploy/github.ts +198 -0
- package/src/deploy/index.ts +66 -18
- package/src/deploy/keys.ts +208 -144
- package/src/deploy/link.ts +185 -0
- package/src/deploy/secrets.ts +217 -35
- package/src/dotenvx-primitives.d.ts +4 -0
- package/src/generate/ci.ts +176 -121
- package/src/generate/frontend.ts +26 -4
- package/src/generate/index.ts +46 -8
- package/src/generate/support.ts +7 -5
- package/src/init.ts +2 -1
- package/src/manifest.ts +33 -8
- package/src/regenerate.ts +1 -0
- package/src/wizard.ts +7 -3
package/src/generate/ci.ts
CHANGED
|
@@ -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
|
|
6
|
+
* The workflow that turns a push into a live shop.
|
|
6
7
|
*
|
|
7
|
-
* ── Why
|
|
8
|
+
* ── Why GitHub Actions deploys, and not Cloudflare's own build ───────────
|
|
8
9
|
*
|
|
9
|
-
* Cloudflare's Workers Builds
|
|
10
|
-
*
|
|
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
|
-
*
|
|
17
|
-
* the
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
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
|
-
#
|
|
31
|
-
#
|
|
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
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
171
|
+
git push
|
|
131
172
|
\`\`\`
|
|
132
173
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
and
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
\`
|
|
159
|
-
|
|
160
|
-
repository
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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 |
|
|
176
|
-
| Account |
|
|
177
|
-
| Account |
|
|
178
|
-
|
|
|
179
|
-
|
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
###
|
|
233
|
+
### The zone
|
|
185
234
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
+
They live in the repository, encrypted, in \`${SECRETS_FILE}\`:
|
|
200
242
|
|
|
201
243
|
\`\`\`sh
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
a secret
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
261
|
+
## The catalogue
|
|
217
262
|
|
|
218
|
-
\`vc import\` pushes \`data/\` into the running shop
|
|
219
|
-
|
|
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
|
-
##
|
|
266
|
+
## Doing it by hand
|
|
222
267
|
|
|
223
|
-
The same thing
|
|
268
|
+
The same thing, from a checkout, if CI is ever not the answer:
|
|
224
269
|
|
|
225
270
|
\`\`\`sh
|
|
226
|
-
|
|
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
|
|
230
|
-
worker's vars, applies the remote migrations, and deploys exactly what
|
|
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
|
}
|
package/src/generate/frontend.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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}
|
|
187
|
-
#
|
|
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]
|
package/src/generate/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
389
|
-
|
|
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);
|
package/src/generate/support.ts
CHANGED
|
@@ -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
|
|
15
|
-
? ` // One
|
|
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
|
|
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
|
|
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
|
-
|
|
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", "
|
|
97
|
+
["frontend", "the storefront — prerendered 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
|
);
|