@forgecart/cli 2.202607190047.0 → 2.202607240817.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/commands/init.d.ts +2 -2
- package/dist/src/commands/init.js +5 -5
- package/dist/src/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/.env.example +1 -1
- package/templates/storefront/README.md +2 -2
- package/templates/storefront/next.config.js +16 -15
- package/templates/storefront/package.json +3 -2
- package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +168 -0
- package/templates/storefront/src/app/layout.tsx +2 -2
- package/templates/storefront/src/app/page.tsx +15 -3
- package/templates/storefront/src/components/CartView.tsx +2 -0
- package/templates/storefront/src/components/ForgeTracker.tsx +267 -0
- package/templates/storefront/src/components/Header.tsx +1 -1
- package/templates/storefront/src/components/ProductCard.tsx +1 -0
- package/templates/storefront/src/components/ProductPurchase.tsx +1 -0
- package/templates/storefront/src/lib/cart-actions.ts +1 -1
- package/templates/storefront/src/lib/experiments.ts +335 -0
- package/templates/storefront/src/lib/forgecart.ts +3 -3
- package/templates/storefront/src/lib/track-forward.ts +158 -0
- package/templates/storefront/src/middleware.ts +199 -0
- package/templates/storefront/src/components/ForgeAnalytics.tsx +0 -128
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { usePathname } from 'next/navigation';
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Storefront event tracker (design doc S7). Renders nothing.
|
|
8
|
+
*
|
|
9
|
+
* The single client-side emitter of marketing events. Everything goes through
|
|
10
|
+
* the same-origin `/__fc/track` relay — NEVER the SDK's WebSocket singleton,
|
|
11
|
+
* whose connection pins one identity and would mis-attribute every event after
|
|
12
|
+
* a login. The relay replays the shopper's `forgecart-session` cookie per
|
|
13
|
+
* request, so attribution follows the identity.
|
|
14
|
+
*
|
|
15
|
+
* What it emits:
|
|
16
|
+
* - `page_view` on the initial load and on every App-Router route change
|
|
17
|
+
* (client-effect emission is the only prefetch-safe choice — `<Link>`
|
|
18
|
+
* prefetches run middleware and RSC fetches but no client effects).
|
|
19
|
+
* `properties.referrerPath` is the module-tracked previous pathname:
|
|
20
|
+
* `document.referrer` is frozen across soft navigations and must not be
|
|
21
|
+
* read per-view; only the initial load additionally carries it (origin
|
|
22
|
+
* level under the default Referrer-Policy) as `properties.referrer`.
|
|
23
|
+
* - `cta_click` from ONE delegated capture-phase click listener for any
|
|
24
|
+
* `[data-fc-track]` element — the element stays dumb (a semantic slug in
|
|
25
|
+
* `properties.trackId`); what the click MEANS is backend automation
|
|
26
|
+
* config, never deployed code.
|
|
27
|
+
* - `heartbeat` every {@link HEARTBEAT_MS} while the tab is visible (plus
|
|
28
|
+
* one on refocus) — liveness plumbing only, excluded from aggregates.
|
|
29
|
+
*
|
|
30
|
+
* Delivery: events buffer and flush as one batch at {@link FLUSH_MAX_EVENTS}
|
|
31
|
+
* events or after {@link FLUSH_INTERVAL_MS} via `fetch(keepalive)`;
|
|
32
|
+
* `sendBeacon` is used ONLY for the `pagehide` flush (its unload-time queueing
|
|
33
|
+
* guarantee is the one thing keepalive fetch lacks). Every event carries a
|
|
34
|
+
* client-minted `eventId` (`crypto.randomUUID`) — the server dedupes on it, so
|
|
35
|
+
* an accidental double-send is harmless. Delivery is fire-and-forget: failures
|
|
36
|
+
* are swallowed and never retried; analytics must never break the storefront.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately NO 30-second same-path dedup (the old `ForgeAnalytics`
|
|
39
|
+
* behavior): it was live-map-correct but funnel-lossy — A→B→A inside 30s
|
|
40
|
+
* dropped the return view. The short same-signal suppressors (300ms; also what
|
|
41
|
+
* keeps StrictMode's double-mount from double-emitting, via module scope) plus
|
|
42
|
+
* the server-side `eventId` dedupe replace it, so revisits are recorded.
|
|
43
|
+
*
|
|
44
|
+
* Two deliberate suppressions:
|
|
45
|
+
* - missing config (the pod image pre-renders the template before
|
|
46
|
+
* `forgecart init` writes `.env`) → the tracker is inert;
|
|
47
|
+
* - framed embeds (`window.parent !== window`) → the visual editor's
|
|
48
|
+
* artboard preview emits nothing, so funnels stay production-visitor-only.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const HEARTBEAT_MS = 60_000;
|
|
52
|
+
/** Minimum spacing between heartbeats (refocus storms, StrictMode remounts). */
|
|
53
|
+
const HEARTBEAT_MIN_SPACING_MS = 30_000;
|
|
54
|
+
/** Batch flush thresholds: whichever of size / age is hit first sends. */
|
|
55
|
+
const FLUSH_MAX_EVENTS = 10;
|
|
56
|
+
const FLUSH_INTERVAL_MS = 500;
|
|
57
|
+
/** Same-signal suppression window (per click slug; per page_view path). */
|
|
58
|
+
const SUPPRESS_MS = 300;
|
|
59
|
+
const TRACK_ENDPOINT = '/__fc/track';
|
|
60
|
+
|
|
61
|
+
/** One buffered event — the relay forwards these fields to `trackEvent`. */
|
|
62
|
+
interface TrackedEvent {
|
|
63
|
+
eventType: string;
|
|
64
|
+
eventId: string;
|
|
65
|
+
occurredAt: string;
|
|
66
|
+
properties?: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Module scope on purpose: the buffer, timers and suppressors must survive
|
|
70
|
+
// StrictMode's mount → unmount → mount cycle (a ref would reset) and there is
|
|
71
|
+
// exactly one tracker per page.
|
|
72
|
+
let buffer: TrackedEvent[] = [];
|
|
73
|
+
let flushTimer: number | null = null;
|
|
74
|
+
const lastClickAtBySlug = new Map<string, number>();
|
|
75
|
+
let lastPageView: { path: string; at: number } | null = null;
|
|
76
|
+
let previousPathname: string | null = null;
|
|
77
|
+
let lastHeartbeatAt = 0;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Consent extension point. Tracking currently needs no opt-in, so this is a
|
|
81
|
+
* no-op returning `true`; the merchant cookie-banner fast-follow implements
|
|
82
|
+
* the real gate HERE (read the stored consent state) and every emission path
|
|
83
|
+
* already respects it — nothing enters the buffer without consent.
|
|
84
|
+
*/
|
|
85
|
+
function hasTrackingConsent(): boolean {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Client-side idempotency key: `crypto.randomUUID` where available; on the one
|
|
91
|
+
* environment it isn't (insecure-context LAN-IP dev hosts — the API is
|
|
92
|
+
* secure-context gated, `getRandomValues` is not) mint the same v4 shape by
|
|
93
|
+
* hand, because a thrown TypeError inside a click listener would violate the
|
|
94
|
+
* "analytics never breaks the storefront" invariant.
|
|
95
|
+
*/
|
|
96
|
+
function mintEventId(): string {
|
|
97
|
+
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
|
98
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
99
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
100
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
101
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
102
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Stamp and buffer one event; flush on size, else arm the age timer. */
|
|
106
|
+
function enqueue(eventType: string, properties?: Record<string, unknown>): void {
|
|
107
|
+
if (!hasTrackingConsent()) return;
|
|
108
|
+
const event: TrackedEvent = {
|
|
109
|
+
eventType,
|
|
110
|
+
eventId: mintEventId(),
|
|
111
|
+
occurredAt: new Date().toISOString(),
|
|
112
|
+
};
|
|
113
|
+
if (properties) event.properties = properties;
|
|
114
|
+
buffer.push(event);
|
|
115
|
+
|
|
116
|
+
if (buffer.length >= FLUSH_MAX_EVENTS) {
|
|
117
|
+
flush();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (flushTimer === null) {
|
|
121
|
+
flushTimer = window.setTimeout(flush, FLUSH_INTERVAL_MS);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Drain the buffer, returning the batch to send (empties module state). */
|
|
126
|
+
function drain(): TrackedEvent[] {
|
|
127
|
+
if (flushTimer !== null) {
|
|
128
|
+
window.clearTimeout(flushTimer);
|
|
129
|
+
flushTimer = null;
|
|
130
|
+
}
|
|
131
|
+
const events = buffer;
|
|
132
|
+
buffer = [];
|
|
133
|
+
return events;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Primary delivery: keepalive fetch, fire-and-forget, failures swallowed. */
|
|
137
|
+
function flush(): void {
|
|
138
|
+
const events = drain();
|
|
139
|
+
if (events.length === 0) return;
|
|
140
|
+
void fetch(TRACK_ENDPOINT, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'content-type': 'application/json' },
|
|
143
|
+
body: JSON.stringify({ events }),
|
|
144
|
+
keepalive: true,
|
|
145
|
+
}).catch(() => undefined);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `pagehide` delivery: `sendBeacon` — the only flush that uses it, for its
|
|
150
|
+
* survive-the-unload queueing guarantee. Falls back to keepalive fetch where
|
|
151
|
+
* the API is unavailable.
|
|
152
|
+
*/
|
|
153
|
+
function flushOnPagehide(): void {
|
|
154
|
+
const events = drain();
|
|
155
|
+
if (events.length === 0) return;
|
|
156
|
+
const body = JSON.stringify({ events });
|
|
157
|
+
if (typeof navigator.sendBeacon === 'function') {
|
|
158
|
+
navigator.sendBeacon(TRACK_ENDPOINT, new Blob([body], { type: 'application/json' }));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
void fetch(TRACK_ENDPOINT, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'content-type': 'application/json' },
|
|
164
|
+
body,
|
|
165
|
+
keepalive: true,
|
|
166
|
+
}).catch(() => undefined);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function ForgeTracker({
|
|
170
|
+
shopApiUrl,
|
|
171
|
+
channelToken,
|
|
172
|
+
}: {
|
|
173
|
+
shopApiUrl: string;
|
|
174
|
+
channelToken: string;
|
|
175
|
+
}) {
|
|
176
|
+
const pathname = usePathname();
|
|
177
|
+
const enabled = Boolean(shopApiUrl && channelToken);
|
|
178
|
+
|
|
179
|
+
// page_view — initial load + every route change.
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (!enabled) return;
|
|
182
|
+
if (window.parent !== window) return;
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
if (lastPageView && lastPageView.path === pathname && now - lastPageView.at < SUPPRESS_MS) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
lastPageView = { path: pathname, at: now };
|
|
188
|
+
const properties: Record<string, unknown> = { path: pathname };
|
|
189
|
+
if (previousPathname !== null) {
|
|
190
|
+
properties.referrerPath = previousPathname;
|
|
191
|
+
} else if (document.referrer) {
|
|
192
|
+
properties.referrer = document.referrer;
|
|
193
|
+
}
|
|
194
|
+
previousPathname = pathname;
|
|
195
|
+
enqueue('page_view', properties);
|
|
196
|
+
}, [enabled, pathname]);
|
|
197
|
+
|
|
198
|
+
// cta_click — one delegated capture-phase listener — and the pagehide flush.
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (!enabled) return;
|
|
201
|
+
if (window.parent !== window) return;
|
|
202
|
+
|
|
203
|
+
const onClick = (event: Event): void => {
|
|
204
|
+
if (!(event.target instanceof Element)) return;
|
|
205
|
+
const tracked = event.target.closest('[data-fc-track]');
|
|
206
|
+
if (!tracked) return;
|
|
207
|
+
const trackId = tracked.getAttribute('data-fc-track');
|
|
208
|
+
if (!trackId) return;
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
const lastAt = lastClickAtBySlug.get(trackId);
|
|
211
|
+
if (lastAt !== undefined && now - lastAt < SUPPRESS_MS) return;
|
|
212
|
+
lastClickAtBySlug.set(trackId, now);
|
|
213
|
+
enqueue('cta_click', { trackId, path: window.location.pathname });
|
|
214
|
+
};
|
|
215
|
+
const onPagehide = (): void => flushOnPagehide();
|
|
216
|
+
|
|
217
|
+
// Capture phase so the event is seen even when a handler below stops
|
|
218
|
+
// propagation (e.g. a framework's synthetic-event stopPropagation).
|
|
219
|
+
document.addEventListener('click', onClick, true);
|
|
220
|
+
window.addEventListener('pagehide', onPagehide);
|
|
221
|
+
return () => {
|
|
222
|
+
document.removeEventListener('click', onClick, true);
|
|
223
|
+
window.removeEventListener('pagehide', onPagehide);
|
|
224
|
+
};
|
|
225
|
+
}, [enabled]);
|
|
226
|
+
|
|
227
|
+
// heartbeat — while visible, plus one on refocus; paused while hidden.
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
if (!enabled) return;
|
|
230
|
+
if (window.parent !== window) return;
|
|
231
|
+
|
|
232
|
+
let interval: ReturnType<typeof setInterval> | null = null;
|
|
233
|
+
|
|
234
|
+
const beat = (): void => {
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
if (now - lastHeartbeatAt < HEARTBEAT_MIN_SPACING_MS) return;
|
|
237
|
+
lastHeartbeatAt = now;
|
|
238
|
+
enqueue('heartbeat');
|
|
239
|
+
};
|
|
240
|
+
const start = (): void => {
|
|
241
|
+
if (interval !== null) return;
|
|
242
|
+
interval = setInterval(beat, HEARTBEAT_MS);
|
|
243
|
+
};
|
|
244
|
+
const stop = (): void => {
|
|
245
|
+
if (interval === null) return;
|
|
246
|
+
clearInterval(interval);
|
|
247
|
+
interval = null;
|
|
248
|
+
};
|
|
249
|
+
const onVisibility = (): void => {
|
|
250
|
+
if (document.visibilityState === 'visible') {
|
|
251
|
+
beat();
|
|
252
|
+
start();
|
|
253
|
+
} else {
|
|
254
|
+
stop();
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
259
|
+
if (document.visibilityState === 'visible') start();
|
|
260
|
+
return () => {
|
|
261
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
262
|
+
stop();
|
|
263
|
+
};
|
|
264
|
+
}, [enabled]);
|
|
265
|
+
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
@@ -32,7 +32,7 @@ export function Header() {
|
|
|
32
32
|
<Link href="/products" className="hover:text-gray-900">
|
|
33
33
|
Products
|
|
34
34
|
</Link>
|
|
35
|
-
<Link href="/cart" className="relative hover:text-gray-900">
|
|
35
|
+
<Link href="/cart" data-fc-track="header-cart" className="relative hover:text-gray-900">
|
|
36
36
|
Cart
|
|
37
37
|
{mounted && itemCount > 0 && (
|
|
38
38
|
<span className="ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-gray-900 px-1.5 text-xs font-semibold text-white">
|
|
@@ -9,6 +9,7 @@ export function ProductCard({ product }: { product: Product }) {
|
|
|
9
9
|
return (
|
|
10
10
|
<Link
|
|
11
11
|
href={`/products/${product.slug}`}
|
|
12
|
+
data-fc-track="product-card"
|
|
12
13
|
className="group flex flex-col overflow-hidden rounded-lg border border-gray-200 bg-white transition hover:shadow-md"
|
|
13
14
|
>
|
|
14
15
|
<div className="aspect-square overflow-hidden bg-gray-100">
|
|
@@ -193,6 +193,7 @@ export function ProductPurchase({
|
|
|
193
193
|
type="button"
|
|
194
194
|
onClick={onAdd}
|
|
195
195
|
disabled={!variant || pending}
|
|
196
|
+
data-fc-track="add-to-cart"
|
|
196
197
|
className="rounded-md bg-gray-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
197
198
|
>
|
|
198
199
|
{pending ? 'Adding…' : added ? 'Added!' : variant ? 'Add to cart' : 'Unavailable'}
|
|
@@ -63,7 +63,7 @@ async function persistSession(client: ForgeCartShopClient): Promise<void> {
|
|
|
63
63
|
/**
|
|
64
64
|
* Read the active order (cart). Safe to call from a Server Component.
|
|
65
65
|
*
|
|
66
|
-
* Before `forgecart init` writes `.env
|
|
66
|
+
* Before `forgecart init` writes `.env` (notably the image-build pre-warm,
|
|
67
67
|
* which renders `/` with no env), there is no shop to talk to — return an empty
|
|
68
68
|
* cart immediately instead of driving the client into a doomed fetch. The
|
|
69
69
|
* layout renders on every route, so this read must never stall an unconfigured
|
|
@@ -0,0 +1,335 @@
|
|
|
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 { forwardTrackEvent, getUpstreamConfig, isObviousBot } from './track-forward';
|
|
14
|
+
import type { TrackEventInput, UpstreamConfig } from './track-forward';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Server-side A/B variant resolution (design doc Wave F-B).
|
|
18
|
+
*
|
|
19
|
+
* `getVariant(experimentKey)` is the ONE storefront API for experiments:
|
|
20
|
+
* Server Components call it and branch on the returned `variantKey` (variant
|
|
21
|
+
* payloads are code branches first, `payload` JSON second). Resolution is
|
|
22
|
+
* LOCAL — the SDK's deterministic assignment engine over a TTL-cached config
|
|
23
|
+
* — so variants render with zero flicker and zero added request round-trips.
|
|
24
|
+
*
|
|
25
|
+
* How the pieces fit:
|
|
26
|
+
* - Config: the public `activeExperiments` shop query, fetched over raw
|
|
27
|
+
* HTTP GraphQL with the hand-rolled `forgecart-token` header (the
|
|
28
|
+
* `track-forward.ts` pattern — the template's pinned published SDK has no
|
|
29
|
+
* plain-HTTP query path) and cached module-scope for
|
|
30
|
+
* {@link CONFIG_TTL_MS} keyed by channel token. A fetch failure resolves
|
|
31
|
+
* to "no experiments" (every caller renders the default branch) and is
|
|
32
|
+
* cached for the same TTL, so a dead backend costs one failed fetch per
|
|
33
|
+
* minute — never one per request.
|
|
34
|
+
* - Subject: the `forgecart-visitor` cookie (S6). When absent, an id is
|
|
35
|
+
* minted IN-REQUEST via a request-scoped `cache()` — never relying on
|
|
36
|
+
* middleware→RSC cookie propagation (S6 doc rule; the middleware persists
|
|
37
|
+
* the durable cookie, this module only needs a consistent value for the
|
|
38
|
+
* current render). Same UUIDv7 value shape as the middleware mint.
|
|
39
|
+
* - Forced entry: the `forgecart-exp-force` cookie the middleware merges
|
|
40
|
+
* from `?fc-exp=` campaign URLs. The engine honors it only when the
|
|
41
|
+
* experiment allows forced entry and the variant exists
|
|
42
|
+
* (`assignmentSource: 'forced'`).
|
|
43
|
+
* - Exposure: when the engine says `exposed` (hashed or forced), ONE
|
|
44
|
+
* `experiment_exposure` marketing event is emitted per request inside
|
|
45
|
+
* `next/server` `after()` — after the response, off the critical path —
|
|
46
|
+
* through the shared `forwardTrackEvent` (never an HTTP self-call to the
|
|
47
|
+
* relay, never the SDK's identity-pinning WebSocket). The event id is the
|
|
48
|
+
* SDK's deterministic UUIDv5 over `salt:subjectKey:utcDay`, so re-renders
|
|
49
|
+
* and multi-page sessions dedupe server-side to one exposure per subject
|
|
50
|
+
* per UTC day.
|
|
51
|
+
*
|
|
52
|
+
* `currencyCode` is deliberately ABSENT from the exposure input: §A wants the
|
|
53
|
+
* session market's currency on exposures, but pre-cart the template server
|
|
54
|
+
* has no side-effect-free source for it — the shop API surfaces currency only
|
|
55
|
+
* via `activeOrder` (whose read AUTO-CREATES an order for a session, see
|
|
56
|
+
* `cart-actions.ts#getCart`) and the geo-seeded market lives in a session
|
|
57
|
+
* that a first-touch page view does not yet have. Inventing a source here
|
|
58
|
+
* (e.g. the channel default off a product read) would mislabel the segment;
|
|
59
|
+
* the field stays unset until the ingest side can stamp the session market
|
|
60
|
+
* server-side.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
const VISITOR_COOKIE = 'forgecart-visitor';
|
|
64
|
+
const SESSION_COOKIE = 'forgecart-session';
|
|
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(
|
|
287
|
+
async (experimentKey: string): Promise<ExperimentVariantResult> => {
|
|
288
|
+
const inert: ExperimentVariantResult = {
|
|
289
|
+
variantKey: null,
|
|
290
|
+
assignmentSource: null,
|
|
291
|
+
payload: null,
|
|
292
|
+
};
|
|
293
|
+
if (!SLUG_REGEX.test(experimentKey)) return inert;
|
|
294
|
+
|
|
295
|
+
const experiments = await getActiveExperiments();
|
|
296
|
+
const experiment = experiments.find((entry) => entry.experimentKey === experimentKey);
|
|
297
|
+
if (!experiment) return inert;
|
|
298
|
+
|
|
299
|
+
if (experiment.subjectType === 'customer') {
|
|
300
|
+
// The storefront server has no verified customer id (auth lives behind
|
|
301
|
+
// the shop API session) — a customer-scoped experiment renders as
|
|
302
|
+
// non-participant control here, with no exposure. Customer-subject
|
|
303
|
+
// resolution belongs to clients that KNOW the customer id.
|
|
304
|
+
const control = experiment.variants[0];
|
|
305
|
+
if (!control) return inert;
|
|
306
|
+
return { variantKey: control.key, assignmentSource: null, payload: control.payload ?? null };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const visitorId = await getRequestVisitorId();
|
|
310
|
+
const forcedVariantKey = (await getForcedVariantMap())[experimentKey] ?? null;
|
|
311
|
+
|
|
312
|
+
let result: AssignmentResult;
|
|
313
|
+
try {
|
|
314
|
+
result = resolveExperimentAssignment(toAssignmentConfig(experiment), {
|
|
315
|
+
subjectKey: visitorId,
|
|
316
|
+
forcedVariantKey,
|
|
317
|
+
});
|
|
318
|
+
} catch {
|
|
319
|
+
// A malformed delivery (out-of-bounds weight, empty variants) must
|
|
320
|
+
// never break rendering — the caller falls back to its default branch.
|
|
321
|
+
return inert;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (result.exposed && result.variantKey !== null) {
|
|
325
|
+
await scheduleExposure(experiment, result, visitorId);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const variant = experiment.variants.find((entry) => entry.key === result.variantKey);
|
|
329
|
+
return {
|
|
330
|
+
variantKey: result.variantKey,
|
|
331
|
+
assignmentSource: result.assignmentSource,
|
|
332
|
+
payload: variant?.payload ?? null,
|
|
333
|
+
};
|
|
334
|
+
},
|
|
335
|
+
);
|
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
*
|
|
16
16
|
* The channel token is sent as the `forgecart-token` header on every request
|
|
17
17
|
* (the SDK adds it from `channelToken`), and the endpoint points at the
|
|
18
|
-
* channel's shop-api. Both values come from the environment (`.env
|
|
18
|
+
* channel's shop-api. Both values come from the environment (`.env`,
|
|
19
19
|
* written by `forgecart init`):
|
|
20
20
|
* - FORGECART_SHOP_API_URL -> endpoint
|
|
21
21
|
* - FORGECART_CHANNEL_TOKEN -> channelToken
|
|
@@ -42,12 +42,12 @@ let client: ForgeCartShopClient | null = null;
|
|
|
42
42
|
export function getShopClient(): ForgeCartShopClient {
|
|
43
43
|
if (!SHOP_API_URL) {
|
|
44
44
|
throw new Error(
|
|
45
|
-
'FORGECART_SHOP_API_URL is not set. Add it to .env
|
|
45
|
+
'FORGECART_SHOP_API_URL is not set. Add it to .env (forgecart init writes it for you).',
|
|
46
46
|
);
|
|
47
47
|
}
|
|
48
48
|
if (!CHANNEL_TOKEN) {
|
|
49
49
|
throw new Error(
|
|
50
|
-
'FORGECART_CHANNEL_TOKEN is not set. Add it to .env
|
|
50
|
+
'FORGECART_CHANNEL_TOKEN is not set. Add it to .env (forgecart init writes it for you).',
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
53
|
if (!client) {
|