@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.
- 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,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 {}
|