@saastemly/voidcommerce 0.5.0 → 0.7.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/catalog.js +1 -1
- package/dist/cli.js +9 -7
- package/dist/generate/auth.d.ts +13 -0
- package/dist/generate/frontend.d.ts +1 -1
- package/dist/{index-b9b4dawy.js → index-6w8rwtsh.js} +2 -2
- package/dist/{index-5m3t0zfc.js → index-85m0qkmg.js} +163 -25
- package/dist/{index-844b3qn9.js → index-mnb7fz2t.js} +12 -1
- package/dist/{index-pz6m2hkm.js → index-tjy6yygc.js} +6 -5
- package/dist/index.js +6 -4
- package/dist/{keys-b91c72zp.js → keys-92tcdjrc.js} +3 -3
- package/dist/manifest.d.ts +27 -5
- package/dist/manifest.js +4 -2
- package/package.json +1 -1
- package/src/catalog.ts +12 -1
- package/src/deploy/cloudflare.ts +68 -4
- package/src/generate/auth.ts +34 -2
- package/src/generate/ci.ts +8 -0
- package/src/generate/frontend.ts +26 -4
- package/src/generate/index.ts +29 -8
- package/src/generate/support.ts +74 -8
- package/src/init.ts +1 -1
- package/src/manifest.ts +33 -8
- package/src/wizard.ts +7 -3
package/dist/catalog.js
CHANGED
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-85m0qkmg.js";
|
|
25
25
|
import {
|
|
26
26
|
PRIVATE_KEY_VAR,
|
|
27
27
|
SECRETS_FILE,
|
|
@@ -42,17 +42,18 @@ import {
|
|
|
42
42
|
setVariable,
|
|
43
43
|
variableNames,
|
|
44
44
|
verifyCloudflareToken
|
|
45
|
-
} from "./index-
|
|
45
|
+
} from "./index-6w8rwtsh.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-tjy6yygc.js";
|
|
53
54
|
import {
|
|
54
55
|
GROUPS
|
|
55
|
-
} from "./index-
|
|
56
|
+
} from "./index-mnb7fz2t.js";
|
|
56
57
|
import {
|
|
57
58
|
__require
|
|
58
59
|
} from "./index-0v6na3yp.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);
|
package/dist/generate/auth.d.ts
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
1
|
import { type Manifest } from "../manifest";
|
|
2
|
+
/**
|
|
3
|
+
* Carriers with a provider behind them, and whether each needs `lib/shipping.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Exported because `generate/index.ts` has to know which shops get that file,
|
|
6
|
+
* and a second hand-maintained list of carrier ids WILL drift from this one —
|
|
7
|
+
* it already did: adding Royal Mail here left the file ungenerated, so the
|
|
8
|
+
* generated `auth.ts` imported a price table that did not exist.
|
|
9
|
+
*
|
|
10
|
+
* The token needs nothing: no origin, no tariff. Everything else needs one or
|
|
11
|
+
* the other, so the rule is simply "any carrier but the token".
|
|
12
|
+
*/
|
|
13
|
+
export declare const WIRED_CARRIERS: readonly ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"];
|
|
14
|
+
export declare const needsShippingConfig: (id: string) => boolean;
|
|
2
15
|
export declare function renderAuthTs(manifest: Manifest): string;
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
hasFrontend,
|
|
4
4
|
isApex,
|
|
5
5
|
zone
|
|
6
|
-
} from "./index-
|
|
6
|
+
} from "./index-tjy6yygc.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-92tcdjrc.js");
|
|
372
372
|
const derived = await publicKeyFor(privateKey);
|
|
373
373
|
if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
|
|
374
374
|
return {
|
|
@@ -9,22 +9,23 @@ import {
|
|
|
9
9
|
renderEnvLocal,
|
|
10
10
|
renderEnvProduction,
|
|
11
11
|
renderEnvTs
|
|
12
|
-
} from "./index-
|
|
12
|
+
} from "./index-6w8rwtsh.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-tjy6yygc.js";
|
|
25
26
|
import {
|
|
26
27
|
CHOICES
|
|
27
|
-
} from "./index-
|
|
28
|
+
} from "./index-mnb7fz2t.js";
|
|
28
29
|
import {
|
|
29
30
|
__require
|
|
30
31
|
} from "./index-0v6na3yp.js";
|
|
@@ -47,10 +48,17 @@ var VAT = {
|
|
|
47
48
|
PL: { rate: 2300, name: "VAT" }
|
|
48
49
|
};
|
|
49
50
|
var social = (manifest, id, envPrefix) => has(manifest, id) ? ` ${id}: { clientId: must(env, "${envPrefix}_CLIENT_ID"), clientSecret: must(env, "${envPrefix}_CLIENT_SECRET") },` : null;
|
|
51
|
+
var WIRED_CARRIERS = ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"];
|
|
52
|
+
var needsShippingConfig = (id) => WIRED_CARRIERS.includes(id) && id !== "nft";
|
|
50
53
|
function renderAuthTs(manifest) {
|
|
51
54
|
const { shop } = manifest;
|
|
52
55
|
const lines = [];
|
|
53
56
|
const CARRIER_CALLS = {
|
|
57
|
+
"royal-mail": {
|
|
58
|
+
label: "Royal Mail",
|
|
59
|
+
call: `royalMailProvider({ apiKey: must(env, "CLICK_DROP_AUTH_KEY"), services: royalMailServices, currency: "${shop.currency}", carrierName: "Royal Mail" })`,
|
|
60
|
+
import: `import { royalMailProvider } from "@saastemly/better-commerce/providers/royal-mail";`
|
|
61
|
+
},
|
|
54
62
|
"dhl-express": {
|
|
55
63
|
label: "DHL Express",
|
|
56
64
|
call: `dhlExpressProvider({ apiKey: must(env, "DHL_API_KEY"), apiSecret: must(env, "DHL_API_SECRET"), accountNumber: must(env, "DHL_ACCOUNT_NUMBER"), from: shipFrom })`,
|
|
@@ -85,8 +93,12 @@ import { mintProvider, destinationFor } from "./lib/mint.ts";`
|
|
|
85
93
|
};
|
|
86
94
|
const carriers = Object.keys(CARRIER_CALLS).filter((id) => has(manifest, id));
|
|
87
95
|
const carrierImports = carriers.map((id) => CARRIER_CALLS[id].import);
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
const shipping = [
|
|
97
|
+
...carriers.some((id) => id !== "nft" && id !== "royal-mail") ? ["shipFrom"] : [],
|
|
98
|
+
...carriers.includes("royal-mail") ? ["royalMailServices"] : []
|
|
99
|
+
];
|
|
100
|
+
if (shipping.length > 0)
|
|
101
|
+
carrierImports.push(`import { ${shipping.join(", ")} } from "./lib/shipping.ts";`);
|
|
90
102
|
const wantsNft = has(manifest, "nft");
|
|
91
103
|
const carrierFulfillment = carriers.length ? `, deliveryFulfillment([${carriers.map((id) => CARRIER_CALLS[id].call).join(", ")}])` : "";
|
|
92
104
|
const nftDefault = carriers.length ? `, defaultProvider: "delivery:${carriers.join("+")}"` : "";
|
|
@@ -363,6 +375,22 @@ var camel = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
|
363
375
|
var FRONTEND_OUT = "dist/client";
|
|
364
376
|
var FRONTEND_BRANCH = "frontend-static";
|
|
365
377
|
function renderFrontendApiTs(manifest) {
|
|
378
|
+
if (oneOrigin(manifest.layout)) {
|
|
379
|
+
return `import { createAuthClient } from "better-auth/react";
|
|
380
|
+
import { commerceClient } from "@saastemly/better-commerce/client";
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The one client the storefront talks through.
|
|
384
|
+
*
|
|
385
|
+
* SAME ORIGIN. One worker serves this page and the API it calls, so there is
|
|
386
|
+
* no address to configure, nothing to bake at build time, and nothing that
|
|
387
|
+
* can point at the wrong place. Cookies are first-party, so no SameSite=None
|
|
388
|
+
* and no CORS preflight on any request.
|
|
389
|
+
*/
|
|
390
|
+
export const API_ORIGIN = "";
|
|
391
|
+
export const api = createAuthClient({ plugins: [commerceClient()] });
|
|
392
|
+
`;
|
|
393
|
+
}
|
|
366
394
|
return `/// <reference types="vite/client" />
|
|
367
395
|
import { createAuthClient } from "better-auth/react";
|
|
368
396
|
import { commerceClient } from "@saastemly/better-commerce/client";
|
|
@@ -383,6 +411,13 @@ export const api = createAuthClient({ baseURL: API_ORIGIN, plugins: [commerceCli
|
|
|
383
411
|
`;
|
|
384
412
|
}
|
|
385
413
|
function renderFrontendEnvProduction(manifest) {
|
|
414
|
+
if (oneOrigin(manifest.layout)) {
|
|
415
|
+
return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
|
|
416
|
+
#
|
|
417
|
+
# The API is same-origin, so there is no VITE_API_ORIGIN: one worker serves
|
|
418
|
+
# this storefront and the endpoints it calls.
|
|
419
|
+
`;
|
|
420
|
+
}
|
|
386
421
|
return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
|
|
387
422
|
VITE_API_ORIGIN=https://api.${manifest.shop.domain}
|
|
388
423
|
`;
|
|
@@ -506,9 +541,8 @@ function renderFrontendWorkflow(manifest, dir) {
|
|
|
506
541
|
# Generated by \`vc init\` from voidcommerce.json.
|
|
507
542
|
#
|
|
508
543
|
# Builds the storefront and force-pushes the prerendered tree to
|
|
509
|
-
# \`${FRONTEND_BRANCH}
|
|
510
|
-
#
|
|
511
|
-
# \`void-dist\`.
|
|
544
|
+
# \`${FRONTEND_BRANCH}\`, which GitHub Pages serves. The worker deploys
|
|
545
|
+
# separately, from deploy.yml.
|
|
512
546
|
on:
|
|
513
547
|
push:
|
|
514
548
|
branches: [main, master]
|
|
@@ -1310,7 +1344,7 @@ import color from "picocolors";
|
|
|
1310
1344
|
// package.json
|
|
1311
1345
|
var package_default = {
|
|
1312
1346
|
name: "@saastemly/voidcommerce",
|
|
1313
|
-
version: "0.
|
|
1347
|
+
version: "0.7.0",
|
|
1314
1348
|
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
1349
|
type: "module",
|
|
1316
1350
|
license: "MIT",
|
|
@@ -1775,6 +1809,14 @@ That is the deploy. GitHub Actions regenerates the app from
|
|
|
1775
1809
|
database and queue if they are not there, applies the migrations, and deploys
|
|
1776
1810
|
the worker with this repository's secrets attached.
|
|
1777
1811
|
|
|
1812
|
+
**One worker serves everything.** The storefront is prerendered and folded
|
|
1813
|
+
into the worker's own static assets, so \`${manifest.shop.domain}\` answers with
|
|
1814
|
+
the shop, the admin panel and the API from a single origin. Cloudflare creates
|
|
1815
|
+
the hostname's DNS record and certificate itself, because it is a Worker
|
|
1816
|
+
custom domain. There is nothing to configure in any dashboard, no second
|
|
1817
|
+
branch to keep in step, and no CORS — the storefront's requests are
|
|
1818
|
+
first-party.
|
|
1819
|
+
|
|
1778
1820
|
You do not need wrangler on your machine, and you do not need to be logged
|
|
1779
1821
|
into it. You do not need to open the Cloudflare dashboard after the one step
|
|
1780
1822
|
below.
|
|
@@ -1914,7 +1956,7 @@ export function check(present: Map<string, string | undefined>): Requirement[] {
|
|
|
1914
1956
|
|
|
1915
1957
|
// src/generate/support.ts
|
|
1916
1958
|
function renderDomainTs(layout) {
|
|
1917
|
-
const derive = layout
|
|
1959
|
+
const derive = layout !== "monorepo" ? ` // One origin: the worker IS the site. No separate API host, and no other
|
|
1918
1960
|
// origin to trust. It answers on www too, but only at a zone's apex —
|
|
1919
1961
|
// www.shop.example.com is not a name anybody types.
|
|
1920
1962
|
const hosts = apex ? [host, www] : [host];
|
|
@@ -1925,7 +1967,7 @@ function renderDomainTs(layout) {
|
|
|
1925
1967
|
return `/**
|
|
1926
1968
|
* The shop's domain, and everything derived from it.
|
|
1927
1969
|
*
|
|
1928
|
-
* One variable. \`SHOP_DOMAIN\` gives ${layout
|
|
1970
|
+
* One variable. \`SHOP_DOMAIN\` gives ${layout !== "monorepo" ? "the worker's hostnames" : `api.<domain> for the worker and the
|
|
1929
1971
|
* storefront's origins`}, the app's public origin, and the
|
|
1930
1972
|
* CORS and CSRF allow-list. They were four settings that had to agree and
|
|
1931
1973
|
* nothing checked that they did.
|
|
@@ -1968,11 +2010,13 @@ export const GITHUB_PAGES_AAAA = ["2606:50c0:8000::153", "2606:50c0:8001::153",
|
|
|
1968
2010
|
/**
|
|
1969
2011
|
* The storefront's records. DNS-only, never proxied: GitHub issues the
|
|
1970
2012
|
* 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
|
|
2013
|
+
* is NOT here — it is a Cloudflare custom domain in wrangler.jsonc.${layout !== "monorepo" ? `
|
|
2014
|
+
* One worker serves the storefront, so this is always empty.` : ""}
|
|
1973
2015
|
*/
|
|
1974
2016
|
export function storefrontRecords(domain: ShopDomain, pagesHost: string) {
|
|
1975
|
-
|
|
2017
|
+
// Empty whenever one worker serves the storefront: Cloudflare creates the
|
|
2018
|
+
// hostname's record and certificate itself for a custom domain.
|
|
2019
|
+
if (!pagesHost || domain.frontendOrigins.length === 0) return [];
|
|
1976
2020
|
// The record's name is relative to the zone: "@" at the apex, otherwise the
|
|
1977
2021
|
// labels in front of it.
|
|
1978
2022
|
const name = domain.apex ? "@" : domain.host.slice(0, -(domain.zone.length + 1));
|
|
@@ -2148,13 +2192,18 @@ export async function destinationFor(orderId: string): Promise<string | null> {
|
|
|
2148
2192
|
`;
|
|
2149
2193
|
}
|
|
2150
2194
|
function renderShippingTs(manifest) {
|
|
2151
|
-
|
|
2152
|
-
|
|
2195
|
+
const royalMail = has(manifest, "royal-mail");
|
|
2196
|
+
const origin = ["dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id));
|
|
2197
|
+
const imports = [
|
|
2198
|
+
...origin ? [`import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";`] : [],
|
|
2199
|
+
...royalMail ? [`import type { RoyalMailService } from "@saastemly/better-commerce/providers/royal-mail";`] : []
|
|
2200
|
+
];
|
|
2201
|
+
const originBlock = `
|
|
2153
2202
|
/**
|
|
2154
2203
|
* The address every parcel is quoted and shipped FROM.
|
|
2155
2204
|
*
|
|
2156
|
-
* TODO VERIFY: this is a placeholder. A carrier quotes against it, and
|
|
2157
|
-
*
|
|
2205
|
+
* TODO VERIFY: this is a placeholder. A carrier quotes against it, and most
|
|
2206
|
+
* refuse a shipment whose origin has no \`name\` and \`phone\`.
|
|
2158
2207
|
*/
|
|
2159
2208
|
export const shipFrom: DeliveryAddress = {
|
|
2160
2209
|
name: "${manifest.shop.name}",
|
|
@@ -2165,6 +2214,9 @@ export const shipFrom: DeliveryAddress = {
|
|
|
2165
2214
|
phone: "TODO VERIFY: +44…",
|
|
2166
2215
|
};
|
|
2167
2216
|
`;
|
|
2217
|
+
return `${imports.join(`
|
|
2218
|
+
`)}
|
|
2219
|
+
${origin ? originBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
|
|
2168
2220
|
}
|
|
2169
2221
|
function renderErpTs() {
|
|
2170
2222
|
return `import { businessCentralProvider } from "@saastemly/better-commerce/providers/business-central";
|
|
@@ -2193,6 +2245,44 @@ export const erpFulfillment = (currency: string): FulfillmentProvider =>
|
|
|
2193
2245
|
}).fulfillment();
|
|
2194
2246
|
`;
|
|
2195
2247
|
}
|
|
2248
|
+
var ROYAL_MAIL_SERVICES = () => `
|
|
2249
|
+
/**
|
|
2250
|
+
* What this shop charges for Royal Mail, by total parcel weight.
|
|
2251
|
+
*
|
|
2252
|
+
* TODO VERIFY: every \`amount\` below is a placeholder in pence. Replace them
|
|
2253
|
+
* with your own OBA rate card — Click & Drop → Settings → Shipping services
|
|
2254
|
+
* lists exactly which services your account has, and the codes here must be
|
|
2255
|
+
* among them. Service codes are account-specific: the published list is from
|
|
2256
|
+
* 2021 and Royal Mail warn that "available services are unique to your OBA
|
|
2257
|
+
* account and individual service agreements".
|
|
2258
|
+
*
|
|
2259
|
+
* The band whose \`maxGrams\` first covers the parcel wins, so order does not
|
|
2260
|
+
* matter. A parcel heavier than every band gets no rate at all rather than a
|
|
2261
|
+
* wrong one, and Click & Drop refuses anything over 30 kg.
|
|
2262
|
+
*/
|
|
2263
|
+
export const royalMailServices: RoyalMailService[] = [
|
|
2264
|
+
{
|
|
2265
|
+
code: "TPN24",
|
|
2266
|
+
name: "Tracked 24",
|
|
2267
|
+
estimatedDays: 1,
|
|
2268
|
+
bands: [
|
|
2269
|
+
{ maxGrams: 1000, amount: 0 /* TODO VERIFY */ },
|
|
2270
|
+
{ maxGrams: 2000, amount: 0 /* TODO VERIFY */ },
|
|
2271
|
+
{ maxGrams: 5000, amount: 0 /* TODO VERIFY */ },
|
|
2272
|
+
],
|
|
2273
|
+
},
|
|
2274
|
+
{
|
|
2275
|
+
code: "TPS48",
|
|
2276
|
+
name: "Tracked 48",
|
|
2277
|
+
estimatedDays: 2,
|
|
2278
|
+
bands: [
|
|
2279
|
+
{ maxGrams: 1000, amount: 0 /* TODO VERIFY */ },
|
|
2280
|
+
{ maxGrams: 2000, amount: 0 /* TODO VERIFY */ },
|
|
2281
|
+
{ maxGrams: 5000, amount: 0 /* TODO VERIFY */ },
|
|
2282
|
+
],
|
|
2283
|
+
},
|
|
2284
|
+
];
|
|
2285
|
+
`;
|
|
2196
2286
|
|
|
2197
2287
|
// src/generate/index.ts
|
|
2198
2288
|
async function exists(path) {
|
|
@@ -2281,7 +2371,7 @@ async function generateApi(root, dir, manifest, result, opts) {
|
|
|
2281
2371
|
if (has(manifest, "nft")) {
|
|
2282
2372
|
await put(root, at("lib/mint.ts"), renderMintTs(), result, own);
|
|
2283
2373
|
}
|
|
2284
|
-
if (
|
|
2374
|
+
if (WIRED_CARRIERS.some((id) => needsShippingConfig(id) && has(manifest, id))) {
|
|
2285
2375
|
await put(root, at("lib/shipping.ts"), renderShippingTs(manifest), result, own);
|
|
2286
2376
|
}
|
|
2287
2377
|
if (has(manifest, "business-central")) {
|
|
@@ -2338,7 +2428,11 @@ async function generateFrontend(root, dir, manifest, result) {
|
|
|
2338
2428
|
await put(root, at("void.json"), renderFrontendVoidJson(), result, "own");
|
|
2339
2429
|
await put(root, at("tsconfig.json"), renderFrontendTsconfig(), result, "own");
|
|
2340
2430
|
await put(root, at("pages/index.tsx"), renderStorefrontPage(manifest), result, "own");
|
|
2341
|
-
|
|
2431
|
+
if (oneOrigin(manifest.layout)) {
|
|
2432
|
+
await retire(root, ".github/workflows/frontend-static.yml", result);
|
|
2433
|
+
} else {
|
|
2434
|
+
await put(root, ".github/workflows/frontend-static.yml", renderFrontendWorkflow(manifest, dir), result, "regenerate");
|
|
2435
|
+
}
|
|
2342
2436
|
if (dir === "")
|
|
2343
2437
|
return;
|
|
2344
2438
|
await mergeJson(root, at("package.json"), { name: `${manifest.shop.domain.split(".")[0]}-frontend`, type: "module", private: true }, (pkg) => {
|
|
@@ -2440,8 +2534,13 @@ async function generateStrictApp(root, dir, manifest, result) {
|
|
|
2440
2534
|
await put(root, at("db/seed.ts"), renderDbSeed(), result, gen);
|
|
2441
2535
|
await put(root, at("pages/layout.tsx"), renderLayoutTsx(), result, gen);
|
|
2442
2536
|
await put(root, at("pages/app.css"), renderAppCss(), result, gen);
|
|
2443
|
-
|
|
2444
|
-
|
|
2537
|
+
if (!oneOrigin(manifest.layout) || isSingleApp(manifest.layout)) {
|
|
2538
|
+
await put(root, at("pages/index.server.ts"), renderIndexServer(), result, gen);
|
|
2539
|
+
await put(root, at("pages/index.tsx"), INDEX_PAGE, result, gen);
|
|
2540
|
+
} else {
|
|
2541
|
+
await retire(root, at("pages/index.server.ts"), result);
|
|
2542
|
+
await retire(root, at("pages/index.tsx"), result);
|
|
2543
|
+
}
|
|
2445
2544
|
await put(root, at("pages/api-dashboard/index.tsx"), renderDashboardPage(manifest), result, gen);
|
|
2446
2545
|
await put(root, at("pages/api-dashboard/index.server.ts"), CLIENT_ONLY, result, gen);
|
|
2447
2546
|
await put(root, at("pages/sign-in/index.tsx"), renderSignInPage(manifest), result, gen);
|
|
@@ -2909,7 +3008,7 @@ ${color3.red("NOT ready to go live.")}
|
|
|
2909
3008
|
}
|
|
2910
3009
|
|
|
2911
3010
|
// src/deploy/cloudflare.ts
|
|
2912
|
-
import { existsSync as existsSync8, readFileSync as readFileSync3, writeFileSync } from "node:fs";
|
|
3011
|
+
import { copyFileSync, existsSync as existsSync8, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync } from "node:fs";
|
|
2913
3012
|
import { dirname as dirname5, join as join8 } from "node:path";
|
|
2914
3013
|
import color4 from "picocolors";
|
|
2915
3014
|
var fail = (message) => {
|
|
@@ -2999,11 +3098,32 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
2999
3098
|
const builder = findBuilder(app);
|
|
3000
3099
|
if (!builder)
|
|
3001
3100
|
return fail("no build tool: neither vite-plus nor vite is installed.");
|
|
3101
|
+
const merging = oneOrigin(project.manifest.layout) && !isSingleApp(project.manifest.layout);
|
|
3102
|
+
if (merging) {
|
|
3103
|
+
console.log(`
|
|
3104
|
+
▸ ${builder.label} ${color4.dim("(the storefront)")}`);
|
|
3105
|
+
const storefront = await wrangler(builder.cmd, ["build"], project.root, true);
|
|
3106
|
+
if (storefront.code !== 0)
|
|
3107
|
+
return fail(`the storefront build failed (exit ${storefront.code}).`);
|
|
3108
|
+
}
|
|
3002
3109
|
console.log(`
|
|
3003
|
-
▸ ${builder.label}`);
|
|
3110
|
+
▸ ${builder.label}${merging ? color4.dim(" (the worker)") : ""}`);
|
|
3004
3111
|
const built = await wrangler(builder.cmd, ["build"], app, true);
|
|
3005
3112
|
if (built.code !== 0)
|
|
3006
3113
|
return fail(`the build failed (exit ${built.code}).`);
|
|
3114
|
+
if (merging) {
|
|
3115
|
+
const from = join8(project.root, "dist", "client");
|
|
3116
|
+
const into = join8(app, "dist", "client");
|
|
3117
|
+
if (!existsSync8(from))
|
|
3118
|
+
return fail(`the storefront build emitted no ${from} — is the root a Void app with output "static"?`);
|
|
3119
|
+
if (!existsSync8(into))
|
|
3120
|
+
return fail(`the worker build emitted no ${into}; there is nothing to merge into.`);
|
|
3121
|
+
const merged = mergeTree(from, into, new Set([".vite"]));
|
|
3122
|
+
if (!existsSync8(join8(into, "index.html"))) {
|
|
3123
|
+
return fail("the storefront produced no index.html, so the shop has no front page.");
|
|
3124
|
+
}
|
|
3125
|
+
console.log(`${color4.green("✓")} folded ${merged} storefront file${merged === 1 ? "" : "s"} into the worker's assets`);
|
|
3126
|
+
}
|
|
3007
3127
|
const emittedPath = join8(app, "dist", "ssr", "wrangler.json");
|
|
3008
3128
|
if (!existsSync8(emittedPath))
|
|
3009
3129
|
return fail(`the build emitted no ${emittedPath} — is this a Void app on the Cloudflare target?`);
|
|
@@ -3046,10 +3166,28 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3046
3166
|
const url = `https://${workerHosts(project.manifest)[0]}`;
|
|
3047
3167
|
console.log(`
|
|
3048
3168
|
${color4.green("Live:")} ${url}`);
|
|
3049
|
-
if (hasFrontend(project.manifest.layout))
|
|
3169
|
+
if (hasFrontend(project.manifest.layout) && !oneOrigin(project.manifest.layout)) {
|
|
3050
3170
|
console.log(color4.dim("The storefront deploys itself from GitHub Actions on push."));
|
|
3171
|
+
}
|
|
3051
3172
|
return 0;
|
|
3052
3173
|
}
|
|
3174
|
+
function mergeTree(from, into, skip) {
|
|
3175
|
+
let count = 0;
|
|
3176
|
+
for (const entry of readdirSync2(from, { withFileTypes: true })) {
|
|
3177
|
+
if (skip.has(entry.name))
|
|
3178
|
+
continue;
|
|
3179
|
+
const source = join8(from, entry.name);
|
|
3180
|
+
const target = join8(into, entry.name);
|
|
3181
|
+
if (entry.isDirectory()) {
|
|
3182
|
+
mkdirSync(target, { recursive: true });
|
|
3183
|
+
count += mergeTree(source, target, skip);
|
|
3184
|
+
} else {
|
|
3185
|
+
copyFileSync(source, target);
|
|
3186
|
+
count += 1;
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
return count;
|
|
3190
|
+
}
|
|
3053
3191
|
|
|
3054
3192
|
// src/import.ts
|
|
3055
3193
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
@@ -446,7 +446,18 @@ var CARRIERS = {
|
|
|
446
446
|
{ id: "fedex", label: "FedEx", hint: "your own FedEx account, for negotiated rates an aggregator cannot see", env: [{ key: "FEDEX_CLIENT_ID", breaks: "FedEx rates are not quoted" }, { key: "FEDEX_CLIENT_SECRET", breaks: "the same" }, { key: "FEDEX_ACCOUNT_NUMBER", breaks: "FedEx has no account to bill the transport to" }] },
|
|
447
447
|
{ id: "dhl-express", label: "DHL Express", hint: "international express, and the one worth having for a shop that posts abroad", env: [{ key: "DHL_API_KEY", breaks: "DHL rates are not quoted" }, { key: "DHL_API_SECRET", breaks: "the same" }, { key: "DHL_ACCOUNT_NUMBER", breaks: "DHL has no account to bill the transport to" }] },
|
|
448
448
|
{ id: "usps", label: "USPS", hint: "US domestic post — the cheapest way to ship a small parcel inside the US", env: [{ key: "USPS_CLIENT_ID", breaks: "USPS rates are not quoted" }, { key: "USPS_CLIENT_SECRET", breaks: "the same" }] },
|
|
449
|
-
{
|
|
449
|
+
{
|
|
450
|
+
id: "royal-mail",
|
|
451
|
+
label: "Royal Mail (Click & Drop)",
|
|
452
|
+
hint: "UK post. Needs an OBA business account — Click & Drop generates labels through the API only for those. Prices come from your own tariff table, because no Royal Mail API quotes rates",
|
|
453
|
+
env: [
|
|
454
|
+
{
|
|
455
|
+
key: "CLICK_DROP_AUTH_KEY",
|
|
456
|
+
breaks: "no label and no tracking number: orders are created in Click & Drop but never despatched",
|
|
457
|
+
where: "Click & Drop → Settings → Integrations → Click & Drop API"
|
|
458
|
+
}
|
|
459
|
+
]
|
|
460
|
+
},
|
|
450
461
|
{ id: "aramex", label: "Aramex", hint: "the carrier to reach the Gulf and wider Middle East reliably", env: [{ key: "ARAMEX_API_KEY", breaks: "Aramex rates are not quoted" }] },
|
|
451
462
|
{ id: "doordash-drive", label: "DoorDash Drive", hint: "same-day local delivery", env: [{ key: "DOORDASH_DEVELOPER_ID", breaks: "DoorDash is not offered" }] },
|
|
452
463
|
{ id: "stuart", label: "Stuart", hint: "same-day courier, Europe", env: [{ key: "STUART_CLIENT_ID", breaks: "Stuart is not offered" }] },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CHOICES,
|
|
3
3
|
GROUPS
|
|
4
|
-
} from "./index-
|
|
4
|
+
} from "./index-mnb7fz2t.js";
|
|
5
5
|
|
|
6
6
|
// src/manifest.ts
|
|
7
7
|
import { readFile, writeFile } 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-85m0qkmg.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-6w8rwtsh.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-tjy6yygc.js";
|
|
83
84
|
import {
|
|
84
85
|
AUTH_PLUGINS,
|
|
85
86
|
CARRIERS,
|
|
@@ -96,7 +97,7 @@ import {
|
|
|
96
97
|
SIGN_IN,
|
|
97
98
|
TAX,
|
|
98
99
|
UI
|
|
99
|
-
} from "./index-
|
|
100
|
+
} from "./index-mnb7fz2t.js";
|
|
100
101
|
import"./index-0v6na3yp.js";
|
|
101
102
|
export {
|
|
102
103
|
zoneOf,
|
|
@@ -147,6 +148,7 @@ export {
|
|
|
147
148
|
parseWhoAmI,
|
|
148
149
|
parseJsonc,
|
|
149
150
|
packagesOf,
|
|
151
|
+
oneOrigin,
|
|
150
152
|
isSingleApp,
|
|
151
153
|
isApex,
|
|
152
154
|
importCommand,
|
|
@@ -5,9 +5,9 @@ import {
|
|
|
5
5
|
keysCommand,
|
|
6
6
|
provisionKey,
|
|
7
7
|
publicKeyFor
|
|
8
|
-
} from "./index-
|
|
9
|
-
import"./index-
|
|
10
|
-
import"./index-
|
|
8
|
+
} from "./index-6w8rwtsh.js";
|
|
9
|
+
import"./index-tjy6yygc.js";
|
|
10
|
+
import"./index-mnb7fz2t.js";
|
|
11
11
|
import"./index-0v6na3yp.js";
|
|
12
12
|
export {
|
|
13
13
|
publicKeyFor,
|
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,8 +18,8 @@ import {
|
|
|
17
18
|
writeManifest,
|
|
18
19
|
zone,
|
|
19
20
|
zoneOf
|
|
20
|
-
} from "./index-
|
|
21
|
-
import"./index-
|
|
21
|
+
} from "./index-tjy6yygc.js";
|
|
22
|
+
import"./index-mnb7fz2t.js";
|
|
22
23
|
import"./index-0v6na3yp.js";
|
|
23
24
|
export {
|
|
24
25
|
zoneOf,
|
|
@@ -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.7.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/catalog.ts
CHANGED
|
@@ -519,7 +519,18 @@ export const CARRIERS: Group = {
|
|
|
519
519
|
{ id: "fedex", label: "FedEx", hint: "your own FedEx account, for negotiated rates an aggregator cannot see", env: [{ key: "FEDEX_CLIENT_ID", breaks: "FedEx rates are not quoted" }, { key: "FEDEX_CLIENT_SECRET", breaks: "the same" }, { key: "FEDEX_ACCOUNT_NUMBER", breaks: "FedEx has no account to bill the transport to" }] },
|
|
520
520
|
{ id: "dhl-express", label: "DHL Express", hint: "international express, and the one worth having for a shop that posts abroad", env: [{ key: "DHL_API_KEY", breaks: "DHL rates are not quoted" }, { key: "DHL_API_SECRET", breaks: "the same" }, { key: "DHL_ACCOUNT_NUMBER", breaks: "DHL has no account to bill the transport to" }] },
|
|
521
521
|
{ id: "usps", label: "USPS", hint: "US domestic post — the cheapest way to ship a small parcel inside the US", env: [{ key: "USPS_CLIENT_ID", breaks: "USPS rates are not quoted" }, { key: "USPS_CLIENT_SECRET", breaks: "the same" }] },
|
|
522
|
-
{
|
|
522
|
+
{
|
|
523
|
+
id: "royal-mail",
|
|
524
|
+
label: "Royal Mail (Click & Drop)",
|
|
525
|
+
hint: "UK post. Needs an OBA business account — Click & Drop generates labels through the API only for those. Prices come from your own tariff table, because no Royal Mail API quotes rates",
|
|
526
|
+
env: [
|
|
527
|
+
{
|
|
528
|
+
key: "CLICK_DROP_AUTH_KEY",
|
|
529
|
+
breaks: "no label and no tracking number: orders are created in Click & Drop but never despatched",
|
|
530
|
+
where: "Click & Drop → Settings → Integrations → Click & Drop API",
|
|
531
|
+
},
|
|
532
|
+
],
|
|
533
|
+
},
|
|
523
534
|
{ id: "aramex", label: "Aramex", hint: "the carrier to reach the Gulf and wider Middle East reliably", env: [{ key: "ARAMEX_API_KEY", breaks: "Aramex rates are not quoted" }] },
|
|
524
535
|
{ id: "doordash-drive", label: "DoorDash Drive", hint: "same-day local delivery", env: [{ key: "DOORDASH_DEVELOPER_ID", breaks: "DoorDash is not offered" }] },
|
|
525
536
|
{ id: "stuart", label: "Stuart", hint: "same-day courier, Europe", env: [{ key: "STUART_CLIENT_ID", breaks: "Stuart is not offered" }] },
|
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/auth.ts
CHANGED
|
@@ -49,6 +49,20 @@ const social = (manifest: Manifest, id: string, envPrefix: string) =>
|
|
|
49
49
|
? ` ${id}: { clientId: must(env, "${envPrefix}_CLIENT_ID"), clientSecret: must(env, "${envPrefix}_CLIENT_SECRET") },`
|
|
50
50
|
: null;
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Carriers with a provider behind them, and whether each needs `lib/shipping.ts`.
|
|
54
|
+
*
|
|
55
|
+
* Exported because `generate/index.ts` has to know which shops get that file,
|
|
56
|
+
* and a second hand-maintained list of carrier ids WILL drift from this one —
|
|
57
|
+
* it already did: adding Royal Mail here left the file ungenerated, so the
|
|
58
|
+
* generated `auth.ts` imported a price table that did not exist.
|
|
59
|
+
*
|
|
60
|
+
* The token needs nothing: no origin, no tariff. Everything else needs one or
|
|
61
|
+
* the other, so the rule is simply "any carrier but the token".
|
|
62
|
+
*/
|
|
63
|
+
export const WIRED_CARRIERS = ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"] as const;
|
|
64
|
+
export const needsShippingConfig = (id: string): boolean => WIRED_CARRIERS.includes(id as never) && id !== "nft";
|
|
65
|
+
|
|
52
66
|
export function renderAuthTs(manifest: Manifest): string {
|
|
53
67
|
const { shop } = manifest;
|
|
54
68
|
const lines: Line[] = [];
|
|
@@ -68,6 +82,14 @@ export function renderAuthTs(manifest: Manifest): string {
|
|
|
68
82
|
const CARRIER_CALLS: Record<string, { call: string; label: string; import: string }> = {
|
|
69
83
|
// Every real carrier needs the ORIGIN it ships from, which is shop data
|
|
70
84
|
// rather than a credential — so it lives in an owned lib/shipping.ts.
|
|
85
|
+
"royal-mail": {
|
|
86
|
+
label: "Royal Mail",
|
|
87
|
+
// No `from`: Click & Drop posts from the account's registered
|
|
88
|
+
// address, so an origin here would be ignored. `services` is the
|
|
89
|
+
// price table in lib/shipping.ts, because nothing quotes Royal Mail.
|
|
90
|
+
call: `royalMailProvider({ apiKey: must(env, "CLICK_DROP_AUTH_KEY"), services: royalMailServices, currency: "${shop.currency}", carrierName: "Royal Mail" })`,
|
|
91
|
+
import: `import { royalMailProvider } from "@saastemly/better-commerce/providers/royal-mail";`,
|
|
92
|
+
},
|
|
71
93
|
"dhl-express": {
|
|
72
94
|
label: "DHL Express",
|
|
73
95
|
call: `dhlExpressProvider({ apiKey: must(env, "DHL_API_KEY"), apiSecret: must(env, "DHL_API_SECRET"), accountNumber: must(env, "DHL_ACCOUNT_NUMBER"), from: shipFrom })`,
|
|
@@ -101,8 +123,18 @@ export function renderAuthTs(manifest: Manifest): string {
|
|
|
101
123
|
};
|
|
102
124
|
const carriers = Object.keys(CARRIER_CALLS).filter((id) => has(manifest, id));
|
|
103
125
|
const carrierImports = carriers.map((id) => CARRIER_CALLS[id]!.import);
|
|
104
|
-
//
|
|
105
|
-
|
|
126
|
+
// One import from lib/shipping.ts, naming only what is actually used —
|
|
127
|
+
// an unused binding is a type error in a shop with `noUnusedLocals`.
|
|
128
|
+
//
|
|
129
|
+
// The token needs no origin. Royal Mail needs no origin either: Click &
|
|
130
|
+
// Drop posts from the address registered on the account, so a `from` here
|
|
131
|
+
// would be silently ignored. It needs the price table instead, because
|
|
132
|
+
// nothing quotes Royal Mail.
|
|
133
|
+
const shipping = [
|
|
134
|
+
...(carriers.some((id) => id !== "nft" && id !== "royal-mail") ? ["shipFrom"] : []),
|
|
135
|
+
...(carriers.includes("royal-mail") ? ["royalMailServices"] : []),
|
|
136
|
+
];
|
|
137
|
+
if (shipping.length > 0) carrierImports.push(`import { ${shipping.join(", ")} } from "./lib/shipping.ts";`);
|
|
106
138
|
const wantsNft = has(manifest, "nft");
|
|
107
139
|
// One bridge for every carrier: which ships is decided by whose rate the
|
|
108
140
|
// buyer paid for, so a token order mints and a parcel order posts.
|
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,8 +2,8 @@ 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";
|
|
6
|
-
import { renderAuthTs } from "./auth";
|
|
5
|
+
import { MANIFEST_FILE, type Manifest, has, isSingleApp, oneOrigin, packagesOf, specifier, workerHosts, writeManifest, zone } from "../manifest";
|
|
6
|
+
import { renderAuthTs, WIRED_CARRIERS, needsShippingConfig } from "./auth";
|
|
7
7
|
import {
|
|
8
8
|
envSummary,
|
|
9
9
|
renderEnvExample,
|
|
@@ -189,8 +189,10 @@ async function generateApi(root: string, dir: string, manifest: Manifest, result
|
|
|
189
189
|
if (has(manifest, "nft")) {
|
|
190
190
|
await put(root, at("lib/mint.ts"), renderMintTs(), result, own);
|
|
191
191
|
}
|
|
192
|
-
// Any carrier but the token
|
|
193
|
-
|
|
192
|
+
// Any carrier but the token needs shop data of its own — an origin to quote
|
|
193
|
+
// against, or a price table. The list lives with the carriers themselves so
|
|
194
|
+
// it cannot drift from them.
|
|
195
|
+
if (WIRED_CARRIERS.some((id) => needsShippingConfig(id) && has(manifest, id))) {
|
|
194
196
|
await put(root, at("lib/shipping.ts"), renderShippingTs(manifest), result, own);
|
|
195
197
|
}
|
|
196
198
|
if (has(manifest, "business-central")) {
|
|
@@ -255,7 +257,7 @@ async function generateApi(root: string, dir: string, manifest: Manifest, result
|
|
|
255
257
|
);
|
|
256
258
|
}
|
|
257
259
|
|
|
258
|
-
/** The
|
|
260
|
+
/** The storefront: served by the worker in strict, by GitHub Pages in a monorepo. */
|
|
259
261
|
async function generateFrontend(root: string, dir: string, manifest: Manifest, result: GenerateResult) {
|
|
260
262
|
const at = (file: string) => join(dir, file);
|
|
261
263
|
|
|
@@ -265,7 +267,14 @@ async function generateFrontend(root: string, dir: string, manifest: Manifest, r
|
|
|
265
267
|
await put(root, at("void.json"), renderFrontendVoidJson(), result, "own");
|
|
266
268
|
await put(root, at("tsconfig.json"), renderFrontendTsconfig(), result, "own");
|
|
267
269
|
await put(root, at("pages/index.tsx"), renderStorefrontPage(manifest), result, "own");
|
|
268
|
-
|
|
270
|
+
// A separate publishing workflow exists only when the storefront has a
|
|
271
|
+
// separate host. In strict the worker serves it, so the branch, the
|
|
272
|
+
// workflow, the CNAME file and the base-path rewriting all go away.
|
|
273
|
+
if (oneOrigin(manifest.layout)) {
|
|
274
|
+
await retire(root, ".github/workflows/frontend-static.yml", result);
|
|
275
|
+
} else {
|
|
276
|
+
await put(root, ".github/workflows/frontend-static.yml", renderFrontendWorkflow(manifest, dir), result, "regenerate");
|
|
277
|
+
}
|
|
269
278
|
|
|
270
279
|
// A monorepo's frontend is its own workspace; strict's shares the root's
|
|
271
280
|
// one package.json, which generateStrictRoot has already written.
|
|
@@ -404,8 +413,20 @@ async function generateStrictApp(root: string, dir: string, manifest: Manifest,
|
|
|
404
413
|
await put(root, at("db/seed.ts"), renderDbSeed(), result, gen);
|
|
405
414
|
await put(root, at("pages/layout.tsx"), renderLayoutTsx(), result, gen);
|
|
406
415
|
await put(root, at("pages/app.css"), renderAppCss(), result, gen);
|
|
407
|
-
|
|
408
|
-
|
|
416
|
+
// In strict the STOREFRONT owns "/": its prerendered index.html is merged
|
|
417
|
+
// into the worker's assets, and the worker reaches assets only by 404ing
|
|
418
|
+
// first. A worker index page here would shadow the shop's front door.
|
|
419
|
+
if (!oneOrigin(manifest.layout) || isSingleApp(manifest.layout)) {
|
|
420
|
+
await put(root, at("pages/index.server.ts"), renderIndexServer(), result, gen);
|
|
421
|
+
await put(root, at("pages/index.tsx"), INDEX_PAGE, result, gen);
|
|
422
|
+
} else {
|
|
423
|
+
// Deleted, not merely skipped. A shop regenerated from the older
|
|
424
|
+
// two-origin layout still has these on disk, and a leftover index page
|
|
425
|
+
// would answer "/" instead of 404ing through to the storefront — the
|
|
426
|
+
// shop would silently serve a blank redirect as its front door.
|
|
427
|
+
await retire(root, at("pages/index.server.ts"), result);
|
|
428
|
+
await retire(root, at("pages/index.tsx"), result);
|
|
429
|
+
}
|
|
409
430
|
await put(root, at("pages/api-dashboard/index.tsx"), renderDashboardPage(manifest), result, gen);
|
|
410
431
|
await put(root, at("pages/api-dashboard/index.server.ts"), CLIENT_ONLY, result, gen);
|
|
411
432
|
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));
|
|
@@ -272,13 +274,22 @@ export async function destinationFor(orderId: string): Promise<string | null> {
|
|
|
272
274
|
* deploy, so both are marked rather than left blank.
|
|
273
275
|
*/
|
|
274
276
|
export function renderShippingTs(manifest: Manifest): string {
|
|
275
|
-
|
|
277
|
+
const royalMail = has(manifest, "royal-mail");
|
|
278
|
+
// Royal Mail posts from the address registered on the Click & Drop account,
|
|
279
|
+
// so an origin here would be ignored. Only carriers that QUOTE need one.
|
|
280
|
+
const origin = ["dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id));
|
|
276
281
|
|
|
282
|
+
const imports = [
|
|
283
|
+
...(origin ? [`import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";`] : []),
|
|
284
|
+
...(royalMail ? [`import type { RoyalMailService } from "@saastemly/better-commerce/providers/royal-mail";`] : []),
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
const originBlock = `
|
|
277
288
|
/**
|
|
278
289
|
* The address every parcel is quoted and shipped FROM.
|
|
279
290
|
*
|
|
280
|
-
* TODO VERIFY: this is a placeholder. A carrier quotes against it, and
|
|
281
|
-
*
|
|
291
|
+
* TODO VERIFY: this is a placeholder. A carrier quotes against it, and most
|
|
292
|
+
* refuse a shipment whose origin has no \`name\` and \`phone\`.
|
|
282
293
|
*/
|
|
283
294
|
export const shipFrom: DeliveryAddress = {
|
|
284
295
|
name: "${manifest.shop.name}",
|
|
@@ -289,6 +300,8 @@ export const shipFrom: DeliveryAddress = {
|
|
|
289
300
|
phone: "TODO VERIFY: +44…",
|
|
290
301
|
};
|
|
291
302
|
`;
|
|
303
|
+
|
|
304
|
+
return `${imports.join("\n")}\n${origin ? originBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
|
|
292
305
|
}
|
|
293
306
|
|
|
294
307
|
export function renderErpTs(): string {
|
|
@@ -318,3 +331,56 @@ export const erpFulfillment = (currency: string): FulfillmentProvider =>
|
|
|
318
331
|
}).fulfillment();
|
|
319
332
|
`;
|
|
320
333
|
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Royal Mail's price table — YOUR prices, because nothing will quote them.
|
|
338
|
+
*
|
|
339
|
+
* This is the one carrier here that cannot be asked what postage costs. No
|
|
340
|
+
* Royal Mail API quotes rates: not Click & Drop, not Shipping V3, not
|
|
341
|
+
* anything on the developer portal. Click & Drop imports orders and prints
|
|
342
|
+
* labels; postage is charged afterwards at whatever your OBA agreement says.
|
|
343
|
+
*
|
|
344
|
+
* So the shop quotes from this table, and the table has to match the rate
|
|
345
|
+
* card on your account or you will undercharge every order and find out at
|
|
346
|
+
* the invoice. The codes below are real Royal Mail service codes; the prices
|
|
347
|
+
* are NOT — they are placeholders, in pence.
|
|
348
|
+
*/
|
|
349
|
+
const ROYAL_MAIL_SERVICES = (): string => `
|
|
350
|
+
/**
|
|
351
|
+
* What this shop charges for Royal Mail, by total parcel weight.
|
|
352
|
+
*
|
|
353
|
+
* TODO VERIFY: every \`amount\` below is a placeholder in pence. Replace them
|
|
354
|
+
* with your own OBA rate card — Click & Drop → Settings → Shipping services
|
|
355
|
+
* lists exactly which services your account has, and the codes here must be
|
|
356
|
+
* among them. Service codes are account-specific: the published list is from
|
|
357
|
+
* 2021 and Royal Mail warn that "available services are unique to your OBA
|
|
358
|
+
* account and individual service agreements".
|
|
359
|
+
*
|
|
360
|
+
* The band whose \`maxGrams\` first covers the parcel wins, so order does not
|
|
361
|
+
* matter. A parcel heavier than every band gets no rate at all rather than a
|
|
362
|
+
* wrong one, and Click & Drop refuses anything over 30 kg.
|
|
363
|
+
*/
|
|
364
|
+
export const royalMailServices: RoyalMailService[] = [
|
|
365
|
+
{
|
|
366
|
+
code: "TPN24",
|
|
367
|
+
name: "Tracked 24",
|
|
368
|
+
estimatedDays: 1,
|
|
369
|
+
bands: [
|
|
370
|
+
{ maxGrams: 1000, amount: 0 /* TODO VERIFY */ },
|
|
371
|
+
{ maxGrams: 2000, amount: 0 /* TODO VERIFY */ },
|
|
372
|
+
{ maxGrams: 5000, amount: 0 /* TODO VERIFY */ },
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
code: "TPS48",
|
|
377
|
+
name: "Tracked 48",
|
|
378
|
+
estimatedDays: 2,
|
|
379
|
+
bands: [
|
|
380
|
+
{ maxGrams: 1000, amount: 0 /* TODO VERIFY */ },
|
|
381
|
+
{ maxGrams: 2000, amount: 0 /* TODO VERIFY */ },
|
|
382
|
+
{ maxGrams: 5000, amount: 0 /* TODO VERIFY */ },
|
|
383
|
+
],
|
|
384
|
+
},
|
|
385
|
+
];
|
|
386
|
+
`;
|
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
|
};
|