@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,151 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// The /shop subpath is the ONLY SDK entry browser code may import: the SDK
|
|
4
|
+
// compiles to CommonJS (never tree-shaken), and only the /shop module graph
|
|
5
|
+
// is guaranteed admin-free — the root barrel would bundle the admin client
|
|
6
|
+
// into every shopper's browser (the pod image build greps chunks for exactly
|
|
7
|
+
// that and fails).
|
|
8
|
+
import { ForgeCartShopClient, extractError } from '@forgecart/sdk/shop';
|
|
9
|
+
|
|
10
|
+
import { UNREACHABLE_ERROR, type ActionResult } from './action-result';
|
|
11
|
+
import { syncShopSession } from './session-actions';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The shopper's OWN shop websocket — the client half of the progressive
|
|
15
|
+
* stack. Sockets are session-based, so the session socket lives where the
|
|
16
|
+
* session lives: in the browser, one long-lived client per tab. The server
|
|
17
|
+
* never builds session-bearing sockets (its two clients are the anonymous
|
|
18
|
+
* channel singleton in `forgecart.ts` and the embedded backend's admin
|
|
19
|
+
* singleton); it only prefetches public data and mirrors the session cookie.
|
|
20
|
+
*
|
|
21
|
+
* Lifecycle:
|
|
22
|
+
* - `initShopSession(config)` (the CartProvider mount) arms the singleton
|
|
23
|
+
* with the channel coordinates; the socket itself opens lazily on the
|
|
24
|
+
* first operation and the SDK's `lazyCloseTimeout` parks it when idle.
|
|
25
|
+
* - The client boots its auth from the JS-readable mirror cookie
|
|
26
|
+
* (`forgecart-session-client`), so a returning shopper's socket adopts
|
|
27
|
+
* the same session SSR saw.
|
|
28
|
+
* - After every operation, a newly-captured session token (the SDK reads
|
|
29
|
+
* it from result extensions on the first mutation) is synced back into
|
|
30
|
+
* BOTH cookies via the `syncShopSession` server action — the next SSR
|
|
31
|
+
* pass sees the cart this socket built.
|
|
32
|
+
*
|
|
33
|
+
* Every operation returns the same `ActionResult` envelope the old Server
|
|
34
|
+
* Actions used, so calling components keep their error contract: typed
|
|
35
|
+
* in-band errors, never a throw.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export interface ShopSessionConfig {
|
|
39
|
+
apiUrl: string;
|
|
40
|
+
channelToken: string;
|
|
41
|
+
/** The request's resolved locale — the session speaks the page's language. */
|
|
42
|
+
languageCode: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SESSION_MIRROR_COOKIE = 'forgecart-session-client';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Browser-readable mirror of the httpOnly `forgecart-visitor` cookie,
|
|
49
|
+
* minted by the middleware (#1079 P1). Its value rides the SDK's
|
|
50
|
+
* `forgecart-visitor` WS connection-params header so visitor-subject
|
|
51
|
+
* experiment goals stamp server-side.
|
|
52
|
+
*/
|
|
53
|
+
const VISITOR_MIRROR_COOKIE = 'forgecart-visitor-client';
|
|
54
|
+
|
|
55
|
+
let config: ShopSessionConfig | null = null;
|
|
56
|
+
let client: ForgeCartShopClient | null = null;
|
|
57
|
+
let syncedToken: string | null = null;
|
|
58
|
+
|
|
59
|
+
/** Read a JS-visible mirror cookie (null outside the browser or when absent). */
|
|
60
|
+
function readMirrorCookie(name: string): string | null {
|
|
61
|
+
if (typeof document === 'undefined') return null;
|
|
62
|
+
const entry = document.cookie
|
|
63
|
+
.split('; ')
|
|
64
|
+
.find((candidate) => candidate.startsWith(`${name}=`));
|
|
65
|
+
if (!entry) return null;
|
|
66
|
+
const value = decodeURIComponent(entry.slice(name.length + 1));
|
|
67
|
+
return value.length > 0 ? value : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Read the session mirror cookie (null outside the browser or when absent). */
|
|
71
|
+
export function readMirrorSession(): string | null {
|
|
72
|
+
return readMirrorCookie(SESSION_MIRROR_COOKIE);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Arm the singleton with the channel coordinates (idempotent; provider mount).
|
|
77
|
+
*
|
|
78
|
+
* A locale switch is a full document load — the switcher uses plain anchors
|
|
79
|
+
* precisely so the language cannot go stale — which resets this module and
|
|
80
|
+
* rebuilds the client at the new language. The re-sync below covers the
|
|
81
|
+
* remaining case: a client-side navigation that somehow changes the resolved
|
|
82
|
+
* locale would otherwise leave a live socket speaking the previous language
|
|
83
|
+
* for the rest of the session. `setLanguageCode` disposes and re-handshakes
|
|
84
|
+
* the socket, so it is called ONLY on an actual change.
|
|
85
|
+
*/
|
|
86
|
+
export function initShopSession(next: ShopSessionConfig): void {
|
|
87
|
+
const previous = config;
|
|
88
|
+
config = next;
|
|
89
|
+
if (client && previous && previous.languageCode !== next.languageCode) {
|
|
90
|
+
client.setLanguageCode(next.languageCode);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether the session socket can exist (configured scaffold + browser). */
|
|
95
|
+
export function isSessionReady(): boolean {
|
|
96
|
+
return config !== null && typeof window !== 'undefined';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ensureClient(): ForgeCartShopClient {
|
|
100
|
+
if (!config) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
'Shop session not initialized — CartProvider must mount with shopConfig first.',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (!client) {
|
|
106
|
+
client = new ForgeCartShopClient({
|
|
107
|
+
endpoint: config.apiUrl,
|
|
108
|
+
channelToken: config.channelToken,
|
|
109
|
+
// Fixed at handshake — the cart, checkout and every error message on
|
|
110
|
+
// this socket answer in the language the URL resolved to.
|
|
111
|
+
languageCode: config.languageCode,
|
|
112
|
+
// The visitor mirror is minted by the middleware before any page can
|
|
113
|
+
// mount the provider, so it is present on every real navigation; when
|
|
114
|
+
// it is somehow absent the client simply sends no visitor header and
|
|
115
|
+
// visitor-subject goals stay unstamped (never guess — #1079).
|
|
116
|
+
visitorId: readMirrorCookie(VISITOR_MIRROR_COOKIE) ?? undefined,
|
|
117
|
+
});
|
|
118
|
+
const existing = readMirrorSession();
|
|
119
|
+
if (existing) {
|
|
120
|
+
client.setAuthToken(existing);
|
|
121
|
+
syncedToken = existing;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return client;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Run one session operation on the shopper's socket and return the typed
|
|
129
|
+
* envelope. Session capture: when the operation minted or rotated the
|
|
130
|
+
* session, the new token is synced into the cookies fire-and-forget —
|
|
131
|
+
* a sync failure never breaks the shopper's interaction.
|
|
132
|
+
*/
|
|
133
|
+
export async function runSessionOp<T>(
|
|
134
|
+
op: (client: ForgeCartShopClient) => Promise<T>,
|
|
135
|
+
): Promise<ActionResult<T>> {
|
|
136
|
+
if (!isSessionReady()) {
|
|
137
|
+
return { ok: false, error: UNREACHABLE_ERROR };
|
|
138
|
+
}
|
|
139
|
+
const live = ensureClient();
|
|
140
|
+
try {
|
|
141
|
+
const data = await op(live);
|
|
142
|
+
const token = live.getAuthToken();
|
|
143
|
+
if (token && token !== syncedToken) {
|
|
144
|
+
syncedToken = token;
|
|
145
|
+
void syncShopSession(token).catch(() => undefined);
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, data };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return { ok: false, error: extractError(error) ?? UNREACHABLE_ERROR };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
* The browser's low-entropy Client Hints (`sec-ch-ua`, `sec-ch-ua-mobile`,
|
|
79
|
+
* `sec-ch-ua-platform`), mirrored verbatim so device identification can
|
|
80
|
+
* outrank the frozen UA per-field (#1014). Chromium sends the trio on
|
|
81
|
+
* every request — no handshake, no `Accept-CH` — so this is a pure
|
|
82
|
+
* pass-through; absent on non-Chromium browsers.
|
|
83
|
+
*/
|
|
84
|
+
secChUa?: string;
|
|
85
|
+
secChUaMobile?: string;
|
|
86
|
+
secChUaPlatform?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Client IP as `x-forwarded-for` (the proxy-standard contract the shop API
|
|
89
|
+
* trusts) — NEVER ForgeCart's edge-secret-gated client-IP override header
|
|
90
|
+
* (design doc S7): unsigned use would collapse per-IP rate limits onto the
|
|
91
|
+
* storefront server's IP and geo-seed wrong market/currency defaults.
|
|
92
|
+
*/
|
|
93
|
+
forwardedFor: string | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface UpstreamOutcome {
|
|
97
|
+
accepted: boolean;
|
|
98
|
+
eventId: string | null;
|
|
99
|
+
/** Session token surfaced in the response extensions, if any. */
|
|
100
|
+
sessionToken: string | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface GraphQLTrackResponse {
|
|
104
|
+
data?: { trackEvent?: { eventId?: string; accepted?: boolean } | null } | null;
|
|
105
|
+
errors?: unknown[];
|
|
106
|
+
extensions?: Record<string, unknown>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The configured upstream, or `null` before `forgecart init` writes
|
|
111
|
+
* `.env` (the image-prewarm contract — callers must stay inert).
|
|
112
|
+
*/
|
|
113
|
+
export function getUpstreamConfig(): UpstreamConfig | null {
|
|
114
|
+
if (!SHOP_API_URL || !CHANNEL_TOKEN) return null;
|
|
115
|
+
return { shopApiUrl: SHOP_API_URL, channelToken: CHANNEL_TOKEN };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** True when the UA is unambiguously a crawler/CLI fetcher — skip emission. */
|
|
119
|
+
export function isObviousBot(userAgent: string): boolean {
|
|
120
|
+
const normalized = userAgent.toLowerCase();
|
|
121
|
+
return BOT_UA_SUBSTRINGS.some((substring) => normalized.includes(substring));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
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.
|
|
132
|
+
*/
|
|
133
|
+
export function buildUpstreamHeaders(
|
|
134
|
+
channelToken: string,
|
|
135
|
+
{ sessionToken, userAgent, forwardedFor, secChUa, secChUaMobile, secChUaPlatform }: ForwardHeaders,
|
|
136
|
+
): Record<string, string> {
|
|
137
|
+
const headers: Record<string, string> = {
|
|
138
|
+
'content-type': 'application/json',
|
|
139
|
+
'forgecart-token': channelToken,
|
|
140
|
+
};
|
|
141
|
+
if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
|
|
142
|
+
if (userAgent) headers['user-agent'] = userAgent;
|
|
143
|
+
if (forwardedFor) headers['x-forwarded-for'] = forwardedFor;
|
|
144
|
+
if (secChUa) headers['sec-ch-ua'] = secChUa;
|
|
145
|
+
if (secChUaMobile) headers['sec-ch-ua-mobile'] = secChUaMobile;
|
|
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 };
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const response = await fetch(upstream.shopApiUrl, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: buildUpstreamHeaders(upstream.channelToken, forward),
|
|
182
|
+
body: JSON.stringify({ query: TRACK_EVENT_MUTATION, variables: { input } }),
|
|
183
|
+
});
|
|
184
|
+
if (!response.ok) return { accepted: false, eventId: null, sessionToken: null };
|
|
185
|
+
|
|
186
|
+
const payload = (await response.json()) as GraphQLTrackResponse;
|
|
187
|
+
const capturedToken = readMintedSessionToken(payload.extensions);
|
|
188
|
+
const trackEvent = payload.data?.trackEvent;
|
|
189
|
+
if (!trackEvent) return { accepted: false, eventId: null, sessionToken: capturedToken };
|
|
190
|
+
return {
|
|
191
|
+
accepted: trackEvent.accepted === true,
|
|
192
|
+
eventId: trackEvent.eventId ?? null,
|
|
193
|
+
sessionToken: capturedToken,
|
|
194
|
+
};
|
|
195
|
+
} catch {
|
|
196
|
+
// Best-effort by design: an unreachable/booting backend must never break
|
|
197
|
+
// the storefront, and `accepted:false` is terminal — no caller retries.
|
|
198
|
+
return { accepted: false, eventId: null, sessionToken: null };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4 UUID that works in EVERY browsing context this storefront serves from.
|
|
3
|
+
*
|
|
4
|
+
* `crypto.randomUUID` is secure-context gated: it exists on https origins and
|
|
5
|
+
* on localhost, but NOT on plain-http LAN/preview hosts — exactly the local
|
|
6
|
+
* k3s preview origin (`http://<code>.127.0.0.1.nip.io:8081`) the dev recipe
|
|
7
|
+
* and the dashboard-e2e forge suite drive. A bare call there is a TypeError
|
|
8
|
+
* mid-render (the checkout AddressStep crash, 2026-08-09). `getRandomValues`
|
|
9
|
+
* carries no such gate, so mint the same v4 shape by hand when the fast path
|
|
10
|
+
* is missing.
|
|
11
|
+
*/
|
|
12
|
+
export function mintUuid(): string {
|
|
13
|
+
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
|
14
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
15
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
16
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
17
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
18
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
19
|
+
}
|