@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 +8 -6
- package/dist/generate/frontend.d.ts +1 -1
- package/dist/{index-b9b4dawy.js → index-2qt2yh3z.js} +2 -2
- package/dist/{index-5m3t0zfc.js → index-f1wds190.js} +98 -17
- package/dist/{index-pz6m2hkm.js → index-xx5p4b8d.js} +5 -4
- package/dist/index.js +5 -3
- package/dist/{keys-b91c72zp.js → keys-qwvt324e.js} +2 -2
- package/dist/manifest.d.ts +27 -5
- package/dist/manifest.js +3 -1
- package/package.json +1 -1
- package/src/deploy/cloudflare.ts +68 -4
- package/src/generate/ci.ts +8 -0
- package/src/generate/frontend.ts +26 -4
- package/src/generate/index.ts +24 -5
- package/src/generate/support.ts +7 -5
- package/src/init.ts +1 -1
- package/src/manifest.ts +33 -8
- package/src/wizard.ts +7 -3
package/dist/cli.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
runVoid,
|
|
22
22
|
version,
|
|
23
23
|
voidAppsIn
|
|
24
|
-
} from "./index-
|
|
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-
|
|
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-
|
|
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
|
-
...
|
|
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:
|
|
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", "
|
|
616
|
+
["frontend", "the storefront — prerendered 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);
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
hasFrontend,
|
|
4
4
|
isApex,
|
|
5
5
|
zone
|
|
6
|
-
} from "./index-
|
|
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-
|
|
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-
|
|
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-
|
|
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}
|
|
510
|
-
#
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
1972
|
-
* One
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2444
|
-
|
|
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
|
|
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 (!
|
|
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 (
|
|
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-
|
|
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-
|
|
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-
|
|
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,
|
package/dist/manifest.d.ts
CHANGED
|
@@ -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.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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,
|
|
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
|
-
/**
|
|
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-
|
|
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.
|
|
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",
|
package/src/deploy/cloudflare.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
+
}
|
package/src/generate/ci.ts
CHANGED
|
@@ -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.
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
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);
|
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);
|
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.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
-
...(
|
|
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:
|
|
218
|
+
pagesHost: onPages ? (shop as { pagesHost?: string }).pagesHost?.trim().toLowerCase() || undefined : undefined,
|
|
215
219
|
},
|
|
216
220
|
chosen,
|
|
217
221
|
};
|