@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.
- 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,333 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deriveExposureEventId,
|
|
3
|
+
resolveExperimentAssignment,
|
|
4
|
+
type AssignmentResult,
|
|
5
|
+
type AssignmentSource,
|
|
6
|
+
type ExperimentAssignmentConfig,
|
|
7
|
+
} from '@forgecart/sdk';
|
|
8
|
+
import { cookies, headers } from 'next/headers';
|
|
9
|
+
import { after } from 'next/server';
|
|
10
|
+
import { cache } from 'react';
|
|
11
|
+
import { uuidv7 } from 'uuidv7';
|
|
12
|
+
|
|
13
|
+
import { SESSION_COOKIE } from './session-cookies';
|
|
14
|
+
import { forwardTrackEvent, getUpstreamConfig, isObviousBot } from './track-forward';
|
|
15
|
+
import type { TrackEventInput, UpstreamConfig } from './track-forward';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Server-side A/B variant resolution (design doc Wave F-B).
|
|
19
|
+
*
|
|
20
|
+
* `getVariant(experimentKey)` is the ONE storefront API for experiments:
|
|
21
|
+
* Server Components call it and branch on the returned `variantKey` (variant
|
|
22
|
+
* payloads are code branches first, `payload` JSON second). Resolution is
|
|
23
|
+
* LOCAL — the SDK's deterministic assignment engine over a TTL-cached config
|
|
24
|
+
* — so variants render with zero flicker and zero added request round-trips.
|
|
25
|
+
*
|
|
26
|
+
* How the pieces fit:
|
|
27
|
+
* - Config: the public `activeExperiments` shop query, fetched over raw
|
|
28
|
+
* HTTP GraphQL with the hand-rolled `forgecart-token` header (the
|
|
29
|
+
* `track-forward.ts` pattern — the template's pinned published SDK has no
|
|
30
|
+
* plain-HTTP query path) and cached module-scope for
|
|
31
|
+
* {@link CONFIG_TTL_MS} keyed by channel token. A fetch failure resolves
|
|
32
|
+
* to "no experiments" (every caller renders the default branch) and is
|
|
33
|
+
* cached for the same TTL, so a dead backend costs one failed fetch per
|
|
34
|
+
* minute — never one per request.
|
|
35
|
+
* - Subject: the `forgecart-visitor` cookie (S6). When absent, an id is
|
|
36
|
+
* minted IN-REQUEST via a request-scoped `cache()` — never relying on
|
|
37
|
+
* middleware→RSC cookie propagation (S6 doc rule; the middleware persists
|
|
38
|
+
* the durable cookie, this module only needs a consistent value for the
|
|
39
|
+
* current render). Same UUIDv7 value shape as the middleware mint.
|
|
40
|
+
* - Forced entry: the `forgecart-exp-force` cookie the middleware merges
|
|
41
|
+
* from `?fc-exp=` campaign URLs. The engine honors it only when the
|
|
42
|
+
* experiment allows forced entry and the variant exists
|
|
43
|
+
* (`assignmentSource: 'forced'`).
|
|
44
|
+
* - Exposure: when the engine says `exposed` (hashed or forced), ONE
|
|
45
|
+
* `experiment_exposure` marketing event is emitted per request inside
|
|
46
|
+
* `next/server` `after()` — after the response, off the critical path —
|
|
47
|
+
* through the shared `forwardTrackEvent` (never an HTTP self-call to the
|
|
48
|
+
* relay, never the SDK's identity-pinning WebSocket). The event id is the
|
|
49
|
+
* SDK's deterministic UUIDv5 over `salt:subjectKey:utcDay`, so re-renders
|
|
50
|
+
* and multi-page sessions dedupe server-side to one exposure per subject
|
|
51
|
+
* per UTC day.
|
|
52
|
+
*
|
|
53
|
+
* `currencyCode` is deliberately ABSENT from the exposure input: §A wants the
|
|
54
|
+
* session market's currency on exposures, but pre-cart the template server
|
|
55
|
+
* has no side-effect-free source for it — the shop API surfaces currency only
|
|
56
|
+
* via `activeOrder` (whose read AUTO-CREATES an order for a session, see
|
|
57
|
+
* `cart-actions.ts#getCart`) and the geo-seeded market lives in a session
|
|
58
|
+
* that a first-touch page view does not yet have. Inventing a source here
|
|
59
|
+
* (e.g. the channel default off a product read) would mislabel the segment;
|
|
60
|
+
* the field stays unset until the ingest side can stamp the session market
|
|
61
|
+
* server-side.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
const VISITOR_COOKIE = 'forgecart-visitor';
|
|
65
|
+
const FORCED_ENTRY_COOKIE = 'forgecart-exp-force';
|
|
66
|
+
|
|
67
|
+
/** Delivery-config cache TTL — the design doc's 60s config-plane freshness. */
|
|
68
|
+
const CONFIG_TTL_MS = 60_000;
|
|
69
|
+
|
|
70
|
+
/** Canonical textual UUID — the only visitor-cookie shape trusted as subject. */
|
|
71
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
72
|
+
|
|
73
|
+
/** Backend slug grammar (`ExperimentService`) — gates keys read from cookies. */
|
|
74
|
+
const SLUG_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The SDK's own `ShopActiveExperiments` document, inlined — same rationale as
|
|
78
|
+
* `track-forward.ts`: the generated client is WebSocket-only, so the config
|
|
79
|
+
* read speaks plain HTTP GraphQL with the identical selection.
|
|
80
|
+
*/
|
|
81
|
+
const ACTIVE_EXPERIMENTS_QUERY = `query ShopActiveExperiments {
|
|
82
|
+
activeExperiments {
|
|
83
|
+
experimentKey
|
|
84
|
+
subjectType
|
|
85
|
+
version
|
|
86
|
+
salt
|
|
87
|
+
lifecycle
|
|
88
|
+
trafficBps
|
|
89
|
+
allowForcedEntry
|
|
90
|
+
variants {
|
|
91
|
+
key
|
|
92
|
+
weight
|
|
93
|
+
payload
|
|
94
|
+
}
|
|
95
|
+
winnerVariantKey
|
|
96
|
+
}
|
|
97
|
+
}`;
|
|
98
|
+
|
|
99
|
+
/** One variant as the `activeExperiments` shop query delivers it. */
|
|
100
|
+
interface ActiveExperimentVariant {
|
|
101
|
+
key: string;
|
|
102
|
+
weight: number;
|
|
103
|
+
payload: Record<string, unknown> | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The public delivery projection of one experiment (FB2b contract). */
|
|
107
|
+
interface ActiveExperiment {
|
|
108
|
+
experimentKey: string;
|
|
109
|
+
subjectType: 'visitor' | 'customer';
|
|
110
|
+
version: number;
|
|
111
|
+
salt: string;
|
|
112
|
+
lifecycle: 'running' | 'paused' | 'completed';
|
|
113
|
+
trafficBps: number;
|
|
114
|
+
allowForcedEntry: boolean;
|
|
115
|
+
variants: ActiveExperimentVariant[];
|
|
116
|
+
winnerVariantKey: string | null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** What a Server Component branches on. */
|
|
120
|
+
export interface ExperimentVariantResult {
|
|
121
|
+
/**
|
|
122
|
+
* The assigned variant key, or `null` when the experiment is unknown /
|
|
123
|
+
* undelivered / malformed — callers treat `null` as "render the default
|
|
124
|
+
* branch", which keeps every page correct with zero experiments configured.
|
|
125
|
+
*/
|
|
126
|
+
variantKey: string | null;
|
|
127
|
+
/**
|
|
128
|
+
* How the variant was decided: `'hashed'` (randomized cohort), `'forced'`
|
|
129
|
+
* (campaign entry — excluded from the randomized comparison), `'lifecycle'`
|
|
130
|
+
* (paused → control / completed → winner), or `null` (non-participant
|
|
131
|
+
* control or no experiment).
|
|
132
|
+
*/
|
|
133
|
+
assignmentSource: AssignmentSource | null;
|
|
134
|
+
/** The variant's optional delivery payload (design doc Q5), if any. */
|
|
135
|
+
payload: Record<string, unknown> | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface GraphQLActiveExperimentsResponse {
|
|
139
|
+
data?: { activeExperiments?: unknown } | null;
|
|
140
|
+
errors?: unknown[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Module-scope 60s TTL cache — one config fetch per server per minute. */
|
|
144
|
+
let configCache: {
|
|
145
|
+
channelToken: string;
|
|
146
|
+
expiresAt: number;
|
|
147
|
+
load: Promise<ActiveExperiment[]>;
|
|
148
|
+
} | null = null;
|
|
149
|
+
|
|
150
|
+
async function fetchActiveExperiments(upstream: UpstreamConfig): Promise<ActiveExperiment[]> {
|
|
151
|
+
try {
|
|
152
|
+
const response = await fetch(upstream.shopApiUrl, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: {
|
|
155
|
+
'content-type': 'application/json',
|
|
156
|
+
'forgecart-token': upstream.channelToken,
|
|
157
|
+
},
|
|
158
|
+
body: JSON.stringify({ query: ACTIVE_EXPERIMENTS_QUERY }),
|
|
159
|
+
});
|
|
160
|
+
if (!response.ok) return [];
|
|
161
|
+
const payload = (await response.json()) as GraphQLActiveExperimentsResponse;
|
|
162
|
+
const list = payload.data?.activeExperiments;
|
|
163
|
+
return Array.isArray(list) ? (list as ActiveExperiment[]) : [];
|
|
164
|
+
} catch {
|
|
165
|
+
// Experiments must never break the storefront: no config → every caller
|
|
166
|
+
// renders its default branch until the next TTL window retries.
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getActiveExperiments(): Promise<ActiveExperiment[]> {
|
|
172
|
+
const upstream = getUpstreamConfig();
|
|
173
|
+
// Inert before `forgecart init` writes `.env` (image-prewarm contract).
|
|
174
|
+
if (!upstream) return Promise.resolve([]);
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
if (
|
|
177
|
+
configCache &&
|
|
178
|
+
configCache.channelToken === upstream.channelToken &&
|
|
179
|
+
now < configCache.expiresAt
|
|
180
|
+
) {
|
|
181
|
+
return configCache.load;
|
|
182
|
+
}
|
|
183
|
+
configCache = {
|
|
184
|
+
channelToken: upstream.channelToken,
|
|
185
|
+
expiresAt: now + CONFIG_TTL_MS,
|
|
186
|
+
load: fetchActiveExperiments(upstream),
|
|
187
|
+
};
|
|
188
|
+
return configCache.load;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The assignment subject for this request: the S6 visitor cookie when it
|
|
193
|
+
* rode in, else ONE in-request UUIDv7 shared by every `getVariant` call of
|
|
194
|
+
* the render (request-scoped `cache()`). A cookie-less first page view thus
|
|
195
|
+
* gets a consistent assignment within its own render; the middleware's
|
|
196
|
+
* Set-Cookie makes the durable id authoritative from the next request on.
|
|
197
|
+
*/
|
|
198
|
+
const getRequestVisitorId = cache(async (): Promise<string> => {
|
|
199
|
+
const store = await cookies();
|
|
200
|
+
const existing = store.get(VISITOR_COOKIE)?.value;
|
|
201
|
+
if (existing && UUID_PATTERN.test(existing)) return existing;
|
|
202
|
+
return uuidv7();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/** The forced-entry map from the middleware-merged cookie, slug-validated. */
|
|
206
|
+
const getForcedVariantMap = cache(async (): Promise<Record<string, string>> => {
|
|
207
|
+
const store = await cookies();
|
|
208
|
+
const raw = store.get(FORCED_ENTRY_COOKIE)?.value;
|
|
209
|
+
if (!raw) return {};
|
|
210
|
+
let parsed: unknown;
|
|
211
|
+
try {
|
|
212
|
+
// `cookies()` already percent-decodes — `raw` is the JSON text.
|
|
213
|
+
parsed = JSON.parse(raw);
|
|
214
|
+
} catch {
|
|
215
|
+
return {};
|
|
216
|
+
}
|
|
217
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {};
|
|
218
|
+
const map: Record<string, string> = {};
|
|
219
|
+
for (const [experimentKey, variantKey] of Object.entries(parsed)) {
|
|
220
|
+
if (typeof variantKey !== 'string') continue;
|
|
221
|
+
if (!SLUG_REGEX.test(experimentKey) || !SLUG_REGEX.test(variantKey)) continue;
|
|
222
|
+
map[experimentKey] = variantKey;
|
|
223
|
+
}
|
|
224
|
+
return map;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
function toAssignmentConfig(experiment: ActiveExperiment): ExperimentAssignmentConfig {
|
|
228
|
+
return {
|
|
229
|
+
salt: experiment.salt,
|
|
230
|
+
lifecycle: experiment.lifecycle,
|
|
231
|
+
trafficBps: experiment.trafficBps,
|
|
232
|
+
allowForcedEntry: experiment.allowForcedEntry,
|
|
233
|
+
variants: experiment.variants.map(({ key, weight }) => ({ key, weight })),
|
|
234
|
+
winnerVariantKey: experiment.winnerVariantKey,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Emit the `experiment_exposure` marketing event inside `after()`. Request
|
|
240
|
+
* data (UA, client IP, session) is captured HERE, during render — `after()`
|
|
241
|
+
* callbacks run once the response is done and must not touch request APIs.
|
|
242
|
+
* The response is already sent when a session-less exposure reaches the shop
|
|
243
|
+
* API, so a token minted for it cannot be persisted — accepted: the relay's
|
|
244
|
+
* first client batch establishes the durable shopper session.
|
|
245
|
+
*/
|
|
246
|
+
async function scheduleExposure(
|
|
247
|
+
experiment: ActiveExperiment,
|
|
248
|
+
result: AssignmentResult,
|
|
249
|
+
visitorId: string,
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
const requestHeaders = await headers();
|
|
252
|
+
const userAgent = requestHeaders.get('user-agent') ?? '';
|
|
253
|
+
// Same bot policy as the relay: unambiguous crawlers emit nothing.
|
|
254
|
+
if (isObviousBot(userAgent)) return;
|
|
255
|
+
const forwardedFor = requestHeaders.get('x-forwarded-for');
|
|
256
|
+
const store = await cookies();
|
|
257
|
+
const sessionToken = store.get(SESSION_COOKIE)?.value ?? null;
|
|
258
|
+
|
|
259
|
+
const occurredAt = new Date();
|
|
260
|
+
const input: TrackEventInput = {
|
|
261
|
+
eventType: 'experiment_exposure',
|
|
262
|
+
// Deterministic UUIDv5 over salt:subjectKey:utcDay — every re-render of
|
|
263
|
+
// this subject today resubmits the SAME id and the backend dedupes.
|
|
264
|
+
eventId: deriveExposureEventId(experiment.salt, visitorId, occurredAt),
|
|
265
|
+
occurredAt: occurredAt.toISOString(),
|
|
266
|
+
properties: {
|
|
267
|
+
experimentKey: experiment.experimentKey,
|
|
268
|
+
variantKey: result.variantKey,
|
|
269
|
+
version: experiment.version,
|
|
270
|
+
subjectType: experiment.subjectType,
|
|
271
|
+
assignmentSource: result.assignmentSource,
|
|
272
|
+
visitorId,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
after(async () => {
|
|
277
|
+
await forwardTrackEvent(input, { sessionToken, userAgent, forwardedFor });
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Resolve the variant for one experiment. Request-scoped `cache()` keeps the
|
|
283
|
+
* whole render consistent — layout and page asking for the same key get one
|
|
284
|
+
* resolution and at most ONE exposure emission per request.
|
|
285
|
+
*/
|
|
286
|
+
export const getVariant = cache(async (experimentKey: string): Promise<ExperimentVariantResult> => {
|
|
287
|
+
const inert: ExperimentVariantResult = {
|
|
288
|
+
variantKey: null,
|
|
289
|
+
assignmentSource: null,
|
|
290
|
+
payload: null,
|
|
291
|
+
};
|
|
292
|
+
if (!SLUG_REGEX.test(experimentKey)) return inert;
|
|
293
|
+
|
|
294
|
+
const experiments = await getActiveExperiments();
|
|
295
|
+
const experiment = experiments.find((entry) => entry.experimentKey === experimentKey);
|
|
296
|
+
if (!experiment) return inert;
|
|
297
|
+
|
|
298
|
+
if (experiment.subjectType === 'customer') {
|
|
299
|
+
// The storefront server has no verified customer id (auth lives behind
|
|
300
|
+
// the shop API session) — a customer-scoped experiment renders as
|
|
301
|
+
// non-participant control here, with no exposure. Customer-subject
|
|
302
|
+
// resolution belongs to clients that KNOW the customer id.
|
|
303
|
+
const control = experiment.variants[0];
|
|
304
|
+
if (!control) return inert;
|
|
305
|
+
return { variantKey: control.key, assignmentSource: null, payload: control.payload ?? null };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const visitorId = await getRequestVisitorId();
|
|
309
|
+
const forcedVariantKey = (await getForcedVariantMap())[experimentKey] ?? null;
|
|
310
|
+
|
|
311
|
+
let result: AssignmentResult;
|
|
312
|
+
try {
|
|
313
|
+
result = resolveExperimentAssignment(toAssignmentConfig(experiment), {
|
|
314
|
+
subjectKey: visitorId,
|
|
315
|
+
forcedVariantKey,
|
|
316
|
+
});
|
|
317
|
+
} catch {
|
|
318
|
+
// A malformed delivery (out-of-bounds weight, empty variants) must
|
|
319
|
+
// never break rendering — the caller falls back to its default branch.
|
|
320
|
+
return inert;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (result.exposed && result.variantKey !== null) {
|
|
324
|
+
await scheduleExposure(experiment, result, visitorId);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const variant = experiment.variants.find((entry) => entry.key === result.variantKey);
|
|
328
|
+
return {
|
|
329
|
+
variantKey: result.variantKey,
|
|
330
|
+
assignmentSource: result.assignmentSource,
|
|
331
|
+
payload: variant?.payload ?? null,
|
|
332
|
+
};
|
|
333
|
+
});
|