@forgecart/cli 2.202610052143.0 → 2.202610060357.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.
Files changed (125) hide show
  1. package/package.json +1 -1
  2. package/templates/storefront-shadcn/.forgecartignore +2 -0
  3. package/templates/storefront-shadcn/Procfile +1 -0
  4. package/templates/storefront-shadcn/README.md +229 -0
  5. package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
  6. package/templates/storefront-shadcn/components.json +21 -0
  7. package/templates/storefront-shadcn/next.config.js +105 -0
  8. package/templates/storefront-shadcn/package.json +39 -0
  9. package/templates/storefront-shadcn/postcss.config.js +5 -0
  10. package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
  11. package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
  12. package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  13. package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  14. package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
  15. package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
  16. package/templates/storefront-shadcn/src/app/error.tsx +23 -0
  17. package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
  18. package/templates/storefront-shadcn/src/app/globals.css +156 -0
  19. package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
  20. package/templates/storefront-shadcn/src/app/page.tsx +217 -0
  21. package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
  22. package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
  23. package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
  24. package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
  25. package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
  26. package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
  27. package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
  28. package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
  29. package/templates/storefront-shadcn/src/app/robots.ts +69 -0
  30. package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
  31. package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
  32. package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
  33. package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
  34. package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
  35. package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
  36. package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
  37. package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
  38. package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
  39. package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
  40. package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
  41. package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
  42. package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
  43. package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
  44. package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
  45. package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
  46. package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
  47. package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
  48. package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
  49. package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  50. package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
  51. package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
  52. package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
  53. package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
  54. package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
  55. package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
  56. package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
  57. package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
  58. package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
  59. package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
  60. package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
  61. package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
  62. package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
  63. package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
  64. package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
  65. package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
  66. package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
  67. package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
  68. package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
  69. package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
  70. package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
  71. package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
  72. package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
  73. package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
  74. package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
  75. package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
  76. package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
  77. package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
  78. package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
  79. package/templates/storefront-shadcn/src/lib/format.ts +89 -0
  80. package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
  81. package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
  82. package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
  83. package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
  84. package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
  85. package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
  86. package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
  87. package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
  88. package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
  89. package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
  90. package/templates/storefront-shadcn/src/lib/money.ts +50 -0
  91. package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
  92. package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
  93. package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
  94. package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
  95. package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
  96. package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
  97. package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
  98. package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
  99. package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
  100. package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
  101. package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
  102. package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
  103. package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
  104. package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
  105. package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
  106. package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
  107. package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
  108. package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
  109. package/templates/storefront-shadcn/src/middleware.ts +379 -0
  110. package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
  111. package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
  112. package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
  113. package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
  114. package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
  115. package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
  116. package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  117. package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  118. package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
  119. package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
  120. package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
  121. package/templates/storefront-shadcn/src/server/runner.ts +90 -0
  122. package/templates/storefront-shadcn/src/server/types.ts +36 -0
  123. package/templates/storefront-shadcn/tsconfig.json +25 -0
  124. package/templates/storefront-shadcn-sdk-floor.json +1174 -0
  125. package/templates/template-set.json +10 -0
@@ -0,0 +1,152 @@
1
+ import {
2
+ buildUpstreamHeaders,
3
+ getUpstreamConfig,
4
+ readMintedSessionToken,
5
+ type ForwardHeaders,
6
+ } from './track-forward';
7
+
8
+ /**
9
+ * Marketing-identity upstream forwarder (#1596).
10
+ *
11
+ * ONE implementation of "tell the ForgeCart shop API which ad click this
12
+ * shopper arrived from". The click IDs a landing URL carries (`gclid`,
13
+ * `fbclid`, `ttclid`) are read on the client by `lib/marketing-params.ts`,
14
+ * posted to the same-origin `/__fc/identify` relay, and land here as a single
15
+ * `setMarketingIdentifiers` mutation.
16
+ *
17
+ * Why a module of its own rather than a branch inside `track-forward.ts`: a
18
+ * click ID is not an event. It names the IDENTITY the events belong to, it is
19
+ * sent once per landing instead of once per batch, and the ad platforms read it
20
+ * back out of that identity months later when a conversion is reported. The two
21
+ * forwarders share their TRANSPORT and nothing else — `getUpstreamConfig`,
22
+ * `buildUpstreamHeaders` and `readMintedSessionToken` live next door and are
23
+ * imported rather than restated, so the session a request rides can never
24
+ * differ between them; the shapes stay apart because they describe different
25
+ * things.
26
+ *
27
+ * Same discipline as the event forwarder, for the same reasons: raw HTTP
28
+ * GraphQL (the generated SDK transports over one WebSocket whose connection
29
+ * pins ONE identity, and has no plain-HTTP query path), inert while
30
+ * `forgecart init` has not written `.env` yet, and EVERY failure mode —
31
+ * missing config, network, HTTP status, GraphQL errors — resolving to a typed
32
+ * negative outcome instead of a throw. Attribution must never break the page
33
+ * the shopper came for.
34
+ */
35
+
36
+ /**
37
+ * The SDK's own `ShopSetMarketingIdentifiers` document, inlined verbatim.
38
+ *
39
+ * Kept character-identical to the shipped operation
40
+ * (`app/client-sdk-generators/src/operations/shop/marketing-identity.graphql`)
41
+ * so checking the two for drift is a diff rather than a reading. `identityId`
42
+ * belongs to that document and is deliberately not read below: the storefront
43
+ * has no use for the backend's internal id, and the client it answers has even
44
+ * less.
45
+ */
46
+ const SET_MARKETING_IDENTIFIERS_MUTATION = `mutation ShopSetMarketingIdentifiers($input: SetMarketingIdentifiersInput!) {
47
+ setMarketingIdentifiers(input: $input) {
48
+ identityId
49
+ accepted
50
+ rejected
51
+ }
52
+ }`;
53
+
54
+ /**
55
+ * One click ID, shaped for the API's `MarketingIdentifierInput`.
56
+ *
57
+ * The client parser's `MarketingIdentifier` is the same pair by design — this
58
+ * declaration exists because the SERVER side of the hop mirrors the SDL (the
59
+ * sibling's `TrackEventInput` does the same), and the relay route rebuilds the
60
+ * pair from untrusted JSON rather than trusting anything the client typed.
61
+ */
62
+ export interface MarketingIdentifierInput {
63
+ /** The registered identifier key the value is stored under. */
64
+ key: string;
65
+ /** The click ID exactly as the ad platform wrote it into the URL. */
66
+ value: string;
67
+ }
68
+
69
+ /**
70
+ * What the upstream made of the identifiers.
71
+ *
72
+ * `rejected` is NOT an error and must never be turned into one. The backend
73
+ * partitions the submitted keys against its identifier registry, stores the
74
+ * ones it knows, drops the ones it does not and REPORTS both — no throw, no
75
+ * rollback, no partial failure (`MarketingIdentityService.setIdentifiersForShop`).
76
+ * A key landing in `rejected` wrote no row and broke nothing; it means only
77
+ * that this storefront and the backend registry disagree about a spelling,
78
+ * which is a deployment fact worth being able to read and not a runtime
79
+ * condition to handle. Nothing downstream may treat a non-empty `rejected` as a
80
+ * reason to retry, to fail the request, or to hold up the page.
81
+ */
82
+ export interface IdentifyOutcome {
83
+ /** Identifier keys the backend recognised and stored. */
84
+ accepted: readonly string[];
85
+ /** Keys it does not know — dropped upstream; see the note above. */
86
+ rejected: readonly string[];
87
+ /** Session token surfaced in the response extensions, if any. */
88
+ sessionToken: string | null;
89
+ }
90
+
91
+ interface GraphQLIdentifyResponse {
92
+ data?: {
93
+ setMarketingIdentifiers?: { accepted?: string[]; rejected?: string[] } | null;
94
+ } | null;
95
+ errors?: unknown[];
96
+ extensions?: Record<string, unknown>;
97
+ }
98
+
99
+ /** The outcome every failure mode answers with: nothing stored, nothing minted. */
100
+ const NO_IDENTIFIERS_STORED: IdentifyOutcome = { accepted: [], rejected: [], sessionToken: null };
101
+
102
+ /**
103
+ * Forward the captured click IDs as one raw HTTP GraphQL POST.
104
+ *
105
+ * One call for the whole list — the mutation takes an array and the backend
106
+ * writes them against a single identity, so there is no per-item sequencing to
107
+ * get wrong here (the event relay loops only because `trackEvent` takes one
108
+ * event at a time).
109
+ *
110
+ * Any failure — missing config, network, HTTP status, GraphQL errors —
111
+ * resolves to an empty outcome; the forwarder is best-effort by design and the
112
+ * outcome is terminal (callers never retry).
113
+ */
114
+ export async function forwardMarketingIdentifiers(
115
+ identifiers: readonly MarketingIdentifierInput[],
116
+ forward: ForwardHeaders,
117
+ ): Promise<IdentifyOutcome> {
118
+ const upstream = getUpstreamConfig();
119
+ if (!upstream) return NO_IDENTIFIERS_STORED;
120
+
121
+ try {
122
+ const response = await fetch(upstream.shopApiUrl, {
123
+ method: 'POST',
124
+ headers: buildUpstreamHeaders(upstream.channelToken, forward),
125
+ body: JSON.stringify({
126
+ query: SET_MARKETING_IDENTIFIERS_MUTATION,
127
+ variables: { input: { identifiers } },
128
+ }),
129
+ });
130
+ if (!response.ok) return NO_IDENTIFIERS_STORED;
131
+
132
+ const payload = (await response.json()) as GraphQLIdentifyResponse;
133
+ // Read the mint even when the mutation itself did not answer: a cookie-less
134
+ // request mints the session in middleware, BEFORE the resolver runs, so the
135
+ // token is real regardless of what happened to the identifiers — and the
136
+ // relay must persist it or the next request mints a second identity.
137
+ const capturedToken = readMintedSessionToken(payload.extensions);
138
+ const result = payload.data?.setMarketingIdentifiers;
139
+ if (!result) return { accepted: [], rejected: [], sessionToken: capturedToken };
140
+ return {
141
+ accepted: result.accepted ?? [],
142
+ rejected: result.rejected ?? [],
143
+ sessionToken: capturedToken,
144
+ };
145
+ } catch {
146
+ // Best-effort by design: an unreachable or still-booting backend must never
147
+ // break the landing page. The click ID is lost with the page view it came
148
+ // with, which is the accepted cost of never letting analytics hold up a
149
+ // shopper — no caller retries.
150
+ return NO_IDENTIFIERS_STORED;
151
+ }
152
+ }
@@ -0,0 +1,169 @@
1
+ import 'server-only';
2
+
3
+ import { createChannelLocalesCache } from './channel-locales';
4
+ import type { ChannelLocales, ChannelLocalesResolution } from './channel-locales';
5
+ import { toChannelLocales } from './channel-locales-map';
6
+ import type { RawChannelLocales } from './channel-locales-map';
7
+
8
+ /**
9
+ * Binds the channel-language cache to the shop API (#1346, W1-8 / S1).
10
+ *
11
+ * Transport is a plain GraphQL POST, deliberately — NOT the SDK's shop client.
12
+ * This read happens inside the root layout, which is outside every Suspense
13
+ * boundary, so it sits on the first byte of every route. The SDK client's
14
+ * language is a per-CONNECTION header fixed at socket handshake, so using it
15
+ * here would put a WebSocket handshake plus an anonymous-session mint on that
16
+ * path, and a hung socket would hold the document open with no bound. The
17
+ * experiments config-fetch (`experiments.ts`) already reads the shop API this
18
+ * way for the same reason; this follows it, and adds the timeout that path
19
+ * lacks.
20
+ *
21
+ * Failure policy, in three layers, because this module decides what a
22
+ * storefront looks like when its backend is unwell:
23
+ *
24
+ * 1. Not configured yet — no fetch at all. The workspace-pod prewarm boots
25
+ * this server with no `.env`, and the layout must render anyway.
26
+ * 2. Fetch fails with a warm cache — the cache serves the last good snapshot
27
+ * and retries on the next request. Nothing here to do.
28
+ * 3. Fetch fails on a COLD process — {@link FALLBACK_LOCALES}. A site-wide
29
+ * 500 is the worst thing a crawler can be shown (5xx throttles crawl rate
30
+ * and drops URLs), so the storefront renders instead. The fallback is
31
+ * English-only, which is byte-for-byte what the root layout hardcoded
32
+ * before this slice existed — so an outage degrades to the previous
33
+ * behaviour rather than to something new. It renders, and it says so:
34
+ * `resolved: false` rides the snapshot, and the metadata floor turns that
35
+ * into `noindex` (#1347). Serving a guessed language is survivable;
36
+ * letting a crawler INDEX the guess is not.
37
+ */
38
+
39
+ const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
40
+ const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
41
+
42
+ /**
43
+ * The bound on how long the document shell may wait for the channel read.
44
+ * Short by intent: past this, serving the fallback beats holding the shell.
45
+ */
46
+ const CHANNEL_FETCH_TIMEOUT_MS = 2_000;
47
+
48
+ /**
49
+ * The channel snapshot the storefront's shell renders from.
50
+ *
51
+ * `ChannelLocales` plus the two facts that are not about languages but come
52
+ * from the same `activeChannel` document, and must come from the same SNAPSHOT
53
+ * of it: a shell that paired one window's language set with another window's
54
+ * shop name would be a torn read of the identical kind the locale resolution
55
+ * itself is built to avoid.
56
+ */
57
+ export interface StorefrontChannel extends ChannelLocales {
58
+ /**
59
+ * The merchant's shop name, or null when unset. The title suffix (#1347) —
60
+ * `null` and `''` both mean "no suffix", and that emptiness rule lives with
61
+ * the title rule, not here: this module reports what the channel said.
62
+ */
63
+ shopName: string | null;
64
+ /**
65
+ * Whether this snapshot came from a real `activeChannel` read.
66
+ *
67
+ * FALSE is the honest admission that the storefront does not know what
68
+ * channel it is serving — an unconfigured scaffold, or a cold process whose
69
+ * backend it could not reach. It rides on the snapshot rather than being
70
+ * derived by the caller because it is a property OF the snapshot: a stale
71
+ * one still says `true`, and correctly so — its languages were established,
72
+ * they are merely a minute old.
73
+ */
74
+ resolved: boolean;
75
+ }
76
+
77
+ /**
78
+ * What an unconfigured scaffold, or a cold process with an unreachable
79
+ * backend, serves. For the UNCONFIGURED case `en` is not a guess about the
80
+ * merchant — it is the value `<html lang>` carried unconditionally before the
81
+ * locale layer existed, and nothing real is being served anyway (the prewarm
82
+ * contract: answer the request, do not crash).
83
+ *
84
+ * For a CONFIGURED store on a cold process it IS a guess, and it is marked as
85
+ * one: `resolved: false`. A channel whose default is not English briefly
86
+ * serves `<html lang="en">` over its own language and 404s its default-language
87
+ * prefix instead of canonicalizing it, and no value here can fix that — the
88
+ * scaffold never learns the channel's default language, since `.env` carries
89
+ * the token and the URLs and nothing else.
90
+ *
91
+ * What CAN be fixed is the asserting. A storefront that does not know its
92
+ * channel makes no indexable claim at all: `resolved: false` closes the
93
+ * metadata floor (`lib/seo/metadata.ts`), so the shell renders — the prewarm
94
+ * contract holds — while telling crawlers to ignore it. That is the same
95
+ * posture this template already takes for a deployment with no public origin,
96
+ * and it closes the #1346 hand-off recorded on #1347.
97
+ */
98
+ const FALLBACK_LOCALES: StorefrontChannel = {
99
+ languageCodes: ['en'],
100
+ defaultLanguageCode: 'en',
101
+ shopName: null,
102
+ resolved: false,
103
+ };
104
+
105
+ const ACTIVE_CHANNEL_QUERY = `query ActiveChannelLocales {
106
+ activeChannel {
107
+ defaultLanguageCode
108
+ availableLanguageCodes
109
+ shopName
110
+ }
111
+ }`;
112
+
113
+ /**
114
+ * The shop name rides this read rather than one of its own: the layout already
115
+ * awaits this document on the first byte of every route, and a second cached
116
+ * fetch for one string would double that cost for a value the same response
117
+ * already carries.
118
+ */
119
+ type RawActiveChannel = RawChannelLocales & { shopName?: string | null };
120
+
121
+ interface ActiveChannelResponse {
122
+ data?: { activeChannel?: RawActiveChannel | null } | null;
123
+ }
124
+
125
+ /**
126
+ * THROWS on any failure, deliberately. The cache distinguishes "refresh
127
+ * failed, keep the good snapshot" from "cold start, nothing to serve", and it
128
+ * can only do that if this reports failure as failure. Returning a fallback
129
+ * here instead would cache English-only for a full TTL window on the first
130
+ * blip — rendering every locale in English while looking perfectly healthy.
131
+ */
132
+ async function loadChannelLocales(): Promise<StorefrontChannel> {
133
+ if (!SHOP_API_URL || !CHANNEL_TOKEN) return FALLBACK_LOCALES;
134
+
135
+ const response = await fetch(SHOP_API_URL, {
136
+ method: 'POST',
137
+ headers: { 'content-type': 'application/json', 'forgecart-token': CHANNEL_TOKEN },
138
+ body: JSON.stringify({ query: ACTIVE_CHANNEL_QUERY }),
139
+ signal: AbortSignal.timeout(CHANNEL_FETCH_TIMEOUT_MS),
140
+ });
141
+ if (!response.ok) {
142
+ throw new Error(`activeChannel read failed: HTTP ${response.status}`);
143
+ }
144
+ const payload = (await response.json()) as ActiveChannelResponse;
145
+ const channel = payload.data?.activeChannel;
146
+ if (!channel?.defaultLanguageCode) {
147
+ throw new Error('activeChannel read returned no defaultLanguageCode');
148
+ }
149
+ return { ...toChannelLocales(channel), shopName: channel.shopName ?? null, resolved: true };
150
+ }
151
+
152
+ const channelLocales = createChannelLocalesCache(loadChannelLocales);
153
+
154
+ /**
155
+ * The channel and whether it offers `candidate`, from one snapshot — and never
156
+ * rejecting, whatever the backend is doing. This is the boundary where layer 3
157
+ * of the failure policy is applied: the cache propagates a cold-start failure
158
+ * so that the decision is made HERE, in one visible place, rather than being
159
+ * invented inside the cache.
160
+ */
161
+ export async function resolveChannelLocales(
162
+ candidate: string | null,
163
+ ): Promise<ChannelLocalesResolution<StorefrontChannel>> {
164
+ try {
165
+ return await channelLocales.resolve(candidate);
166
+ } catch {
167
+ return { channel: FALLBACK_LOCALES, offered: false };
168
+ }
169
+ }
@@ -0,0 +1,46 @@
1
+ import { isLocaleSegment } from './grammar';
2
+ import type { ChannelLocales } from './channel-locales';
3
+
4
+ /**
5
+ * The channel payload → {@link ChannelLocales} coercion (#1346, W1-8 / S1).
6
+ *
7
+ * Pure and SDK-type-free on purpose: this is the one place the shop API's
8
+ * shape can be got wrong, and keeping it importable by the template spec
9
+ * project means it is pinned exhaustively instead of exercised only through a
10
+ * live channel.
11
+ */
12
+
13
+ /** The shape the shop API returns, with its own nullability. */
14
+ export interface RawChannelLocales {
15
+ defaultLanguageCode: string;
16
+ /**
17
+ * NULLABLE in the schema (`availableLanguageCodes: [String!]`) and in the
18
+ * SDK (`Maybe<Array<...>>`). The schema's own comment says null means "none
19
+ * enabled beyond the default" and that storefronts coalesce to
20
+ * `[defaultLanguageCode]` — which is exactly what this function does.
21
+ */
22
+ availableLanguageCodes?: readonly string[] | null;
23
+ }
24
+
25
+ /**
26
+ * Coerce the channel payload into the non-null set the grammar consumes.
27
+ *
28
+ * Union-with-the-default FIRST, which makes the result null-safe and
29
+ * empty-safe in one expression and lets the single-language channel fall out
30
+ * of the rule rather than needing its own branch: its only language IS the
31
+ * default, so it never serves a prefix.
32
+ *
33
+ * The extras are filtered through {@link isLocaleSegment}, the default is not.
34
+ * A stray `en-GB` from a channel configured outside the platform's 2-char
35
+ * registry is unreachable as a URL — the grammar can never parse a prefix for
36
+ * it — so admitting it would only mint a language-switcher link to a page that
37
+ * 404s. The default is exempt because it is never URL-visible: it is the
38
+ * language served UNPREFIXED, so its spelling never has to survive a parse.
39
+ */
40
+ export function toChannelLocales(raw: RawChannelLocales): ChannelLocales {
41
+ const codes = new Set<string>([raw.defaultLanguageCode]);
42
+ for (const code of raw.availableLanguageCodes ?? []) {
43
+ if (isLocaleSegment(code)) codes.add(code);
44
+ }
45
+ return { languageCodes: [...codes], defaultLanguageCode: raw.defaultLanguageCode };
46
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The channel's language set, cached per server process (#1346, epic
3
+ * launch#54 W1-8 / S1).
4
+ *
5
+ * The middleware cannot validate a locale — it is zero-fetch by contract — so
6
+ * a two-letter first segment reaches the layout as a CANDIDATE and is decided
7
+ * here, in the request-time hole.
8
+ *
9
+ * This cache sits on the critical path of the DOCUMENT SHELL of every route:
10
+ * the root layout must know the locale before it can write `<html lang>`, and
11
+ * that is structurally outside every Suspense boundary. Three requirements
12
+ * follow, and together they are the whole design of this module:
13
+ *
14
+ * - **A language added a moment ago must work on its very first request.** A
15
+ * plain TTL cache would 404 the new locale for up to a minute — exactly the
16
+ * "I added German and the German page is broken" report this slice exists
17
+ * to prevent. Hence: a MISS forces a live refetch before the answer is
18
+ * allowed to be `false`.
19
+ * - **But a miss is also what an unknown locale looks like.** Refetching on
20
+ * every miss would turn `/zz/`, `/qq/`, `/aa/` … into one live call per
21
+ * request, aimed at the storefront's own backend. Hence: at most ONE
22
+ * miss-driven refetch per TTL window.
23
+ * - **An expiry must never block a render, and a backend blip must never
24
+ * become a 5xx.** A stale snapshot is served immediately while a refresh
25
+ * runs behind it, and a refresh that fails leaves the last good snapshot in
26
+ * place. Without this, one slow backend puts a full round-trip on the first
27
+ * byte of every route once per window, and one unreachable backend turns
28
+ * every page — including every 404 — into a site-wide 500. That is the most
29
+ * damaging thing a crawler can see: 5xx throttles crawl rate site-wide and
30
+ * drops URLs if it persists.
31
+ *
32
+ * Net behaviour: a real new language costs one extra fetch, once. A flood of
33
+ * junk locales costs one extra fetch, once, then is free. An expiry costs a
34
+ * background fetch and no latency. An outage costs nothing until the process
35
+ * restarts, and the caller decides what a cold start with no backend serves.
36
+ *
37
+ * The loader and the clock are injected so this logic is unit-testable with no
38
+ * network and no wall-clock waiting. The SDK loader is bound where the cache
39
+ * is consumed; this module stays free of both the SDK and the clock so the
40
+ * spec project can import it.
41
+ */
42
+
43
+ /** The channel's advertised languages. */
44
+ export interface ChannelLocales {
45
+ /** Every language the channel offers, including the default. */
46
+ languageCodes: readonly string[];
47
+ /** The language served UNPREFIXED. */
48
+ defaultLanguageCode: string;
49
+ }
50
+
51
+ /**
52
+ * One request's answer, derived from ONE snapshot.
53
+ *
54
+ * Returning both together is the point: resolving the channel and the
55
+ * candidate through two separate reads lets a request decide "render prefixed"
56
+ * against an old `defaultLanguageCode` while `offered` came from a snapshot in
57
+ * which that locale had BECOME the default — a torn read whose symptom is a
58
+ * page that should have 308'd rendering prefixed instead.
59
+ */
60
+ export interface ChannelLocalesResolution<T extends ChannelLocales = ChannelLocales> {
61
+ channel: T;
62
+ offered: boolean;
63
+ }
64
+
65
+ /**
66
+ * Generic in the SNAPSHOT, because deciding `offered` is the only thing this
67
+ * cache does with it — everything else about the channel is a passenger.
68
+ *
69
+ * The passenger is not hypothetical: the storefront's shell also needs the
70
+ * shop name (the title suffix) and whether the read was authoritative at all
71
+ * (#1347). Both come from the SAME `activeChannel` document this cache already
72
+ * fetches, and they have to arrive on the same snapshot or the shell can pair
73
+ * one window's languages with another window's name. A second cached read
74
+ * would put a second round-trip on the first byte of every route, which is
75
+ * precisely what the docblock above rules out.
76
+ *
77
+ * Generic rather than widening {@link ChannelLocales} with those fields: this
78
+ * module is the LOCALE grammar's cache, its type is consumed by
79
+ * `decideRequestLocale`, and a shop name has no business in either. The
80
+ * constraint states exactly what is required of a snapshot, and anything
81
+ * further rides through untouched.
82
+ */
83
+ export interface ChannelLocalesCache<T extends ChannelLocales = ChannelLocales> {
84
+ /** The current snapshot; refreshes behind the response once it is stale. */
85
+ get(): Promise<T>;
86
+ /**
87
+ * The channel AND whether it offers `candidate`, from a single snapshot. A
88
+ * miss against an existing snapshot forces one live reload before answering
89
+ * `false` — bounded to once per window, see the module docblock.
90
+ */
91
+ resolve(candidate: string | null): Promise<ChannelLocalesResolution<T>>;
92
+ }
93
+
94
+ export interface ChannelLocalesCacheOptions {
95
+ ttlMs?: number;
96
+ /** Injected clock — the specs drive time instead of waiting on it. */
97
+ now?: () => number;
98
+ }
99
+
100
+ /** The window S1 fixes for the channel-language snapshot. */
101
+ export const CHANNEL_LOCALES_TTL_MS = 60_000;
102
+
103
+ export function createChannelLocalesCache<T extends ChannelLocales>(
104
+ load: () => Promise<T>,
105
+ options: ChannelLocalesCacheOptions = {},
106
+ ): ChannelLocalesCache<T> {
107
+ const ttlMs = options.ttlMs ?? CHANNEL_LOCALES_TTL_MS;
108
+ const now = options.now ?? Date.now;
109
+
110
+ let snapshot: T | null = null;
111
+ let loadedAt = 0;
112
+ /** When a MISS last forced a reload, so the next miss can be answered free. */
113
+ let missReloadedAt: number | null = null;
114
+ /**
115
+ * The in-flight load, shared by every caller that arrives during it. A
116
+ * server handles requests concurrently; without this, a cold start under
117
+ * load would fan one expired snapshot out into N identical round-trips.
118
+ */
119
+ let inFlight: Promise<T> | null = null;
120
+
121
+ function isFresh(): boolean {
122
+ return snapshot !== null && now() - loadedAt < ttlMs;
123
+ }
124
+
125
+ async function reload(): Promise<T> {
126
+ if (inFlight) return inFlight;
127
+ inFlight = load();
128
+ try {
129
+ const loaded = await inFlight;
130
+ snapshot = loaded;
131
+ loadedAt = now();
132
+ return loaded;
133
+ } finally {
134
+ // Cleared on failure too, so a transient backend error does not pin a
135
+ // rejected promise as the permanent answer for every later request.
136
+ inFlight = null;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Refresh behind the response. The rejection is swallowed deliberately: the
142
+ * caller already has a usable snapshot, and an unhandled rejection here
143
+ * would take down the process for a failure that costs the request nothing.
144
+ * `loadedAt` is untouched on failure, so the next request retries.
145
+ */
146
+ function refreshInBackground(): void {
147
+ if (inFlight) return;
148
+ void reload().catch(() => undefined);
149
+ }
150
+
151
+ async function get(): Promise<T> {
152
+ if (snapshot !== null) {
153
+ if (!isFresh()) refreshInBackground();
154
+ return snapshot;
155
+ }
156
+ // Cold start only: there is nothing to serve, so this one awaits — and may
157
+ // reject. The caller decides what an unreachable backend serves on a cold
158
+ // process; this module will not invent a language set.
159
+ return reload();
160
+ }
161
+
162
+ async function resolve(candidate: string | null): Promise<ChannelLocalesResolution<T>> {
163
+ const hadSnapshot = snapshot !== null;
164
+ const channel = await get();
165
+ if (candidate === null) return { channel, offered: false };
166
+ if (channel.languageCodes.includes(candidate)) return { channel, offered: true };
167
+
168
+ // Loaded live in this very call, so the miss is authoritative — reloading
169
+ // again would ask the same question twice.
170
+ if (!hadSnapshot) return { channel, offered: false };
171
+
172
+ // Bounded: one miss-driven reload per window, however many junk locales
173
+ // arrive inside it.
174
+ if (missReloadedAt !== null && now() - missReloadedAt < ttlMs) {
175
+ return { channel, offered: false };
176
+ }
177
+
178
+ // Stamped on the ATTEMPT, not the result. Stamping after the await would
179
+ // leave the bound disarmed whenever the loader rejects — exactly the
180
+ // condition it exists to protect: a backend already failing, being asked
181
+ // again by every junk locale that arrives.
182
+ missReloadedAt = now();
183
+ // A failed miss-reload answers from the snapshot we already had rather
184
+ // than rejecting: this runs inside the document shell, where a throw is a
185
+ // site-wide 500 rather than a degraded section.
186
+ const reloaded = await reload().catch(() => channel);
187
+ return { channel: reloaded, offered: reloaded.languageCodes.includes(candidate) };
188
+ }
189
+
190
+ return { get, resolve };
191
+ }