@forgecart/cli 2.202608221935.0 → 2.202609190800.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli.js +3 -21
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/__test__/cli-harness.d.ts +21 -0
- package/dist/src/commands/__test__/cli-harness.js +29 -0
- package/dist/src/commands/__test__/cli-harness.js.map +1 -0
- package/dist/src/commands/init.d.ts +28 -2
- package/dist/src/commands/init.js +100 -15
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/refresh.d.ts +40 -0
- package/dist/src/commands/refresh.js +147 -0
- package/dist/src/commands/refresh.js.map +1 -0
- package/dist/src/commands/template-manifest.d.ts +38 -0
- package/dist/src/commands/template-manifest.js +116 -0
- package/dist/src/commands/template-manifest.js.map +1 -0
- package/dist/src/version.d.ts +10 -0
- package/dist/src/version.js +25 -0
- package/dist/src/version.js.map +1 -0
- package/package.json +1 -1
- package/templates/storefront/README.md +42 -4
- package/templates/storefront/next.config.js +29 -7
- package/templates/storefront/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +28 -25
- package/templates/storefront/src/app/__forge_beacon/route.ts +1 -1
- package/templates/storefront/src/app/cart/page.tsx +14 -2
- package/templates/storefront/src/app/checkout/page.tsx +14 -2
- package/templates/storefront/src/app/layout.tsx +85 -22
- package/templates/storefront/src/app/page.tsx +63 -20
- package/templates/storefront/src/app/pages/[slug]/not-found.tsx +23 -0
- package/templates/storefront/src/app/pages/[slug]/page.tsx +114 -0
- package/templates/storefront/src/app/ping/route.ts +1 -1
- package/templates/storefront/src/app/products/[slug]/not-found.tsx +6 -4
- package/templates/storefront/src/app/products/[slug]/page.tsx +204 -21
- package/templates/storefront/src/app/products/page.tsx +41 -6
- package/templates/storefront/src/app/register/page.tsx +54 -0
- package/templates/storefront/src/app/reset-password/page.tsx +60 -0
- package/templates/storefront/src/app/robots.ts +69 -0
- package/templates/storefront/src/app/sitemap.ts +106 -0
- package/templates/storefront/src/app/verify/page.tsx +155 -0
- package/templates/storefront/src/components/CartView.tsx +26 -7
- package/templates/storefront/src/components/ForgeTracker.tsx +108 -1
- package/templates/storefront/src/components/Header.tsx +30 -10
- package/templates/storefront/src/components/LanguageSwitcher.tsx +88 -0
- package/templates/storefront/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront/src/components/ProductCard.tsx +10 -4
- package/templates/storefront/src/components/account/AccountMessage.tsx +59 -0
- package/templates/storefront/src/components/account/RegisterForm.tsx +283 -0
- package/templates/storefront/src/components/account/RequestPasswordResetForm.tsx +96 -0
- package/templates/storefront/src/components/account/ResetPasswordForm.tsx +169 -0
- package/templates/storefront/src/components/checkout/CheckoutGate.tsx +12 -4
- package/templates/storefront/src/lib/account/account-link.ts +76 -0
- package/templates/storefront/src/lib/account/register-state.ts +133 -0
- package/templates/storefront/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront/src/lib/account-actions.ts +76 -0
- package/templates/storefront/src/lib/account-session.ts +47 -0
- package/templates/storefront/src/lib/asset-alt.ts +34 -0
- package/templates/storefront/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront/src/lib/experiments.ts +1 -1
- package/templates/storefront/src/lib/forgecart.ts +300 -27
- package/templates/storefront/src/lib/format.ts +12 -14
- package/templates/storefront/src/lib/identify-forward.ts +152 -0
- package/templates/storefront/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront/src/lib/marketing-params.ts +213 -0
- package/templates/storefront/src/lib/money.ts +50 -0
- package/templates/storefront/src/lib/seo/alternates.ts +120 -0
- package/templates/storefront/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront/src/lib/seo/metadata.ts +323 -0
- package/templates/storefront/src/lib/seo/noindex.ts +218 -0
- package/templates/storefront/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront/src/lib/seo/resolve-path.ts +126 -0
- package/templates/storefront/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront/src/lib/seo/sitemap-entries.ts +321 -0
- package/templates/storefront/src/lib/session-actions.ts +15 -8
- package/templates/storefront/src/lib/session-cookies.ts +98 -0
- package/templates/storefront/src/lib/shop-config.ts +9 -2
- package/templates/storefront/src/lib/shop-session.ts +42 -5
- package/templates/storefront/src/lib/track-forward.ts +43 -14
- package/templates/storefront/src/middleware.ts +196 -16
- package/templates/storefront/src/seo/redirects.ts +44 -0
- package/templates/storefront/src/server/runner.ts +1 -2
|
@@ -122,21 +122,21 @@ export function isObviousBot(userAgent: string): boolean {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
125
|
+
* The header set every upstream call carries.
|
|
126
|
+
*
|
|
127
|
+
* ONE builder, shared with the marketing-identity forwarder
|
|
128
|
+
* (`identify-forward.ts`), because both speak about the SAME shopper: the
|
|
129
|
+
* session riding as `Authorization` decides which identity a row is written to,
|
|
130
|
+
* so a header set that drifted between them would split one visitor in two and
|
|
131
|
+
* nothing downstream could put them back together.
|
|
129
132
|
*/
|
|
130
|
-
export
|
|
131
|
-
|
|
133
|
+
export function buildUpstreamHeaders(
|
|
134
|
+
channelToken: string,
|
|
132
135
|
{ sessionToken, userAgent, forwardedFor, secChUa, secChUaMobile, secChUaPlatform }: ForwardHeaders,
|
|
133
|
-
):
|
|
134
|
-
const upstream = getUpstreamConfig();
|
|
135
|
-
if (!upstream) return { accepted: false, eventId: null, sessionToken: null };
|
|
136
|
-
|
|
136
|
+
): Record<string, string> {
|
|
137
137
|
const headers: Record<string, string> = {
|
|
138
138
|
'content-type': 'application/json',
|
|
139
|
-
'forgecart-token':
|
|
139
|
+
'forgecart-token': channelToken,
|
|
140
140
|
};
|
|
141
141
|
if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
|
|
142
142
|
if (userAgent) headers['user-agent'] = userAgent;
|
|
@@ -144,18 +144,47 @@ export async function forwardTrackEvent(
|
|
|
144
144
|
if (secChUa) headers['sec-ch-ua'] = secChUa;
|
|
145
145
|
if (secChUaMobile) headers['sec-ch-ua-mobile'] = secChUaMobile;
|
|
146
146
|
if (secChUaPlatform) headers['sec-ch-ua-platform'] = secChUaPlatform;
|
|
147
|
+
return headers;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The session the shop API minted for a cookie-less request, if it minted one.
|
|
152
|
+
*
|
|
153
|
+
* Surfaced in the response `extensions` exactly once. Shared with the identity
|
|
154
|
+
* forwarder for the same reason the headers are: both relays may ESTABLISH the
|
|
155
|
+
* shopper's `forgecart-session` cookie from this value, and two readings of
|
|
156
|
+
* "did the upstream mint a session" would eventually mean two cookies.
|
|
157
|
+
*/
|
|
158
|
+
export function readMintedSessionToken(
|
|
159
|
+
extensions: Record<string, unknown> | undefined,
|
|
160
|
+
): string | null {
|
|
161
|
+
const minted = extensions?.['forgecart-auth-token'];
|
|
162
|
+
return typeof minted === 'string' && minted.length > 0 ? minted : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Forward one event as a raw HTTP GraphQL POST. Any failure — missing config,
|
|
167
|
+
* network, HTTP status, GraphQL errors — resolves to `accepted:false`; the
|
|
168
|
+
* forwarder is best-effort by design (analytics must never break the
|
|
169
|
+
* storefront) and `accepted:false` is terminal (callers never retry).
|
|
170
|
+
*/
|
|
171
|
+
export async function forwardTrackEvent(
|
|
172
|
+
input: TrackEventInput,
|
|
173
|
+
forward: ForwardHeaders,
|
|
174
|
+
): Promise<UpstreamOutcome> {
|
|
175
|
+
const upstream = getUpstreamConfig();
|
|
176
|
+
if (!upstream) return { accepted: false, eventId: null, sessionToken: null };
|
|
147
177
|
|
|
148
178
|
try {
|
|
149
179
|
const response = await fetch(upstream.shopApiUrl, {
|
|
150
180
|
method: 'POST',
|
|
151
|
-
headers,
|
|
181
|
+
headers: buildUpstreamHeaders(upstream.channelToken, forward),
|
|
152
182
|
body: JSON.stringify({ query: TRACK_EVENT_MUTATION, variables: { input } }),
|
|
153
183
|
});
|
|
154
184
|
if (!response.ok) return { accepted: false, eventId: null, sessionToken: null };
|
|
155
185
|
|
|
156
186
|
const payload = (await response.json()) as GraphQLTrackResponse;
|
|
157
|
-
const
|
|
158
|
-
const capturedToken = typeof minted === 'string' && minted.length > 0 ? minted : null;
|
|
187
|
+
const capturedToken = readMintedSessionToken(payload.extensions);
|
|
159
188
|
const trackEvent = payload.data?.trackEvent;
|
|
160
189
|
if (!trackEvent) return { accepted: false, eventId: null, sessionToken: capturedToken };
|
|
161
190
|
return {
|
|
@@ -2,8 +2,18 @@ import { NextResponse } from 'next/server';
|
|
|
2
2
|
import type { NextRequest } from 'next/server';
|
|
3
3
|
import { uuidv7 } from 'uuidv7';
|
|
4
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
|
+
|
|
5
14
|
/**
|
|
6
|
-
* Visitor-identity + forced-experiment-entry
|
|
15
|
+
* Visitor-identity + forced-experiment-entry + locale-rewrite middleware
|
|
16
|
+
* (design doc S6 + F-B; locale stage = epic launch#54 W1-8 / S1).
|
|
7
17
|
*
|
|
8
18
|
* S6 — visitor identity. Mints the durable anonymous visitor key — the
|
|
9
19
|
* `forgecart-visitor` cookie (UUIDv7, so ids sort by first-touch time) —
|
|
@@ -15,6 +25,16 @@ import { uuidv7 } from 'uuidv7';
|
|
|
15
25
|
* - Mint ONLY when the cookie is absent. An identified visitor's response
|
|
16
26
|
* carries NO `Set-Cookie` at all — re-sending it on every response would
|
|
17
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).
|
|
18
38
|
* - Zero fetches, zero environment reads: the workspace-pod prewarm boots
|
|
19
39
|
* this dev server with no `.env`, and the middleware must be inert
|
|
20
40
|
* to that (it never crashes a request, env or not).
|
|
@@ -44,10 +64,68 @@ import { uuidv7 } from 'uuidv7';
|
|
|
44
64
|
* propagation for the VISITOR id (it reads-else-mints within a
|
|
45
65
|
* request-scoped cache); the forced map is different: it either rode in on
|
|
46
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.
|
|
47
117
|
*/
|
|
48
118
|
|
|
49
119
|
const VISITOR_COOKIE_NAME = 'forgecart-visitor';
|
|
50
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
|
+
|
|
51
129
|
/** 400 days — Chrome's upper bound on cookie lifetime (RFC 6265bis). */
|
|
52
130
|
const VISITOR_COOKIE_MAX_AGE_SECONDS = 400 * 24 * 60 * 60;
|
|
53
131
|
|
|
@@ -147,21 +225,112 @@ function mergeForcedEntries(request: NextRequest): string | null {
|
|
|
147
225
|
return JSON.stringify(Object.fromEntries(merged));
|
|
148
226
|
}
|
|
149
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
|
+
|
|
150
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);
|
|
151
290
|
const forcedEntryValue = mergeForcedEntries(request);
|
|
152
|
-
const
|
|
153
|
-
|
|
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
|
+
) {
|
|
154
318
|
return NextResponse.next();
|
|
155
319
|
}
|
|
156
320
|
|
|
157
|
-
let response: NextResponse;
|
|
158
321
|
if (forcedEntryValue) {
|
|
159
322
|
// Rewrite this request's own cookie header so the landing render already
|
|
160
323
|
// resolves the forced variant (Set-Cookie alone reaches only the NEXT
|
|
161
|
-
// request), then persist for subsequent navigations.
|
|
162
|
-
// campaign URL stays shareable
|
|
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.
|
|
163
328
|
request.cookies.set(FORCED_ENTRY_COOKIE_NAME, forcedEntryValue);
|
|
164
|
-
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const response = buildBaseResponse(request, plan, forcedEntryValue !== null);
|
|
332
|
+
|
|
333
|
+
if (forcedEntryValue) {
|
|
165
334
|
response.cookies.set({
|
|
166
335
|
name: FORCED_ENTRY_COOKIE_NAME,
|
|
167
336
|
value: forcedEntryValue,
|
|
@@ -171,18 +340,29 @@ export function middleware(request: NextRequest): NextResponse {
|
|
|
171
340
|
maxAge: FORCED_ENTRY_COOKIE_MAX_AGE_SECONDS,
|
|
172
341
|
path: '/',
|
|
173
342
|
});
|
|
174
|
-
} else {
|
|
175
|
-
response = NextResponse.next();
|
|
176
343
|
}
|
|
177
344
|
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
182
362
|
response.cookies.set({
|
|
183
|
-
name:
|
|
184
|
-
value:
|
|
185
|
-
httpOnly:
|
|
363
|
+
name: VISITOR_MIRROR_COOKIE_NAME,
|
|
364
|
+
value: visitorId,
|
|
365
|
+
httpOnly: false,
|
|
186
366
|
secure: true,
|
|
187
367
|
sameSite: 'lax',
|
|
188
368
|
maxAge: VISITOR_COOKIE_MAX_AGE_SECONDS,
|
|
@@ -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[] = [];
|
|
@@ -4,13 +4,12 @@ import { extractError, type ExtractedError } from '@forgecart/sdk';
|
|
|
4
4
|
import { cookies } from 'next/headers';
|
|
5
5
|
|
|
6
6
|
import { UNREACHABLE_ERROR, type ActionResult } from '../lib/action-result';
|
|
7
|
+
import { SESSION_COOKIE } from '../lib/session-cookies';
|
|
7
8
|
import { getBackend } from './bootstrap';
|
|
8
9
|
import { ForgeCartClientFactory } from './forgecart/forgecart-client.factory';
|
|
9
10
|
import { BackendError } from './types';
|
|
10
11
|
import type { BackendSession } from './types';
|
|
11
12
|
|
|
12
|
-
const SESSION_COOKIE = 'forgecart-session';
|
|
13
|
-
|
|
14
13
|
/** The gate has no env yet (image pre-warm / before `forgecart init`). */
|
|
15
14
|
const NOT_CONFIGURED: ExtractedError = {
|
|
16
15
|
code: 'BACKEND_NOT_CONFIGURED',
|