@forgecart/cli 2.202610052310.0 → 2.202610071755.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,379 @@
1
+ import { NextResponse } from 'next/server';
2
+ import type { NextRequest } from 'next/server';
3
+ import { uuidv7 } from 'uuidv7';
4
+
5
+ import {
6
+ hasServerAuthoredHeaders,
7
+ localeRequestHeaders,
8
+ planLocaleRewrite,
9
+ } from './lib/locale/middleware-plan';
10
+ import type { LocaleRewritePlan } from './lib/locale/middleware-plan';
11
+ import { planRedirect } from './lib/seo/redirect-plan';
12
+ import { REDIRECTS } from './seo/redirects';
13
+
14
+ /**
15
+ * Visitor-identity + forced-experiment-entry + locale-rewrite middleware
16
+ * (design doc S6 + F-B; locale stage = epic launch#54 W1-8 / S1).
17
+ *
18
+ * S6 — visitor identity. Mints the durable anonymous visitor key — the
19
+ * `forgecart-visitor` cookie (UUIDv7, so ids sort by first-touch time) —
20
+ * exactly once per browser. It is the A/B-test subject key and a
21
+ * marketing-event property; it is deliberately NOT a session and NOT a
22
+ * marketing identity.
23
+ *
24
+ * Contract:
25
+ * - Mint ONLY when the cookie is absent. An identified visitor's response
26
+ * carries NO `Set-Cookie` at all — re-sending it on every response would
27
+ * make every page uncacheable for shared caches.
28
+ * - The key is DUAL-HOMED (#1079 P1, deliberate posture change): the
29
+ * canonical `forgecart-visitor` cookie stays httpOnly (server reads:
30
+ * `getVariant()`'s assignment, header forwarding), and a byte-identical
31
+ * NON-httpOnly mirror `forgecart-visitor-client` (the
32
+ * `forgecart-session-client` pattern) exposes it to browser JS so the
33
+ * SDK can send it in WS connection params — visitor-subject goal events
34
+ * are unattributable without it. The id is a UUIDv7, not a secret, and
35
+ * the httpOnly original stays authoritative: a missing or tampered
36
+ * mirror is re-synced FROM it (one extra `Set-Cookie` response per
37
+ * legacy visitor, then the steady state is Set-Cookie-free again).
38
+ * - Zero fetches, zero environment reads: the workspace-pod prewarm boots
39
+ * this dev server with no `.env`, and the middleware must be inert
40
+ * to that (it never crashes a request, env or not).
41
+ * - Harmless on `<Link>` prefetches: they carry cookies like any navigation,
42
+ * so an identified visitor's prefetch passes straight through; page-view
43
+ * EMISSION stays in the client tracker precisely because prefetches reach
44
+ * middleware (design doc §H.1) — this file only persists the id.
45
+ * - Infra routes are excluded via the matcher: `/ping` (the pod readiness
46
+ * probe polls it forever — it must stay zero-overhead and byte-stable) and
47
+ * `/__forge_beacon` (error beacon; a browser that reaches it already has
48
+ * the cookie from the page), plus Next static internals.
49
+ *
50
+ * F-B — forced campaign entry. Campaign URLs carry
51
+ * `?fc-exp=<experimentKey>:<variantKey>[,<experimentKey>:<variantKey>…]`
52
+ * (at most {@link FORCED_URL_ENTRY_CAP} entries; both sides must match the
53
+ * backend's slug grammar). Valid entries MERGE into the `forgecart-exp-force`
54
+ * cookie — a JSON map `{ experimentKey: variantKey }`, httpOnly (server-read
55
+ * only), 30 days — and the request continues WITHOUT a redirect, so the
56
+ * campaign link stays shareable (the leak is the point). Whether a forced key
57
+ * actually overrides the hash is decided downstream by the assignment engine
58
+ * (`allowForcedEntry` + variant existence) — the middleware only persists.
59
+ *
60
+ * The merged map is ALSO written back onto this request's own `cookie` header
61
+ * (`NextResponse.next({ request })`), so the landing render itself already
62
+ * sees the forced variant — Set-Cookie alone would only reach the NEXT
63
+ * request. `getVariant()` still never relies on middleware→RSC cookie
64
+ * propagation for the VISITOR id (it reads-else-mints within a
65
+ * request-scoped cache); the forced map is different: it either rode in on
66
+ * the request cookie or is deterministically rewritten here.
67
+ *
68
+ * S1 — locale rewrite. `/<xx>/<rest>` is rewritten to `/<rest>`, with the
69
+ * locale carried forward on the request as `x-forgecart-locale` and announced
70
+ * on the response as `Content-Language`. The route tree therefore stays FLAT:
71
+ * no `[locale]` segment, no root catch-all (#1041 law).
72
+ *
73
+ * The locale DECISION is not here — it is `planLocaleRewrite`, a pure module
74
+ * the template spec project pins. This file imports `next/server`, so nothing
75
+ * in it can be reached by that project; the locale stage therefore keeps no
76
+ * decision of its own and only maps the two plan cases onto Next responses.
77
+ * (The F-B parsing and merging below predate that split and are still
78
+ * unpinned — they are covered, like the rest of this file's runtime
79
+ * behaviour, only by the storefront-verify phases.)
80
+ *
81
+ * Three properties of this stage are load-bearing:
82
+ *
83
+ * - It obeys the same zero-fetch / zero-env contract as the rest of this
84
+ * file. A two-letter first segment is only a CANDIDATE here; whether the
85
+ * channel offers it is decided in the layout's request-time hole, the only
86
+ * place that can ask. Validating here would require a fetch, and a fetch in
87
+ * middleware is exactly what the prewarm boot cannot afford.
88
+ * - It runs BEFORE, and independently of, the cookie fast path below. A
89
+ * rewrite is not cookie work: an already-identified visitor carrying no
90
+ * forced entry does no cookie work at all, so a locale stage placed inside
91
+ * that branch would render the DEFAULT language for every prefixed URL a
92
+ * returning visitor opens.
93
+ * - It is the only place `Content-Language` can be set at all. No RSC API
94
+ * lets a layout or page write a response header, so this is the single
95
+ * emitter by necessity rather than by policy — and nothing downstream may
96
+ * add a second, which would produce two header lines.
97
+ *
98
+ * Two consequences are accepted deliberately. It labels the CANDIDATE,
99
+ * because validity is not knowable here: `/zz/x` carries
100
+ * `Content-Language: zz` on the 404 the layout then raises, and
101
+ * `/en/x` carries `Content-Language: en` on its 308. Both are inert — a
102
+ * crawler drops a 404 rather than indexing its language, and a 308 has no
103
+ * body to describe. And the UNPREFIXED default-language response carries no
104
+ * header at all, since this stage cannot know the channel's default without
105
+ * a fetch. The layout announces that one as `<html lang>`, which is the
106
+ * signal search engines actually consume for language; the header is the
107
+ * weaker of the two and is required here only for the prefixed case.
108
+ *
109
+ * Store-wide routes are protected from the locale stage by `planLocaleRewrite`,
110
+ * not by the matcher. The matcher below excludes only `_next/static`,
111
+ * `_next/image`, `favicon.ico`, `ping` and `__forge_beacon`, and only in their
112
+ * UNPREFIXED spelling — its negative lookahead is anchored at the start of the
113
+ * path, so `/de/ping` reaches this file even though `/ping` does not.
114
+ * `sitemap.xml`, `robots.txt`, `api` and `__fc` are not excluded at all and run
115
+ * through this middleware in every spelling. What keeps every one of them
116
+ * unrewritten is the planner's `pass`, whose list is the grammar's.
117
+ */
118
+
119
+ const VISITOR_COOKIE_NAME = 'forgecart-visitor';
120
+
121
+ /**
122
+ * Browser-readable mirror of {@link VISITOR_COOKIE_NAME} (#1079 P1) — same
123
+ * value, same lifetime, NOT httpOnly. Read by the shop-session SDK
124
+ * bootstrap and sent as the `forgecart-visitor` WS connection-params
125
+ * header. Never read server-side: the httpOnly original is authoritative.
126
+ */
127
+ const VISITOR_MIRROR_COOKIE_NAME = 'forgecart-visitor-client';
128
+
129
+ /** 400 days — Chrome's upper bound on cookie lifetime (RFC 6265bis). */
130
+ const VISITOR_COOKIE_MAX_AGE_SECONDS = 400 * 24 * 60 * 60;
131
+
132
+ /** Forced-entry URL param + cookie (design doc F-B). */
133
+ const FORCED_ENTRY_PARAM = 'fc-exp';
134
+ const FORCED_ENTRY_COOKIE_NAME = 'forgecart-exp-force';
135
+
136
+ /** 30 days — the campaign-entry window the design doc fixes. */
137
+ const FORCED_ENTRY_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
138
+
139
+ /** At most this many `exp:variant` entries are honored per URL (design doc). */
140
+ const FORCED_URL_ENTRY_CAP = 3;
141
+
142
+ /**
143
+ * At most this many experiments are retained in the merged cookie (oldest
144
+ * evicted first). At the slug grammar's 64-char ceiling per side that keeps
145
+ * the serialized JSON well under the 4096-byte browser cookie cap, so a
146
+ * hostile chain of campaign URLs can never grow the header unboundedly.
147
+ */
148
+ const FORCED_COOKIE_ENTRY_CAP = 12;
149
+
150
+ /**
151
+ * The backend's experiment/variant slug grammar
152
+ * (`ExperimentService`'s SLUG_REGEX) — both sides of every `exp:variant`
153
+ * entry must match it or the entry is dropped.
154
+ */
155
+ const SLUG_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
156
+
157
+ /**
158
+ * Parse the raw `?fc-exp=` value into at most {@link FORCED_URL_ENTRY_CAP}
159
+ * slug-valid `experimentKey → variantKey` entries (later duplicates of the
160
+ * same experiment win; invalid entries are dropped silently — a mangled
161
+ * campaign URL must still serve the page). `null` when nothing valid remains.
162
+ */
163
+ function parseForcedEntryParam(raw: string | null): Map<string, string> | null {
164
+ if (!raw) return null;
165
+ const entries = new Map<string, string>();
166
+ for (const item of raw.split(',')) {
167
+ const separatorAt = item.indexOf(':');
168
+ if (separatorAt === -1) continue;
169
+ const experimentKey = item.slice(0, separatorAt);
170
+ const variantKey = item.slice(separatorAt + 1);
171
+ if (!SLUG_REGEX.test(experimentKey) || !SLUG_REGEX.test(variantKey)) continue;
172
+ if (!entries.has(experimentKey) && entries.size >= FORCED_URL_ENTRY_CAP) continue;
173
+ entries.set(experimentKey, variantKey);
174
+ }
175
+ return entries.size > 0 ? entries : null;
176
+ }
177
+
178
+ /**
179
+ * Read the existing `forgecart-exp-force` cookie into slug-valid entries.
180
+ * Malformed JSON / shapes are discarded wholesale — the cookie is
181
+ * client-tamperable, and the next valid campaign URL rewrites it anyway.
182
+ */
183
+ function readForcedEntryCookie(request: NextRequest): Map<string, string> {
184
+ const entries = new Map<string, string>();
185
+ const raw = request.cookies.get(FORCED_ENTRY_COOKIE_NAME)?.value;
186
+ if (!raw) return entries;
187
+ let parsed: unknown;
188
+ try {
189
+ // `request.cookies` already percent-decodes — `raw` is the JSON text.
190
+ parsed = JSON.parse(raw);
191
+ } catch {
192
+ return entries;
193
+ }
194
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return entries;
195
+ for (const [experimentKey, variantKey] of Object.entries(parsed)) {
196
+ if (typeof variantKey !== 'string') continue;
197
+ if (!SLUG_REGEX.test(experimentKey) || !SLUG_REGEX.test(variantKey)) continue;
198
+ entries.set(experimentKey, variantKey);
199
+ }
200
+ return entries;
201
+ }
202
+
203
+ /**
204
+ * Merge URL entries over the stored map (URL wins; re-set keys move to the
205
+ * newest position) and evict oldest entries past {@link FORCED_COOKIE_ENTRY_CAP}.
206
+ * Returns the cookie-ready serialized value, or `null` when the URL carried
207
+ * no valid entry (the common case — zero cookie work on normal traffic).
208
+ */
209
+ function mergeForcedEntries(request: NextRequest): string | null {
210
+ const fromUrl = parseForcedEntryParam(request.nextUrl.searchParams.get(FORCED_ENTRY_PARAM));
211
+ if (!fromUrl) return null;
212
+ const merged = readForcedEntryCookie(request);
213
+ for (const [experimentKey, variantKey] of fromUrl) {
214
+ merged.delete(experimentKey);
215
+ merged.set(experimentKey, variantKey);
216
+ }
217
+ while (merged.size > FORCED_COOKIE_ENTRY_CAP) {
218
+ const oldest = merged.keys().next().value;
219
+ if (oldest === undefined) break;
220
+ merged.delete(oldest);
221
+ }
222
+ // RAW JSON on purpose: Next's cookie APIs percent-encode on serialize and
223
+ // decode on parse — symmetrically for the response Set-Cookie AND the
224
+ // request-header rewrite below — so pre-encoding here would double-encode.
225
+ return JSON.stringify(Object.fromEntries(merged));
226
+ }
227
+
228
+ /**
229
+ * The response the cookie steps decorate — one per plan case.
230
+ *
231
+ * A `pass` keeps the untouched `NextResponse.next()` whenever it can, and that
232
+ * is a deliberate budget decision rather than a leftover: passing
233
+ * `{ request: { headers } }` makes Next copy the ENTIRE inbound header block
234
+ * into `x-middleware-request-*` on the middleware→server hop, against Node's
235
+ * 16 KB default. This matcher covers `/api/*`, `_next/data`, `sitemap.xml` and
236
+ * every RSC prefetch, so paying that on all of them — to sanitize the few
237
+ * requests that actually arrive forged — would be a poor trade. The mutated
238
+ * path is taken only when there is something to say or something to strip.
239
+ *
240
+ * Headers are cloned AFTER any forced-entry cookie rewrite, so a rewritten
241
+ * request carries the merged cookie header too — the landing render in a
242
+ * prefixed locale must still see its forced variant. The query string survives
243
+ * because `nextUrl.clone()` carries it and only `pathname` is reassigned.
244
+ */
245
+ function buildBaseResponse(
246
+ request: NextRequest,
247
+ plan: LocaleRewritePlan,
248
+ requestHeadersMutated: boolean,
249
+ ): NextResponse {
250
+ if (plan.kind === 'pass') {
251
+ if (!requestHeadersMutated && !hasServerAuthoredHeaders(request.headers)) {
252
+ return NextResponse.next();
253
+ }
254
+ const headers = localeRequestHeaders(request.headers, plan, request.nextUrl.search);
255
+ return NextResponse.next({ request: { headers } });
256
+ }
257
+
258
+ const url = request.nextUrl.clone();
259
+ url.pathname = plan.routePath;
260
+ const headers = localeRequestHeaders(request.headers, plan, request.nextUrl.search);
261
+ const response = NextResponse.rewrite(url, { request: { headers } });
262
+ response.headers.set('Content-Language', plan.locale);
263
+ return response;
264
+ }
265
+
266
+ export function middleware(request: NextRequest): NextResponse {
267
+ // W1-9 — rename redirects, BEFORE everything else. A retired URL is not a
268
+ // page of this site any more, so nothing downstream should spend work on it:
269
+ // not the locale rewrite (the destination gets its own pass), not the cookie
270
+ // stages (the browser follows the 308 and is identified on the hop that
271
+ // actually renders). The query string rides along, so a campaign link's
272
+ // `?fc-exp=` survives a rename.
273
+ //
274
+ // The table is a SOURCE file (`src/seo/redirects.ts`) rather than
275
+ // `next.config.js`'s `redirects()` because that one is read once at boot: an
276
+ // entry added there does nothing until the server restarts, which in a
277
+ // workspace pod means the merchant watches their rename not work.
278
+ const redirectTo = planRedirect(request.nextUrl.pathname, REDIRECTS);
279
+ if (redirectTo !== null) {
280
+ const destination = request.nextUrl.clone();
281
+ destination.pathname = redirectTo;
282
+ return NextResponse.redirect(destination, 308);
283
+ }
284
+
285
+ // Then the locale plan — outside the cookie fast path below, because it is
286
+ // not cookie work. An identified visitor with no forced entry does no cookie
287
+ // work at all, so a locale stage inside that branch would serve the default
288
+ // language for every prefixed URL a returning visitor opens.
289
+ const plan = planLocaleRewrite(request.nextUrl.pathname);
290
+ const forcedEntryValue = mergeForcedEntries(request);
291
+ const storedVisitorId = request.cookies.get(VISITOR_COOKIE_NAME)?.value;
292
+ const mintVisitor = !storedVisitorId;
293
+ // Mirror sync (#1079 P1): absent on every pre-mirror visitor's first
294
+ // request back, and re-synced whenever it disagrees with the httpOnly
295
+ // original (client JS can rewrite it — the original is authoritative).
296
+ // `mintVisitor` implies a sync: a fresh id is written to BOTH homes.
297
+ const syncMirror =
298
+ mintVisitor || request.cookies.get(VISITOR_MIRROR_COOKIE_NAME)?.value !== storedVisitorId;
299
+ // The steady-state fast path: an identified visitor on an unprefixed URL with
300
+ // no campaign parameter needs no request-header override at all, and skipping
301
+ // it avoids Next copying the entire inbound header block into
302
+ // `x-middleware-request-*` on every such request.
303
+ //
304
+ // `hasServerAuthoredHeaders` MUST be part of this condition, not only of
305
+ // `buildBaseResponse`. A bare `NextResponse.next()` emits no
306
+ // `x-middleware-override-headers`, and Next deletes non-overridden inbound
307
+ // request headers ONLY under that header — so returning early here would let a
308
+ // forged `x-forgecart-locale` survive into the render on the exact path most
309
+ // real traffic takes (every returning browser: visitor cookie present and its
310
+ // mirror in agreement). A forged request instead falls through to
311
+ // `buildBaseResponse`, which strips the server-authored names and re-emits.
312
+ if (
313
+ plan.kind === 'pass' &&
314
+ !forcedEntryValue &&
315
+ !syncMirror &&
316
+ !hasServerAuthoredHeaders(request.headers)
317
+ ) {
318
+ return NextResponse.next();
319
+ }
320
+
321
+ if (forcedEntryValue) {
322
+ // Rewrite this request's own cookie header so the landing render already
323
+ // resolves the forced variant (Set-Cookie alone reaches only the NEXT
324
+ // request), then persist for subsequent navigations. The forced-entry
325
+ // stage itself never redirects — the campaign URL stays shareable — and
326
+ // when the locale stage does canonicalize one, the query string rides
327
+ // along, so the entry survives the hop either way.
328
+ request.cookies.set(FORCED_ENTRY_COOKIE_NAME, forcedEntryValue);
329
+ }
330
+
331
+ const response = buildBaseResponse(request, plan, forcedEntryValue !== null);
332
+
333
+ if (forcedEntryValue) {
334
+ response.cookies.set({
335
+ name: FORCED_ENTRY_COOKIE_NAME,
336
+ value: forcedEntryValue,
337
+ httpOnly: true,
338
+ secure: true,
339
+ sameSite: 'lax',
340
+ maxAge: FORCED_ENTRY_COOKIE_MAX_AGE_SECONDS,
341
+ path: '/',
342
+ });
343
+ }
344
+
345
+ if (syncMirror) {
346
+ const visitorId = storedVisitorId ?? uuidv7();
347
+ if (mintVisitor) {
348
+ // `secure` is honored on https (deployed storefronts) and on localhost;
349
+ // browsers may drop it on other plain-http dev hosts — acceptable, since a
350
+ // stable visitor identity is a production concern, not a preview one.
351
+ response.cookies.set({
352
+ name: VISITOR_COOKIE_NAME,
353
+ value: visitorId,
354
+ httpOnly: true,
355
+ secure: true,
356
+ sameSite: 'lax',
357
+ maxAge: VISITOR_COOKIE_MAX_AGE_SECONDS,
358
+ path: '/',
359
+ });
360
+ }
361
+ // Deliberately NOT httpOnly — see VISITOR_MIRROR_COOKIE_NAME.
362
+ response.cookies.set({
363
+ name: VISITOR_MIRROR_COOKIE_NAME,
364
+ value: visitorId,
365
+ httpOnly: false,
366
+ secure: true,
367
+ sameSite: 'lax',
368
+ maxAge: VISITOR_COOKIE_MAX_AGE_SECONDS,
369
+ path: '/',
370
+ });
371
+ }
372
+ return response;
373
+ }
374
+
375
+ export const config = {
376
+ // Every page and route EXCEPT Next internals/static assets and the infra
377
+ // routes documented above.
378
+ matcher: ['/((?!_next/static|_next/image|favicon.ico|ping|__forge_beacon).*)'],
379
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Rename redirects — the one file you edit when a page moves.
3
+ *
4
+ * When a page's URL changes, the old address does not stop existing: it is in
5
+ * search results, in links other people wrote, and in browser history. Left to
6
+ * 404 it loses everything the old URL had earned. A 308 hands that across to
7
+ * the new address instead.
8
+ *
9
+ * Add an entry and save. The dev server picks it up immediately, so the
10
+ * redirect is live in the same session — which is exactly why this is a source
11
+ * file and not `next.config.js`'s `redirects()`: that one is read once at boot,
12
+ * so an entry added there does nothing until the server restarts.
13
+ *
14
+ * export const REDIRECTS: readonly RedirectRule[] = [
15
+ * { from: '/summer-sale', to: '/promotions/summer' },
16
+ * ];
17
+ *
18
+ * Three things to know, all enforced in `lib/seo/redirect-plan.ts`:
19
+ *
20
+ * - **Paths are written WITHOUT a language prefix.** `/summer-sale` covers
21
+ * `/de/summer-sale` too, and each redirects inside its own language. Writing
22
+ * `/de/summer-sale` here would cover only German — and the language you
23
+ * forgot would keep 404ing, silently.
24
+ * - **Matching is exact.** `/summer-sale` does not match `/summer-sale/tents`
25
+ * or `/summer-sale-2024`. Each moved page gets its own entry, because a
26
+ * prefix rule would drag URLs you never meant to move.
27
+ * - **Chains collapse and cycles are refused.** `/a → /b → /c` answers ONE
28
+ * 308 straight to `/c`, since every extra hop weakens the signal a crawler
29
+ * carries across. A cycle redirects nowhere at all rather than trapping a
30
+ * browser in a loop.
31
+ */
32
+
33
+ /** One moved page: an old address and the address that replaced it. */
34
+ export interface RedirectRule {
35
+ /** The retired path, unprefixed and rooted — e.g. `/summer-sale`. */
36
+ from: string;
37
+ /** Where it lives now, unprefixed and rooted — e.g. `/promotions/summer`. */
38
+ to: string;
39
+ }
40
+
41
+ /**
42
+ * The table. Empty in a fresh scaffold — nothing has been renamed yet.
43
+ */
44
+ export const REDIRECTS: readonly RedirectRule[] = [];
@@ -0,0 +1,18 @@
1
+ import 'server-only';
2
+
3
+ import { Module } from '@nestjs/common';
4
+
5
+ import { CustomerExtrasModule } from './customer-extras/customer-extras.module';
6
+ import { ForgeCartModule } from './forgecart/forgecart.module';
7
+
8
+ /**
9
+ * Root of the storefront's embedded NestJS backend.
10
+ *
11
+ * `bootstrap.ts` boots this module as an APPLICATION CONTEXT — the module
12
+ * system, DI container, and lifecycle only; Next.js keeps owning HTTP — and
13
+ * scans it for `@BackendMethod()` methods, which become the `sdk.backend`
14
+ * surface. Every domain module registers here; a module that is not
15
+ * imported does not exist to the scan.
16
+ */
17
+ @Module({ imports: [ForgeCartModule, CustomerExtrasModule] })
18
+ export class AppModule {}
@@ -0,0 +1,26 @@
1
+ import 'server-only';
2
+
3
+ import type {
4
+ AssignCustomerHashInput,
5
+ AssignCustomerHashResult,
6
+ } from './customer-extras/type/customer-extras.types';
7
+
8
+ /**
9
+ * The compile-time surface of the backend gate — the typed twin of the
10
+ * runtime `@BackendMethod()` scan.
11
+ *
12
+ * `lib/backend-client.ts` types the `sdk.backend` proxy from this interface
13
+ * (a type-only import, so nothing here reaches the client bundle), and
14
+ * `bootstrap.ts` validates at boot that the decorator scan discovered
15
+ * EXACTLY the names below — a mismatch in either direction fails the boot
16
+ * with a message naming the drifted method.
17
+ *
18
+ * Adding a method = the decorated service method + one signature here (+
19
+ * the name in the list below). See the `backend-feature-authoring` recipe.
20
+ */
21
+ export interface BackendApi {
22
+ assignCustomerHash: (input: AssignCustomerHashInput) => Promise<AssignCustomerHashResult>;
23
+ }
24
+
25
+ /** The declared names, for the boot-time drift check against the scan. */
26
+ export const BACKEND_API_METHOD_NAMES: readonly (keyof BackendApi)[] = ['assignCustomerHash'];
@@ -0,0 +1,23 @@
1
+ import 'server-only';
2
+
3
+ import { SetMetadata, type CustomDecorator } from '@nestjs/common';
4
+
5
+ /** Metadata key marking a service method as browser-invocable via `sdk.backend`. */
6
+ export const BACKEND_METHOD_KEY = 'forgecart:backend-method';
7
+
8
+ /**
9
+ * Opt a service method into the `sdk.backend` gate.
10
+ *
11
+ * The decorator IS the allowlist: the bootstrap scan exposes decorated
12
+ * methods and nothing else, so a method becomes browser-callable only by
13
+ * carrying this marker (plus its line in `backend-api.ts` — the boot-time
14
+ * drift check keeps the two in lock-step). Method names must be unique
15
+ * across ALL services; a duplicate fails the boot loudly.
16
+ *
17
+ * A decorated method runs shopper-invocable with the ADMIN client available
18
+ * in its session — the method body owns the decision of what that authority
19
+ * may be used for, and must treat its `input` as untrusted data.
20
+ */
21
+ export function BackendMethod(): CustomDecorator<string> {
22
+ return SetMetadata(BACKEND_METHOD_KEY, true);
23
+ }
@@ -0,0 +1,122 @@
1
+ import 'server-only';
2
+ import 'reflect-metadata';
3
+
4
+ import type { INestApplicationContext } from '@nestjs/common';
5
+ import { MetadataScanner, ModulesContainer, NestFactory, Reflector } from '@nestjs/core';
6
+
7
+ import { AppModule } from './app.module';
8
+ import { BACKEND_API_METHOD_NAMES } from './backend-api';
9
+ import { BACKEND_METHOD_KEY } from './backend-method.decorator';
10
+ import type { BackendSession } from './types';
11
+
12
+ /** A discovered method, bound to its service instance. */
13
+ export type RegisteredBackendMethod = (session: BackendSession, input: unknown) => Promise<unknown>;
14
+
15
+ export interface BackendRuntime {
16
+ ctx: INestApplicationContext;
17
+ registry: ReadonlyMap<string, RegisteredBackendMethod>;
18
+ }
19
+
20
+ /**
21
+ * Boot-once accessor for the embedded NestJS backend.
22
+ *
23
+ * The cache is MODULE-scoped on purpose: under `next dev`, editing anything
24
+ * in the backend graph makes HMR re-execute this module with a fresh scope,
25
+ * so the next invocation boots a NEW context with the new code — that is how
26
+ * agent edits take effect without a server restart. The previous context's
27
+ * handle rides `globalThis` (which HMR does NOT reset) solely so the new
28
+ * boot can close it — retired containers must not leak lifecycle hooks. In
29
+ * production there is no HMR and this degrades to boot-exactly-once.
30
+ *
31
+ * A failed boot (e.g. the api-map drift check) stays cached as a rejection:
32
+ * every invocation reports the same loud error until the FIX edit re-executes
33
+ * this module. Failing closed and visible beats a half-registered backend.
34
+ */
35
+ let runtimePromise: Promise<BackendRuntime> | undefined;
36
+ const handoff = globalThis as { __forgecartBackendPrev?: BackendRuntime };
37
+
38
+ export function getBackend(): Promise<BackendRuntime> {
39
+ runtimePromise ??= boot();
40
+ return runtimePromise;
41
+ }
42
+
43
+ async function boot(): Promise<BackendRuntime> {
44
+ const ctx = await NestFactory.createApplicationContext(AppModule, {
45
+ logger: ['warn', 'error'],
46
+ });
47
+ const registry = scanForBackendMethods(ctx);
48
+ validateAgainstApi(registry);
49
+
50
+ const previous = handoff.__forgecartBackendPrev;
51
+ if (previous) {
52
+ try {
53
+ await previous.ctx.close();
54
+ } catch {
55
+ // A mid-reload teardown race is fine — the old container is gone either way.
56
+ }
57
+ }
58
+ const runtime: BackendRuntime = { ctx, registry };
59
+ handoff.__forgecartBackendPrev = runtime;
60
+ return runtime;
61
+ }
62
+
63
+ /**
64
+ * Walk every provider in the booted module graph and collect the methods
65
+ * carrying `@BackendMethod()` — the decorator is the allowlist, so the map
66
+ * built here is the ONLY surface `sdk.backend` can reach. Names are global:
67
+ * a duplicate across services fails the boot rather than shadowing.
68
+ */
69
+ function scanForBackendMethods(
70
+ ctx: INestApplicationContext,
71
+ ): ReadonlyMap<string, RegisteredBackendMethod> {
72
+ const registry = new Map<string, RegisteredBackendMethod>();
73
+ const modules = ctx.get(ModulesContainer);
74
+ const scanner = new MetadataScanner();
75
+ const reflector = new Reflector();
76
+
77
+ for (const moduleRef of modules.values()) {
78
+ for (const wrapper of moduleRef.providers.values()) {
79
+ const instance = wrapper.instance as Record<string, unknown> | null;
80
+ if (!instance || typeof instance !== 'object') continue;
81
+ const prototype = Object.getPrototypeOf(instance) as Record<string, unknown> | null;
82
+ if (!prototype) continue;
83
+
84
+ for (const name of scanner.getAllMethodNames(prototype)) {
85
+ const handler = prototype[name];
86
+ if (typeof handler !== 'function') continue;
87
+ if (reflector.get<boolean | undefined>(BACKEND_METHOD_KEY, handler) !== true) continue;
88
+ if (registry.has(name)) {
89
+ throw new Error(
90
+ `Duplicate @BackendMethod name "${name}" — backend method names must be unique across all services.`,
91
+ );
92
+ }
93
+ registry.set(name, (handler as RegisteredBackendMethod).bind(instance));
94
+ }
95
+ }
96
+ }
97
+ return registry;
98
+ }
99
+
100
+ /**
101
+ * The drift tripwire: the runtime scan and the compile-time surface
102
+ * (`backend-api.ts`) must agree EXACTLY, in both directions, or the typed
103
+ * proxy the browser compiles against would lie. The error names the exact
104
+ * method so the fix is a one-line edit.
105
+ */
106
+ function validateAgainstApi(registry: ReadonlyMap<string, RegisteredBackendMethod>): void {
107
+ const declared = new Set<string>(BACKEND_API_METHOD_NAMES);
108
+ for (const name of registry.keys()) {
109
+ if (!declared.has(name)) {
110
+ throw new Error(
111
+ `@BackendMethod "${name}" is not declared in backend-api.ts — add its signature to BackendApi and its name to BACKEND_API_METHOD_NAMES.`,
112
+ );
113
+ }
114
+ }
115
+ for (const name of declared) {
116
+ if (!registry.has(name)) {
117
+ throw new Error(
118
+ `backend-api.ts declares "${name}" but no @BackendMethod with that name was discovered — decorate the service method (and wire its module into app.module.ts) or remove the declaration.`,
119
+ );
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,13 @@
1
+ import 'server-only';
2
+
3
+ import { Module } from '@nestjs/common';
4
+
5
+ import { CustomerExtrasService } from './service/customer-extras.service';
6
+
7
+ /**
8
+ * The worked-example domain module. New backend features follow this shape:
9
+ * one folder per domain, services under `service/`, the module listing them,
10
+ * and one `imports:` line in `app.module.ts`.
11
+ */
12
+ @Module({ providers: [CustomerExtrasService] })
13
+ export class CustomerExtrasModule {}