@forgecart/cli 2.202607190047.0 → 2.202607202320.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.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Shared marketing-event upstream forwarder (design doc S7 + F-B).
3
+ *
4
+ * ONE implementation of "send one trackEvent to the ForgeCart shop API over
5
+ * plain HTTP GraphQL", used by both server-side emitters:
6
+ *
7
+ * - the `/__fc/track` relay route (client batches — page_view, cta_click,
8
+ * heartbeat, …), which loops this per item;
9
+ * - `experiments.ts` (F-B), which emits `experiment_exposure` inside
10
+ * `next/server` `after()` — the exposure MUST NOT self-call the relay
11
+ * over HTTP (an extra localhost round-trip that breaks the moment the
12
+ * server is bound behind a proxy) and MUST NOT use the SDK's WebSocket
13
+ * singleton (its connection pins one identity; design doc F-B blocker).
14
+ *
15
+ * Why raw HTTP GraphQL: the generated SDK transports operations over one
16
+ * WebSocket whose connection pins ONE identity — the relay/exposure path
17
+ * instead sends each request with the CURRENT shopper session as
18
+ * `Authorization: Bearer`, so attribution follows the shopper. The generated
19
+ * SDK also has no plain-HTTP query path, hence the hand-rolled POST (the shop
20
+ * API serves HTTP POST at the same `/shop-api` URL).
21
+ *
22
+ * Config is env-backed (`forgecart init` writes `.env`); before that
23
+ * exists (the workspace-pod image prewarm) `getUpstreamConfig()` answers
24
+ * `null` and every caller stays inert — the storefront never crashes over
25
+ * missing analytics config.
26
+ */
27
+
28
+ const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
29
+ const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
30
+
31
+ /**
32
+ * Conservative bot gate shared by every server-side emitter: only UA
33
+ * substrings that are unambiguously non-human (crawlers and CLI fetchers).
34
+ * Real browsers never match; an absent UA does NOT match (absence is not
35
+ * obvious, and losing real events costs more than relaying a stray
36
+ * programmatic call the backend's reputation flags catch).
37
+ */
38
+ const BOT_UA_SUBSTRINGS = ['bot', 'spider', 'crawl', 'curl', 'wget'];
39
+
40
+ /**
41
+ * The SDK's own `ShopTrackEvent` document, inlined: the generated client is
42
+ * WebSocket-only, so this module speaks plain HTTP GraphQL with the same shape.
43
+ */
44
+ const TRACK_EVENT_MUTATION = `mutation ShopTrackEvent($input: TrackEventInput!) {
45
+ trackEvent(input: $input) {
46
+ eventId
47
+ accepted
48
+ }
49
+ }`;
50
+
51
+ /** The channel-scoped upstream the storefront server talks to. */
52
+ export interface UpstreamConfig {
53
+ shopApiUrl: string;
54
+ channelToken: string;
55
+ }
56
+
57
+ /** The shop API's `TrackEventInput` — validated allowlist, never raw client JSON. */
58
+ export interface TrackEventInput {
59
+ eventType: string;
60
+ eventId?: string;
61
+ occurredAt?: string;
62
+ properties?: Record<string, unknown>;
63
+ utmSource?: string;
64
+ utmMedium?: string;
65
+ utmCampaign?: string;
66
+ utmTerm?: string;
67
+ utmContent?: string;
68
+ currencyCode?: string;
69
+ }
70
+
71
+ /** Per-request identity/enrichment headers forwarded upstream with one event. */
72
+ export interface ForwardHeaders {
73
+ /** Current shopper session riding as `Authorization: Bearer`, if any. */
74
+ sessionToken: string | null;
75
+ /** The browser's own User-Agent, forwarded for device enrichment. */
76
+ userAgent: string;
77
+ /**
78
+ * Client IP as `x-forwarded-for` (the proxy-standard contract the shop API
79
+ * trusts) — NEVER ForgeCart's edge-secret-gated client-IP override header
80
+ * (design doc S7): unsigned use would collapse per-IP rate limits onto the
81
+ * storefront server's IP and geo-seed wrong market/currency defaults.
82
+ */
83
+ forwardedFor: string | null;
84
+ }
85
+
86
+ export interface UpstreamOutcome {
87
+ accepted: boolean;
88
+ eventId: string | null;
89
+ /** Session token surfaced in the response extensions, if any. */
90
+ sessionToken: string | null;
91
+ }
92
+
93
+ interface GraphQLTrackResponse {
94
+ data?: { trackEvent?: { eventId?: string; accepted?: boolean } | null } | null;
95
+ errors?: unknown[];
96
+ extensions?: Record<string, unknown>;
97
+ }
98
+
99
+ /**
100
+ * The configured upstream, or `null` before `forgecart init` writes
101
+ * `.env` (the image-prewarm contract — callers must stay inert).
102
+ */
103
+ export function getUpstreamConfig(): UpstreamConfig | null {
104
+ if (!SHOP_API_URL || !CHANNEL_TOKEN) return null;
105
+ return { shopApiUrl: SHOP_API_URL, channelToken: CHANNEL_TOKEN };
106
+ }
107
+
108
+ /** True when the UA is unambiguously a crawler/CLI fetcher — skip emission. */
109
+ export function isObviousBot(userAgent: string): boolean {
110
+ const normalized = userAgent.toLowerCase();
111
+ return BOT_UA_SUBSTRINGS.some((substring) => normalized.includes(substring));
112
+ }
113
+
114
+ /**
115
+ * Forward one event as a raw HTTP GraphQL POST. Any failure — missing config,
116
+ * network, HTTP status, GraphQL errors — resolves to `accepted:false`; the
117
+ * forwarder is best-effort by design (analytics must never break the
118
+ * storefront) and `accepted:false` is terminal (callers never retry).
119
+ */
120
+ export async function forwardTrackEvent(
121
+ input: TrackEventInput,
122
+ { sessionToken, userAgent, forwardedFor }: ForwardHeaders,
123
+ ): Promise<UpstreamOutcome> {
124
+ const upstream = getUpstreamConfig();
125
+ if (!upstream) return { accepted: false, eventId: null, sessionToken: null };
126
+
127
+ const headers: Record<string, string> = {
128
+ 'content-type': 'application/json',
129
+ 'forgecart-token': upstream.channelToken,
130
+ };
131
+ if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
132
+ if (userAgent) headers['user-agent'] = userAgent;
133
+ if (forwardedFor) headers['x-forwarded-for'] = forwardedFor;
134
+
135
+ try {
136
+ const response = await fetch(upstream.shopApiUrl, {
137
+ method: 'POST',
138
+ headers,
139
+ body: JSON.stringify({ query: TRACK_EVENT_MUTATION, variables: { input } }),
140
+ });
141
+ if (!response.ok) return { accepted: false, eventId: null, sessionToken: null };
142
+
143
+ const payload = (await response.json()) as GraphQLTrackResponse;
144
+ const minted = payload.extensions?.['forgecart-auth-token'];
145
+ const capturedToken = typeof minted === 'string' && minted.length > 0 ? minted : null;
146
+ const trackEvent = payload.data?.trackEvent;
147
+ if (!trackEvent) return { accepted: false, eventId: null, sessionToken: capturedToken };
148
+ return {
149
+ accepted: trackEvent.accepted === true,
150
+ eventId: trackEvent.eventId ?? null,
151
+ sessionToken: capturedToken,
152
+ };
153
+ } catch {
154
+ // Best-effort by design: an unreachable/booting backend must never break
155
+ // the storefront, and `accepted:false` is terminal — no caller retries.
156
+ return { accepted: false, eventId: null, sessionToken: null };
157
+ }
158
+ }
@@ -0,0 +1,199 @@
1
+ import { NextResponse } from 'next/server';
2
+ import type { NextRequest } from 'next/server';
3
+ import { uuidv7 } from 'uuidv7';
4
+
5
+ /**
6
+ * Visitor-identity + forced-experiment-entry middleware (design doc S6 + F-B).
7
+ *
8
+ * S6 — visitor identity. Mints the durable anonymous visitor key — the
9
+ * `forgecart-visitor` cookie (UUIDv7, so ids sort by first-touch time) —
10
+ * exactly once per browser. It is the A/B-test subject key and a
11
+ * marketing-event property; it is deliberately NOT a session and NOT a
12
+ * marketing identity.
13
+ *
14
+ * Contract:
15
+ * - Mint ONLY when the cookie is absent. An identified visitor's response
16
+ * carries NO `Set-Cookie` at all — re-sending it on every response would
17
+ * make every page uncacheable for shared caches.
18
+ * - Zero fetches, zero environment reads: the workspace-pod prewarm boots
19
+ * this dev server with no `.env`, and the middleware must be inert
20
+ * to that (it never crashes a request, env or not).
21
+ * - Harmless on `<Link>` prefetches: they carry cookies like any navigation,
22
+ * so an identified visitor's prefetch passes straight through; page-view
23
+ * EMISSION stays in the client tracker precisely because prefetches reach
24
+ * middleware (design doc §H.1) — this file only persists the id.
25
+ * - Infra routes are excluded via the matcher: `/ping` (the pod readiness
26
+ * probe polls it forever — it must stay zero-overhead and byte-stable) and
27
+ * `/__forge_beacon` (error beacon; a browser that reaches it already has
28
+ * the cookie from the page), plus Next static internals.
29
+ *
30
+ * F-B — forced campaign entry. Campaign URLs carry
31
+ * `?fc-exp=<experimentKey>:<variantKey>[,<experimentKey>:<variantKey>…]`
32
+ * (at most {@link FORCED_URL_ENTRY_CAP} entries; both sides must match the
33
+ * backend's slug grammar). Valid entries MERGE into the `forgecart-exp-force`
34
+ * cookie — a JSON map `{ experimentKey: variantKey }`, httpOnly (server-read
35
+ * only), 30 days — and the request continues WITHOUT a redirect, so the
36
+ * campaign link stays shareable (the leak is the point). Whether a forced key
37
+ * actually overrides the hash is decided downstream by the assignment engine
38
+ * (`allowForcedEntry` + variant existence) — the middleware only persists.
39
+ *
40
+ * The merged map is ALSO written back onto this request's own `cookie` header
41
+ * (`NextResponse.next({ request })`), so the landing render itself already
42
+ * sees the forced variant — Set-Cookie alone would only reach the NEXT
43
+ * request. `getVariant()` still never relies on middleware→RSC cookie
44
+ * propagation for the VISITOR id (it reads-else-mints within a
45
+ * request-scoped cache); the forced map is different: it either rode in on
46
+ * the request cookie or is deterministically rewritten here.
47
+ */
48
+
49
+ const VISITOR_COOKIE_NAME = 'forgecart-visitor';
50
+
51
+ /** 400 days — Chrome's upper bound on cookie lifetime (RFC 6265bis). */
52
+ const VISITOR_COOKIE_MAX_AGE_SECONDS = 400 * 24 * 60 * 60;
53
+
54
+ /** Forced-entry URL param + cookie (design doc F-B). */
55
+ const FORCED_ENTRY_PARAM = 'fc-exp';
56
+ const FORCED_ENTRY_COOKIE_NAME = 'forgecart-exp-force';
57
+
58
+ /** 30 days — the campaign-entry window the design doc fixes. */
59
+ const FORCED_ENTRY_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
60
+
61
+ /** At most this many `exp:variant` entries are honored per URL (design doc). */
62
+ const FORCED_URL_ENTRY_CAP = 3;
63
+
64
+ /**
65
+ * At most this many experiments are retained in the merged cookie (oldest
66
+ * evicted first). At the slug grammar's 64-char ceiling per side that keeps
67
+ * the serialized JSON well under the 4096-byte browser cookie cap, so a
68
+ * hostile chain of campaign URLs can never grow the header unboundedly.
69
+ */
70
+ const FORCED_COOKIE_ENTRY_CAP = 12;
71
+
72
+ /**
73
+ * The backend's experiment/variant slug grammar
74
+ * (`ExperimentService`'s SLUG_REGEX) — both sides of every `exp:variant`
75
+ * entry must match it or the entry is dropped.
76
+ */
77
+ const SLUG_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
78
+
79
+ /**
80
+ * Parse the raw `?fc-exp=` value into at most {@link FORCED_URL_ENTRY_CAP}
81
+ * slug-valid `experimentKey → variantKey` entries (later duplicates of the
82
+ * same experiment win; invalid entries are dropped silently — a mangled
83
+ * campaign URL must still serve the page). `null` when nothing valid remains.
84
+ */
85
+ function parseForcedEntryParam(raw: string | null): Map<string, string> | null {
86
+ if (!raw) return null;
87
+ const entries = new Map<string, string>();
88
+ for (const item of raw.split(',')) {
89
+ const separatorAt = item.indexOf(':');
90
+ if (separatorAt === -1) continue;
91
+ const experimentKey = item.slice(0, separatorAt);
92
+ const variantKey = item.slice(separatorAt + 1);
93
+ if (!SLUG_REGEX.test(experimentKey) || !SLUG_REGEX.test(variantKey)) continue;
94
+ if (!entries.has(experimentKey) && entries.size >= FORCED_URL_ENTRY_CAP) continue;
95
+ entries.set(experimentKey, variantKey);
96
+ }
97
+ return entries.size > 0 ? entries : null;
98
+ }
99
+
100
+ /**
101
+ * Read the existing `forgecart-exp-force` cookie into slug-valid entries.
102
+ * Malformed JSON / shapes are discarded wholesale — the cookie is
103
+ * client-tamperable, and the next valid campaign URL rewrites it anyway.
104
+ */
105
+ function readForcedEntryCookie(request: NextRequest): Map<string, string> {
106
+ const entries = new Map<string, string>();
107
+ const raw = request.cookies.get(FORCED_ENTRY_COOKIE_NAME)?.value;
108
+ if (!raw) return entries;
109
+ let parsed: unknown;
110
+ try {
111
+ // `request.cookies` already percent-decodes — `raw` is the JSON text.
112
+ parsed = JSON.parse(raw);
113
+ } catch {
114
+ return entries;
115
+ }
116
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return entries;
117
+ for (const [experimentKey, variantKey] of Object.entries(parsed)) {
118
+ if (typeof variantKey !== 'string') continue;
119
+ if (!SLUG_REGEX.test(experimentKey) || !SLUG_REGEX.test(variantKey)) continue;
120
+ entries.set(experimentKey, variantKey);
121
+ }
122
+ return entries;
123
+ }
124
+
125
+ /**
126
+ * Merge URL entries over the stored map (URL wins; re-set keys move to the
127
+ * newest position) and evict oldest entries past {@link FORCED_COOKIE_ENTRY_CAP}.
128
+ * Returns the cookie-ready serialized value, or `null` when the URL carried
129
+ * no valid entry (the common case — zero cookie work on normal traffic).
130
+ */
131
+ function mergeForcedEntries(request: NextRequest): string | null {
132
+ const fromUrl = parseForcedEntryParam(request.nextUrl.searchParams.get(FORCED_ENTRY_PARAM));
133
+ if (!fromUrl) return null;
134
+ const merged = readForcedEntryCookie(request);
135
+ for (const [experimentKey, variantKey] of fromUrl) {
136
+ merged.delete(experimentKey);
137
+ merged.set(experimentKey, variantKey);
138
+ }
139
+ while (merged.size > FORCED_COOKIE_ENTRY_CAP) {
140
+ const oldest = merged.keys().next().value;
141
+ if (oldest === undefined) break;
142
+ merged.delete(oldest);
143
+ }
144
+ // RAW JSON on purpose: Next's cookie APIs percent-encode on serialize and
145
+ // decode on parse — symmetrically for the response Set-Cookie AND the
146
+ // request-header rewrite below — so pre-encoding here would double-encode.
147
+ return JSON.stringify(Object.fromEntries(merged));
148
+ }
149
+
150
+ export function middleware(request: NextRequest): NextResponse {
151
+ const forcedEntryValue = mergeForcedEntries(request);
152
+ const mintVisitor = !request.cookies.has(VISITOR_COOKIE_NAME);
153
+ if (!forcedEntryValue && !mintVisitor) {
154
+ return NextResponse.next();
155
+ }
156
+
157
+ let response: NextResponse;
158
+ if (forcedEntryValue) {
159
+ // Rewrite this request's own cookie header so the landing render already
160
+ // resolves the forced variant (Set-Cookie alone reaches only the NEXT
161
+ // request), then persist for subsequent navigations. No redirect — the
162
+ // campaign URL stays shareable.
163
+ request.cookies.set(FORCED_ENTRY_COOKIE_NAME, forcedEntryValue);
164
+ response = NextResponse.next({ request: { headers: request.headers } });
165
+ response.cookies.set({
166
+ name: FORCED_ENTRY_COOKIE_NAME,
167
+ value: forcedEntryValue,
168
+ httpOnly: true,
169
+ secure: true,
170
+ sameSite: 'lax',
171
+ maxAge: FORCED_ENTRY_COOKIE_MAX_AGE_SECONDS,
172
+ path: '/',
173
+ });
174
+ } else {
175
+ response = NextResponse.next();
176
+ }
177
+
178
+ if (mintVisitor) {
179
+ // `secure` is honored on https (deployed storefronts) and on localhost;
180
+ // browsers may drop it on other plain-http dev hosts — acceptable, since a
181
+ // stable visitor identity is a production concern, not a preview one.
182
+ response.cookies.set({
183
+ name: VISITOR_COOKIE_NAME,
184
+ value: uuidv7(),
185
+ httpOnly: true,
186
+ secure: true,
187
+ sameSite: 'lax',
188
+ maxAge: VISITOR_COOKIE_MAX_AGE_SECONDS,
189
+ path: '/',
190
+ });
191
+ }
192
+ return response;
193
+ }
194
+
195
+ export const config = {
196
+ // Every page and route EXCEPT Next internals/static assets and the infra
197
+ // routes documented above.
198
+ matcher: ['/((?!_next/static|_next/image|favicon.ico|ping|__forge_beacon).*)'],
199
+ };
@@ -1,128 +0,0 @@
1
- 'use client';
2
-
3
- import { ForgeCartShopClient } from '@forgecart/sdk';
4
- import { usePathname } from 'next/navigation';
5
- import { useEffect } from 'react';
6
-
7
- /**
8
- * Storefront analytics tracker. Renders nothing.
9
- *
10
- * Emits the marketing events that drive the dashboard's live-visitor count and
11
- * realtime map:
12
- *
13
- * - `page_view` on the initial load and on every App-Router route change;
14
- * - `heartbeat` every {@link HEARTBEAT_MS} while the tab is VISIBLE (plus one
15
- * immediately on refocus), so an idle-but-open tab stays inside the live
16
- * window. Heartbeats are liveness plumbing only — the backend excludes
17
- * them from every event-counting aggregate.
18
- *
19
- * Identity needs no code here: the SDK's WebSocket connect mints an anonymous
20
- * shop session server-side, captures the returned token, and persists it in
21
- * localStorage — so every event from this browser rides one identity, and the
22
- * server enriches geo/device from the connection's own IP/UA.
23
- *
24
- * Two deliberate suppressions:
25
- * - missing config (the pod image pre-renders the template before
26
- * `forgecart init` writes `.env.local`) → the tracker is inert;
27
- * - framed embeds (`window.parent !== window`) → the visual editor's
28
- * artboard preview never counts its own admin as a live visitor. A normal
29
- * top-level tab on the same URL still counts.
30
- *
31
- * Unlike `ForgeErrorBeacon` there is NO `NODE_ENV` gate — tracking is a
32
- * production feature. Every send is fire-and-forget and swallows failures:
33
- * analytics must never break the storefront.
34
- */
35
-
36
- const HEARTBEAT_MS = 60_000;
37
- /** Minimum spacing between sends of the same signal (StrictMode remounts, rapid refocus). */
38
- const DEDUP_MS = 30_000;
39
-
40
- let client: ForgeCartShopClient | null = null;
41
- let lastPageView: { path: string; at: number } | null = null;
42
- let lastHeartbeatAt = 0;
43
-
44
- function getClient(shopApiUrl: string, channelToken: string): ForgeCartShopClient {
45
- if (!client) {
46
- client = new ForgeCartShopClient({ endpoint: shopApiUrl, channelToken });
47
- }
48
- return client;
49
- }
50
-
51
- async function send(
52
- shopApiUrl: string,
53
- channelToken: string,
54
- eventType: string,
55
- properties?: Record<string, unknown>,
56
- ): Promise<void> {
57
- try {
58
- await getClient(shopApiUrl, channelToken).marketingEvent.shopTrackEvent({
59
- input: { eventType, properties },
60
- });
61
- } catch {
62
- // Best-effort by design: a failed send (offline, rate-limited, booting
63
- // backend) must never surface in the storefront.
64
- }
65
- }
66
-
67
- export function ForgeAnalytics({
68
- shopApiUrl,
69
- channelToken,
70
- }: {
71
- shopApiUrl: string;
72
- channelToken: string;
73
- }) {
74
- const pathname = usePathname();
75
-
76
- // page_view — initial load + every route change.
77
- useEffect(() => {
78
- if (!shopApiUrl || !channelToken) return;
79
- if (window.parent !== window) return;
80
- const now = Date.now();
81
- if (lastPageView && lastPageView.path === pathname && now - lastPageView.at < DEDUP_MS) {
82
- return;
83
- }
84
- lastPageView = { path: pathname, at: now };
85
- void send(shopApiUrl, channelToken, 'page_view', { path: pathname });
86
- }, [shopApiUrl, channelToken, pathname]);
87
-
88
- // heartbeat — while visible, plus one on refocus; paused while hidden.
89
- useEffect(() => {
90
- if (!shopApiUrl || !channelToken) return;
91
- if (window.parent !== window) return;
92
-
93
- let interval: ReturnType<typeof setInterval> | null = null;
94
-
95
- const beat = () => {
96
- const now = Date.now();
97
- if (now - lastHeartbeatAt < DEDUP_MS) return;
98
- lastHeartbeatAt = now;
99
- void send(shopApiUrl, channelToken, 'heartbeat');
100
- };
101
- const start = () => {
102
- if (interval !== null) return;
103
- interval = setInterval(beat, HEARTBEAT_MS);
104
- };
105
- const stop = () => {
106
- if (interval === null) return;
107
- clearInterval(interval);
108
- interval = null;
109
- };
110
- const onVisibility = () => {
111
- if (document.visibilityState === 'visible') {
112
- beat();
113
- start();
114
- } else {
115
- stop();
116
- }
117
- };
118
-
119
- document.addEventListener('visibilitychange', onVisibility);
120
- if (document.visibilityState === 'visible') start();
121
- return () => {
122
- document.removeEventListener('visibilitychange', onVisibility);
123
- stop();
124
- };
125
- }, [shopApiUrl, channelToken]);
126
-
127
- return null;
128
- }