@forgecart/cli 2.202610052310.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.
- package/package.json +1 -1
- package/templates/storefront-shadcn/.forgecartignore +2 -0
- package/templates/storefront-shadcn/Procfile +1 -0
- package/templates/storefront-shadcn/README.md +229 -0
- package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
- package/templates/storefront-shadcn/components.json +21 -0
- package/templates/storefront-shadcn/next.config.js +105 -0
- package/templates/storefront-shadcn/package.json +39 -0
- package/templates/storefront-shadcn/postcss.config.js +5 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
- package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
- package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/globals.css +156 -0
- package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
- package/templates/storefront-shadcn/src/app/page.tsx +217 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
- package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
- package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
- package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
- package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
- package/templates/storefront-shadcn/src/app/robots.ts +69 -0
- package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
- package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
- package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
- package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
- package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
- package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
- package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
- package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
- package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
- package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
- package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
- package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
- package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
- package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
- package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
- package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
- package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
- package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
- package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
- package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
- package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
- package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
- package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
- package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
- package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
- package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
- package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
- package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
- package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
- package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
- package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
- package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
- package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
- package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
- package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
- package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
- package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
- package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
- package/templates/storefront-shadcn/src/lib/format.ts +89 -0
- package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
- package/templates/storefront-shadcn/src/lib/money.ts +50 -0
- package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
- package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
- package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
- package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
- package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
- package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
- package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
- package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
- package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
- package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
- package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
- package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
- package/templates/storefront-shadcn/src/middleware.ts +379 -0
- package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
- package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
- package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
- package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
- package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
- package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
- package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
- package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront-shadcn/src/server/runner.ts +90 -0
- package/templates/storefront-shadcn/src/server/types.ts +36 -0
- package/templates/storefront-shadcn/tsconfig.json +25 -0
- package/templates/storefront-shadcn-sdk-floor.json +1174 -0
- 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
|
+
}
|