@forgecart/cli 2.202610052310.0 → 2.202610060357.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,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "new-york",
|
|
4
|
+
"rsc": true,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "src/app/globals.css",
|
|
9
|
+
"baseColor": "neutral",
|
|
10
|
+
"cssVariables": true,
|
|
11
|
+
"prefix": ""
|
|
12
|
+
},
|
|
13
|
+
"aliases": {
|
|
14
|
+
"components": "@/components",
|
|
15
|
+
"ui": "@/components/ui",
|
|
16
|
+
"utils": "@/components/ui/utils",
|
|
17
|
+
"lib": "@/lib",
|
|
18
|
+
"hooks": "@/hooks"
|
|
19
|
+
},
|
|
20
|
+
"iconLibrary": "lucide"
|
|
21
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** @type {import('next').NextConfig} */
|
|
2
|
+
const nextConfig = {
|
|
3
|
+
// Produce a self-contained build so a channel workspace can run the
|
|
4
|
+
// storefront with `next start` without a full node_modules tree.
|
|
5
|
+
output: 'standalone',
|
|
6
|
+
reactStrictMode: true,
|
|
7
|
+
// The embedded NestJS backend (src/server/) must be required at runtime
|
|
8
|
+
// from node_modules, not bundled: Nest's core carries optional
|
|
9
|
+
// peer-dependency requires (microservices, platform adapters) that the
|
|
10
|
+
// bundler would otherwise chase into "module not found" errors.
|
|
11
|
+
// NOTE: externalizing Nest keeps it out of the NORMAL server bundle, but
|
|
12
|
+
// `src/instrumentation.ts` compiles under its own rules and statically
|
|
13
|
+
// resolves Nest's lazy `require('class-transformer')` /
|
|
14
|
+
// `require('class-validator')` (class-serializer + ValidationPipe). Those
|
|
15
|
+
// optional peers are therefore REAL dependencies in package.json — remove
|
|
16
|
+
// them and `next build` dies with Module-not-found on the Epinio staging
|
|
17
|
+
// path (CI run 31764049991, both attempts) while dev mode keeps working.
|
|
18
|
+
serverExternalPackages: ['@nestjs/core', '@nestjs/common'],
|
|
19
|
+
// The visual-editor preview runs THIS dev server inside a workspace pod,
|
|
20
|
+
// embedded cross-origin in the dashboard's editor iframe. Next 16 ENFORCES
|
|
21
|
+
// `allowedDevOrigins`: it 403s any cross-site request to an internal dev
|
|
22
|
+
// resource (`/_next`, `/__nextjs`, the HMR websocket) whose Origin/Referer
|
|
23
|
+
// host is not localhost/127.0.0.1 and not listed here — silently killing the
|
|
24
|
+
// dashboard iframe's dev channel with no page error. So this list must name
|
|
25
|
+
// every ORIGIN THAT EMBEDS the editor — i.e. each env's DASHBOARD host — not
|
|
26
|
+
// just the preview subdomain. GOTCHA: `*` matches exactly ONE label (Next's
|
|
27
|
+
// `matchWildcardDomain`), so `*.forgecart.dev` does NOT cover the two-label
|
|
28
|
+
// prod preview host `<code>.preview.forgecart.dev` — it must be listed
|
|
29
|
+
// explicitly. Dev-only — `next start` (deployed storefronts) ignores it.
|
|
30
|
+
allowedDevOrigins: [
|
|
31
|
+
'*.127.0.0.1.nip.io', // LOCAL preview ingress (k3s Traefik :8081) — the host every local pod serves under; without it Next dev 403s the store's own /_next chunks locally (2026-08-13; the wm real-next-app fixture pins the same contract)
|
|
32
|
+
'*.vm.forgecart.com', // `pnpm vm` Cloudflare-tunnel dashboards
|
|
33
|
+
'*.dev.forgecart.dev', // dev dashboard + dev preview subdomains
|
|
34
|
+
'*.forgecart.dev', // single-label deployed preview hosts
|
|
35
|
+
'*.preview.forgecart.dev', // two-label prod preview host (`*.forgecart.dev` can't match it)
|
|
36
|
+
'*.preview.test.forgecart.com', // test-env preview subdomains (plain-http zone; .dev is HSTS-preloaded)
|
|
37
|
+
'dashboard.test.forgecart.com', // test dashboard origin that embeds the editor
|
|
38
|
+
'dashboard.forgecart.com', // prod dashboard origin that embeds the editor
|
|
39
|
+
],
|
|
40
|
+
// CACHE COMPONENTS: OFF, explicitly — and the explicitness is the point.
|
|
41
|
+
//
|
|
42
|
+
// The root layout reads the request locale (`x-forgecart-locale`) to set
|
|
43
|
+
// `<html lang>`, which is an attribute on the document root and therefore
|
|
44
|
+
// structurally outside every Suspense boundary. Under Cache Components that
|
|
45
|
+
// combination is not buildable: `headers()` returns a hanging promise, and a
|
|
46
|
+
// component aborted with no Suspense frame in its stack raises `blocking-route`
|
|
47
|
+
// → `StaticGenBailoutError`. The only escape Next offers is a `<Suspense>`
|
|
48
|
+
// ABOVE `<body>` — in its own words, "an explicit signal from the user that
|
|
49
|
+
// they acknowledge the empty shell".
|
|
50
|
+
//
|
|
51
|
+
// Taking that escape would cost the locale contract entirely. A real 308/404
|
|
52
|
+
// is assigned only when the render promise REJECTS (`renderToStream`'s catch);
|
|
53
|
+
// with a boundary above, React resolves as CLIENT_RENDERED instead, so
|
|
54
|
+
// `notFound()` / `permanentRedirect()` from the layout would emit a 200
|
|
55
|
+
// carrying a 404 page — a soft-404, indexed rather than dropped, which is the
|
|
56
|
+
// exact failure this storefront's locale routing exists to prevent.
|
|
57
|
+
//
|
|
58
|
+
// Nothing is given up by turning it off: the escape hatch yields an empty
|
|
59
|
+
// prelude on every route anyway (identical TTFB), and the template uses no
|
|
60
|
+
// `use cache`. The per-route Suspense boundaries below still stream their
|
|
61
|
+
// holes — that is core SSR, not a Cache Components feature.
|
|
62
|
+
//
|
|
63
|
+
// Written as an explicit `false` rather than omitted: `enforceExperimentalFeatures`
|
|
64
|
+
// re-enables an UNDEFINED value ("we do respect an explicit value in the user
|
|
65
|
+
// config"), so on a future 16.x under this package's caret range a deleted key
|
|
66
|
+
// would silently restore the failure above — in the merchant's scaffold, far
|
|
67
|
+
// from this repo's CI. An explicit false is immune.
|
|
68
|
+
cacheComponents: false,
|
|
69
|
+
experimental: {
|
|
70
|
+
// Persist Turbopack's dev compile artifacts to `.next` so a pod can serve
|
|
71
|
+
// a PRE-WARMED `.next` baked at build time instead of paying a cold first
|
|
72
|
+
// compile. The cache is path-keyed, so it is only valid when warmed at the
|
|
73
|
+
// SAME absolute path the pod serves from (/workspace) — see the workspace-pod
|
|
74
|
+
// Dockerfile `storefront-prewarm` stage and the STOREFRONT materialization
|
|
75
|
+
// initContainer (serve-in-place; never the /opt→/workspace copy). Default-on
|
|
76
|
+
// in Next 16.1+; set explicitly to document the contract.
|
|
77
|
+
turbopackFileSystemCacheForDev: true,
|
|
78
|
+
},
|
|
79
|
+
// Dev-only `data-fc-source` stamping for the ForgeCart visual editor. The
|
|
80
|
+
// loader marks host JSX elements with their source file:line:col so the
|
|
81
|
+
// dashboard can map a sprayed region back to code. It runs under Turbopack
|
|
82
|
+
// before SWC — with no `as` field, Turbopack chains loader -> built-in SWC,
|
|
83
|
+
// so the stamped TSX is still compiled normally.
|
|
84
|
+
//
|
|
85
|
+
// GLOB GOTCHA (Next 15.5): Turbopack matches a rule key by FILENAME unless it
|
|
86
|
+
// contains `/` (then by full project-relative path), and it does NOT expand
|
|
87
|
+
// braces. The earlier `src/**/*.{tsx,jsx}` therefore matched nothing and the
|
|
88
|
+
// loader silently never ran (`data-fc-source` count: 0). Per-extension
|
|
89
|
+
// filename globs are what Turbopack honors. The loader self-gates on
|
|
90
|
+
// NODE_ENV === 'development', so `next build` (webpack, production) is
|
|
91
|
+
// untouched.
|
|
92
|
+
turbopack: {
|
|
93
|
+
rules: {
|
|
94
|
+
'*.tsx': { loaders: ['@forgecart/designer-runtime/turbo-loader'] },
|
|
95
|
+
'*.jsx': { loaders: ['@forgecart/designer-runtime/turbo-loader'] },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
images: {
|
|
99
|
+
// The shop API serves product/asset previews from arbitrary hosts, so
|
|
100
|
+
// allow any remote image. Tighten this to your asset host in production.
|
|
101
|
+
remotePatterns: [{ protocol: 'https', hostname: '**' }],
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
module.exports = nextConfig;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "storefront",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev --turbopack",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@forgecart/designer-runtime": "^1.202607161748.0",
|
|
12
|
+
"@forgecart/sdk": "^1.202609282317.0",
|
|
13
|
+
"@nestjs/common": "^11.1.14",
|
|
14
|
+
"@nestjs/core": "^11.1.14",
|
|
15
|
+
"@radix-ui/react-label": "^2.1.7",
|
|
16
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
17
|
+
"class-transformer": "^0.5.1",
|
|
18
|
+
"class-validator": "^0.14.2",
|
|
19
|
+
"class-variance-authority": "^0.7.1",
|
|
20
|
+
"clsx": "^2.1.1",
|
|
21
|
+
"next": "^16.1.0",
|
|
22
|
+
"react": "^19.0.0",
|
|
23
|
+
"react-dom": "^19.0.0",
|
|
24
|
+
"reflect-metadata": "^0.2.2",
|
|
25
|
+
"rxjs": "^7.8.2",
|
|
26
|
+
"server-only": "^0.0.1",
|
|
27
|
+
"tailwind-merge": "^3.5.0",
|
|
28
|
+
"uuidv7": "^1.1.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@tailwindcss/postcss": "^4.2.1",
|
|
32
|
+
"@types/node": "^22.10.0",
|
|
33
|
+
"@types/react": "^19.0.0",
|
|
34
|
+
"@types/react-dom": "^19.0.0",
|
|
35
|
+
"postcss": "^8.4.49",
|
|
36
|
+
"tailwindcss": "^4.2.1",
|
|
37
|
+
"typescript": "^5.7.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import type { NextRequest } from 'next/server';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
forwardMarketingIdentifiers,
|
|
6
|
+
type MarketingIdentifierInput,
|
|
7
|
+
} from '../../../lib/identify-forward';
|
|
8
|
+
import { CLICK_ID_VALUE_MAX_LENGTH } from '../../../lib/marketing-params';
|
|
9
|
+
import { SESSION_COOKIE, establishSessionCookies } from '../../../lib/session-cookies';
|
|
10
|
+
import { getUpstreamConfig, isObviousBot } from '../../../lib/track-forward';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Same-origin marketing-identity relay (#1596).
|
|
14
|
+
*
|
|
15
|
+
* A shopper arriving from an ad carries the platform's CLICK ID in the landing
|
|
16
|
+
* URL and nowhere else — no cookie, no header, and gone the moment they reach a
|
|
17
|
+
* second page. `lib/marketing-params.ts` reads it on the client, the landing
|
|
18
|
+
* page POSTs what it found here, and this handler forwards the batch to the
|
|
19
|
+
* ForgeCart shop API as one `setMarketingIdentifiers` mutation.
|
|
20
|
+
*
|
|
21
|
+
* The upstream hop itself (raw HTTP GraphQL with the channel token, the shopper
|
|
22
|
+
* session as Bearer, and every failure resolving to an empty outcome instead of
|
|
23
|
+
* a throw) lives in `src/lib/identify-forward.ts`. This route owns everything
|
|
24
|
+
* request-shaped: parsing untrusted JSON, per-identifier validation, the bot
|
|
25
|
+
* gate, session threading and cookie persistence.
|
|
26
|
+
*
|
|
27
|
+
* Sibling by construction: `../track/route.ts` relays EVENTS over the same
|
|
28
|
+
* transport, through the same gates, onto the same session cookie PAIR (both
|
|
29
|
+
* routes write it through `src/lib/session-cookies.ts`) — read the two
|
|
30
|
+
* together, and change them together. They stay separate routes because a
|
|
31
|
+
* click ID is not an event: it names the IDENTITY the events belong to, it is
|
|
32
|
+
* sent once per landing rather than once per batch, and the ad platforms read
|
|
33
|
+
* it back out of that identity months later when a conversion is reported.
|
|
34
|
+
*
|
|
35
|
+
* Inert guard: before `forgecart init` writes `.env` (the image-build prewarm)
|
|
36
|
+
* there is no shop to talk to, so every submitted key answers `rejected` with
|
|
37
|
+
* zero upstream calls and the server never crashes.
|
|
38
|
+
*
|
|
39
|
+
* Session capture: the shop API surfaces a freshly minted session in the
|
|
40
|
+
* response `extensions` exactly once. The relay may ESTABLISH the shopper
|
|
41
|
+
* identity — persisting that mint into BOTH homes of the session pair, the
|
|
42
|
+
* httpOnly `forgecart-session` cookie and its JS-readable mirror
|
|
43
|
+
* `forgecart-session-client` (`src/lib/session-cookies.ts`) — when the request
|
|
44
|
+
* arrived cookie-less, but it never REPLACES an existing cookie. The asymmetry
|
|
45
|
+
* is the sibling's, for the sibling's reason: an incoming cookie is either a
|
|
46
|
+
* live session owned by the cart path (clobbering it would vanish a
|
|
47
|
+
* just-created cart) or a stale one, whose replacement is the cart path's job.
|
|
48
|
+
*
|
|
49
|
+
* Writing both homes matters most HERE (#1733). On an ad landing this route
|
|
50
|
+
* runs before anything else — `ForgeTracker` awaits it before enqueuing the
|
|
51
|
+
* first page_view — so it is what establishes the session the click ID has just
|
|
52
|
+
* been attached to. Write the httpOnly half alone and the shopper's own socket
|
|
53
|
+
* boots unauthenticated, its first cart operation mints a RIVAL session, and
|
|
54
|
+
* the click ID stays on an identity the settled order never resolves to: the
|
|
55
|
+
* attribution this route exists to make possible, lost behind a 200. Presence
|
|
56
|
+
* is still read from the httpOnly copy alone — it is the authoritative one, and
|
|
57
|
+
* the mirror is client-writable.
|
|
58
|
+
*
|
|
59
|
+
* Folder name: `%5F%5Ffc` is URL-encoded `__fc` — the App Router treats
|
|
60
|
+
* `_`-prefixed folders as PRIVATE (excluded from routing), and the `%5F` escape
|
|
61
|
+
* is Next's documented way to serve a literal-underscore URL segment. A folder
|
|
62
|
+
* literally named `__fc` would silently 404.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The largest identifier list this route will look at.
|
|
67
|
+
*
|
|
68
|
+
* A landing page sends at most one entry per click ID it recognises: three
|
|
69
|
+
* today (`lib/marketing-params.ts`), six registered backend-side. Sixteen is
|
|
70
|
+
* headroom for every registration that could plausibly be added without
|
|
71
|
+
* admitting a list no client of ours would ever send — past it the caller is
|
|
72
|
+
* not our page, and the request is refused whole rather than trimmed, because
|
|
73
|
+
* silently keeping a prefix of somebody else's batch is the worse answer.
|
|
74
|
+
*/
|
|
75
|
+
const MAX_IDENTIFIERS = 16;
|
|
76
|
+
|
|
77
|
+
interface IdentifyResponseBody {
|
|
78
|
+
/** Identifier keys the backend recognised and stored. */
|
|
79
|
+
accepted: readonly string[];
|
|
80
|
+
/**
|
|
81
|
+
* Keys the backend does not know — a NORMAL outcome, never an error. The
|
|
82
|
+
* upstream partitions the submitted keys against its identifier registry,
|
|
83
|
+
* stores the ones it knows and reports the rest; nothing was rolled back and
|
|
84
|
+
* nothing may be retried. A key landing here means only that this storefront
|
|
85
|
+
* and the backend registry disagree about a spelling, which is a deployment
|
|
86
|
+
* fact worth being able to read.
|
|
87
|
+
*/
|
|
88
|
+
rejected: readonly string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
92
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Every answer this route gives is the same pair of key lists. */
|
|
96
|
+
function respond(accepted: readonly string[], rejected: readonly string[]): NextResponse {
|
|
97
|
+
const body: IdentifyResponseBody = { accepted, rejected };
|
|
98
|
+
return NextResponse.json(body);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Parse the request body into a bounded identifier array, or `null` when malformed. */
|
|
102
|
+
async function parseIdentifiers(request: NextRequest): Promise<unknown[] | null> {
|
|
103
|
+
let body: unknown;
|
|
104
|
+
try {
|
|
105
|
+
body = await request.json();
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (!isPlainObject(body) || !Array.isArray(body.identifiers)) return null;
|
|
110
|
+
if (body.identifiers.length === 0 || body.identifiers.length > MAX_IDENTIFIERS) return null;
|
|
111
|
+
return body.identifiers;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validate one raw entry into a `MarketingIdentifierInput`, or `null` when it
|
|
116
|
+
* is not a usable pair. The route rebuilds the pair from untrusted JSON rather
|
|
117
|
+
* than trusting anything the client typed, so only these two fields cross.
|
|
118
|
+
*
|
|
119
|
+
* Both strings share the click-ID bound. The key is looked up in a registry
|
|
120
|
+
* whose entries are short words, so its cap is a garbage fence rather than a
|
|
121
|
+
* limit anyone can reach honestly — and a second number for the same purpose
|
|
122
|
+
* would be one more thing to keep in step, for no gain. The value's bound is
|
|
123
|
+
* the one that matters and is deliberately generous: Meta's `fbclid` routinely
|
|
124
|
+
* runs past 128 characters, and a click ID short by one character matches
|
|
125
|
+
* nothing at the platform that issued it.
|
|
126
|
+
*/
|
|
127
|
+
function validateIdentifier(raw: unknown): MarketingIdentifierInput | null {
|
|
128
|
+
if (!isPlainObject(raw)) return null;
|
|
129
|
+
const { key, value } = raw;
|
|
130
|
+
if (typeof key !== 'string' || key.length === 0 || key.length > CLICK_ID_VALUE_MAX_LENGTH) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > CLICK_ID_VALUE_MAX_LENGTH) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return { key, value };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
140
|
+
const submitted = await parseIdentifiers(request);
|
|
141
|
+
if (!submitted) {
|
|
142
|
+
return NextResponse.json({ error: 'malformed identifiers' }, { status: 400 });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Validation runs BEFORE the gates here, unlike the event sibling: its reject
|
|
146
|
+
// shape is positional (one result per submitted item) and can be built from
|
|
147
|
+
// the raw batch, while this route answers with KEYS — which exist only once
|
|
148
|
+
// an entry has been proven to carry one.
|
|
149
|
+
const identifiers: MarketingIdentifierInput[] = [];
|
|
150
|
+
for (const entry of submitted) {
|
|
151
|
+
const identifier = validateIdentifier(entry);
|
|
152
|
+
// A malformed entry is DROPPED, never a 400: one bad pair must not cost a
|
|
153
|
+
// shopper the attribution the others carry. It is then absent from both
|
|
154
|
+
// answer lists rather than reported as rejected, so `rejected` keeps its
|
|
155
|
+
// single meaning — "the upstream does not know this key" — and can never be
|
|
156
|
+
// read as "you sent junk".
|
|
157
|
+
if (identifier) identifiers.push(identifier);
|
|
158
|
+
}
|
|
159
|
+
// Nothing survived validation: answer the empty verdict without an upstream
|
|
160
|
+
// call. Forwarding an empty list would mint a session for a request that has
|
|
161
|
+
// nothing to store against it.
|
|
162
|
+
if (identifiers.length === 0) return respond([], []);
|
|
163
|
+
|
|
164
|
+
const rejectAll = (): NextResponse => respond([], identifiers.map((identifier) => identifier.key));
|
|
165
|
+
|
|
166
|
+
// Inert before `forgecart init` writes `.env` (image-prewarm contract).
|
|
167
|
+
if (!getUpstreamConfig()) return rejectAll();
|
|
168
|
+
|
|
169
|
+
const userAgent = request.headers.get('user-agent') ?? '';
|
|
170
|
+
if (isObviousBot(userAgent)) return rejectAll();
|
|
171
|
+
|
|
172
|
+
const incomingSession = request.cookies.get(SESSION_COOKIE)?.value ?? null;
|
|
173
|
+
const forwardedFor = request.headers.get('x-forwarded-for');
|
|
174
|
+
// #1014: mirror the browser's low-entropy Client Hints trio upstream —
|
|
175
|
+
// Chromium sends them on every request; the shop API's device identification
|
|
176
|
+
// prefers them over the frozen UA.
|
|
177
|
+
const secChUa = request.headers.get('sec-ch-ua') ?? undefined;
|
|
178
|
+
const secChUaMobile = request.headers.get('sec-ch-ua-mobile') ?? undefined;
|
|
179
|
+
const secChUaPlatform = request.headers.get('sec-ch-ua-platform') ?? undefined;
|
|
180
|
+
|
|
181
|
+
// One call for the whole list: the mutation takes an array and the backend
|
|
182
|
+
// writes it against a single identity, so there is no per-item sequencing to
|
|
183
|
+
// get wrong here (the event relay loops only because `trackEvent` takes one
|
|
184
|
+
// event at a time).
|
|
185
|
+
const outcome = await forwardMarketingIdentifiers(identifiers, {
|
|
186
|
+
sessionToken: incomingSession,
|
|
187
|
+
userAgent,
|
|
188
|
+
forwardedFor,
|
|
189
|
+
secChUa,
|
|
190
|
+
secChUaMobile,
|
|
191
|
+
secChUaPlatform,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const response = respond(outcome.accepted, outcome.rejected);
|
|
195
|
+
// Establish-only persist: write the session ONLY when the request arrived
|
|
196
|
+
// without one, and then into BOTH of its homes — the httpOnly original the
|
|
197
|
+
// server reads and the mirror the shopper's own socket boots from. A mint
|
|
198
|
+
// against an EXISTING cookie is never persisted here — replacing a live
|
|
199
|
+
// session would vanish the cart it holds, and replacing a stale one belongs
|
|
200
|
+
// to the cart path (see the module docstring).
|
|
201
|
+
if (!incomingSession && outcome.sessionToken) {
|
|
202
|
+
establishSessionCookies(response, outcome.sessionToken);
|
|
203
|
+
}
|
|
204
|
+
return response;
|
|
205
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import type { NextRequest } from 'next/server';
|
|
3
|
+
|
|
4
|
+
import { SESSION_COOKIE, establishSessionCookies } from '../../../lib/session-cookies';
|
|
5
|
+
import {
|
|
6
|
+
forwardTrackEvent,
|
|
7
|
+
getUpstreamConfig,
|
|
8
|
+
isObviousBot,
|
|
9
|
+
type TrackEventInput,
|
|
10
|
+
} from '../../../lib/track-forward';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Same-origin marketing-event relay (design doc S7).
|
|
14
|
+
*
|
|
15
|
+
* `ForgeTracker` batches client events (page_view, cta_click, heartbeat, …)
|
|
16
|
+
* and POSTs them here; this handler forwards each item to the ForgeCart shop
|
|
17
|
+
* API as a `trackEvent` GraphQL mutation. Unlike `__forge_beacon` this route
|
|
18
|
+
* is a PRODUCTION feature — tracking runs on deployed storefronts — with one
|
|
19
|
+
* inert guard: before `forgecart init` writes `.env` (the image-build
|
|
20
|
+
* prewarm) there is no shop to talk to, so every item answers `accepted:false`
|
|
21
|
+
* without any upstream call and the server never crashes.
|
|
22
|
+
*
|
|
23
|
+
* The upstream hop itself (raw HTTP GraphQL POST with the channel token, the
|
|
24
|
+
* shopper session as Bearer, UA + `x-forwarded-for` enrichment, and the
|
|
25
|
+
* why-not-the-SDK rationale) lives in `src/lib/track-forward.ts` — shared with
|
|
26
|
+
* the F-B `experiment_exposure` emission so both server-side emitters speak
|
|
27
|
+
* one contract. This route owns everything batch-shaped: parsing, per-item
|
|
28
|
+
* validation, the bot gate, session threading, and cookie persistence.
|
|
29
|
+
*
|
|
30
|
+
* Session capture: the shop API surfaces a freshly minted session in the
|
|
31
|
+
* response `extensions` (`forgecart-auth-token`) exactly once; the first
|
|
32
|
+
* touched item captures it and the remaining items of the batch ride it as
|
|
33
|
+
* Bearer (one identity per batch). The relay may ESTABLISH the shopper
|
|
34
|
+
* identity — persisting that mint into BOTH homes of the session pair, the
|
|
35
|
+
* httpOnly `forgecart-session` cookie and its JS-readable mirror
|
|
36
|
+
* `forgecart-session-client`, through the contract `src/lib/session-cookies.ts`
|
|
37
|
+
* owns and `src/lib/session-actions.ts#syncShopSession` writes too — but it
|
|
38
|
+
* never REPLACES an existing cookie. The asymmetry is deliberate: an incoming
|
|
39
|
+
* cookie that the upstream re-mints against is either a live session owned by
|
|
40
|
+
* the cart path (clobbering it would vanish a just-created cart — the
|
|
41
|
+
* first-touch double-mint race) or a stale one, whose replacement is the cart
|
|
42
|
+
* path's job (the SDK adopts re-mints and `syncShopSession` persists them; the
|
|
43
|
+
* relay replacing it here would race those writers).
|
|
44
|
+
*
|
|
45
|
+
* Both homes or neither (#1733): the browser's shop socket boots its auth from
|
|
46
|
+
* the MIRROR (`lib/shop-session.ts`), so a session established into the httpOnly
|
|
47
|
+
* half alone never reaches the client at all — the first cart operation mints a
|
|
48
|
+
* RIVAL session, `syncShopSession` overwrites the pair with it, and the events
|
|
49
|
+
* this batch just recorded stay on a session no attribution read resolves to.
|
|
50
|
+
* Presence, in contrast, is read from the httpOnly copy ALONE: it is the
|
|
51
|
+
* authoritative one, the mirror is derived from it, and a client-writable cookie
|
|
52
|
+
* must not be able to decide whether this relay establishes at all.
|
|
53
|
+
*
|
|
54
|
+
* Residual window: two writers that BOTH start cookie-less inside the same
|
|
55
|
+
* in-flight window remain last-writer-wins. With the tracker's deferred first
|
|
56
|
+
* flush (`ForgeTracker`'s establishment grace) that requires a click-to-
|
|
57
|
+
* response race under ~100ms — accepted.
|
|
58
|
+
*
|
|
59
|
+
* Contract: per-item isolation (one upstream failure never kills the batch),
|
|
60
|
+
* `accepted:false` is TERMINAL for that item (the tracker never retries), and
|
|
61
|
+
* obviously non-human user agents short-circuit the whole batch with zero
|
|
62
|
+
* upstream calls.
|
|
63
|
+
*
|
|
64
|
+
* Folder name: `%5F%5Ffc` is URL-encoded `__fc` — the App Router treats
|
|
65
|
+
* `_`-prefixed folders as PRIVATE (excluded from routing), and the `%5F`
|
|
66
|
+
* escape is Next's documented way to serve a literal-underscore URL segment.
|
|
67
|
+
* A folder literally named `__fc` would silently 404.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/** The tracker flushes at ≤10 events; anything past this is not our client. */
|
|
71
|
+
const MAX_BATCH_SIZE = 50;
|
|
72
|
+
|
|
73
|
+
/** Canonical textual UUID — the only client `eventId` shape the API accepts. */
|
|
74
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
75
|
+
|
|
76
|
+
interface TrackItemResult {
|
|
77
|
+
accepted: boolean;
|
|
78
|
+
eventId: string | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
82
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Parse the request body into a bounded batch array, or `null` when malformed. */
|
|
86
|
+
async function parseBatch(request: NextRequest): Promise<unknown[] | null> {
|
|
87
|
+
let body: unknown;
|
|
88
|
+
try {
|
|
89
|
+
body = await request.json();
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (!isPlainObject(body) || !Array.isArray(body.events)) return null;
|
|
94
|
+
if (body.events.length === 0 || body.events.length > MAX_BATCH_SIZE) return null;
|
|
95
|
+
return body.events;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Copy an optional string field from the raw item, rejecting non-string junk. */
|
|
99
|
+
function readOptionalString(value: unknown): string | undefined {
|
|
100
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Validate one raw batch item into a `TrackEventInput`, or `null` when the
|
|
105
|
+
* item is malformed (missing/empty `eventType`, non-UUID `eventId`, non-object
|
|
106
|
+
* `properties`). Only allowlisted fields cross to the upstream call.
|
|
107
|
+
*/
|
|
108
|
+
function validateItem(raw: unknown): TrackEventInput | null {
|
|
109
|
+
if (!isPlainObject(raw)) return null;
|
|
110
|
+
const { eventType, eventId, properties } = raw;
|
|
111
|
+
if (typeof eventType !== 'string' || eventType.length === 0) return null;
|
|
112
|
+
if (eventId !== undefined && (typeof eventId !== 'string' || !UUID_PATTERN.test(eventId))) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
if (properties !== undefined && !isPlainObject(properties)) return null;
|
|
116
|
+
|
|
117
|
+
const input: TrackEventInput = { eventType };
|
|
118
|
+
if (typeof eventId === 'string') input.eventId = eventId;
|
|
119
|
+
const occurredAt = readOptionalString(raw.occurredAt);
|
|
120
|
+
if (occurredAt) input.occurredAt = occurredAt;
|
|
121
|
+
if (isPlainObject(properties)) input.properties = properties;
|
|
122
|
+
const utmSource = readOptionalString(raw.utmSource);
|
|
123
|
+
if (utmSource) input.utmSource = utmSource;
|
|
124
|
+
const utmMedium = readOptionalString(raw.utmMedium);
|
|
125
|
+
if (utmMedium) input.utmMedium = utmMedium;
|
|
126
|
+
const utmCampaign = readOptionalString(raw.utmCampaign);
|
|
127
|
+
if (utmCampaign) input.utmCampaign = utmCampaign;
|
|
128
|
+
const utmTerm = readOptionalString(raw.utmTerm);
|
|
129
|
+
if (utmTerm) input.utmTerm = utmTerm;
|
|
130
|
+
const utmContent = readOptionalString(raw.utmContent);
|
|
131
|
+
if (utmContent) input.utmContent = utmContent;
|
|
132
|
+
const currencyCode = readOptionalString(raw.currencyCode);
|
|
133
|
+
if (currencyCode) input.currencyCode = currencyCode;
|
|
134
|
+
return input;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
138
|
+
const batch = await parseBatch(request);
|
|
139
|
+
if (!batch) {
|
|
140
|
+
return NextResponse.json({ error: 'malformed batch' }, { status: 400 });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const rejectAll = (): NextResponse =>
|
|
144
|
+
NextResponse.json({
|
|
145
|
+
results: batch.map((): TrackItemResult => ({ accepted: false, eventId: null })),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Inert before `forgecart init` writes `.env` (image-prewarm contract).
|
|
149
|
+
if (!getUpstreamConfig()) return rejectAll();
|
|
150
|
+
|
|
151
|
+
const userAgent = request.headers.get('user-agent') ?? '';
|
|
152
|
+
if (isObviousBot(userAgent)) return rejectAll();
|
|
153
|
+
|
|
154
|
+
const incomingSession = request.cookies.get(SESSION_COOKIE)?.value ?? null;
|
|
155
|
+
const forwardedFor = request.headers.get('x-forwarded-for');
|
|
156
|
+
// #1014: mirror the browser's low-entropy Client Hints trio upstream —
|
|
157
|
+
// Chromium sends them on every request; the shop API's device
|
|
158
|
+
// identification prefers them over the frozen UA.
|
|
159
|
+
const secChUa = request.headers.get('sec-ch-ua') ?? undefined;
|
|
160
|
+
const secChUaMobile = request.headers.get('sec-ch-ua-mobile') ?? undefined;
|
|
161
|
+
const secChUaPlatform = request.headers.get('sec-ch-ua-platform') ?? undefined;
|
|
162
|
+
|
|
163
|
+
let sessionToken = incomingSession;
|
|
164
|
+
const results: TrackItemResult[] = [];
|
|
165
|
+
// Sequential on purpose: the first touched item mints the session and every
|
|
166
|
+
// later item of the batch must ride it — parallel sends would mint one
|
|
167
|
+
// identity per item and shatter attribution.
|
|
168
|
+
for (const raw of batch) {
|
|
169
|
+
const input = validateItem(raw);
|
|
170
|
+
if (!input) {
|
|
171
|
+
results.push({ accepted: false, eventId: null });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const outcome = await forwardTrackEvent(input, { sessionToken, userAgent, forwardedFor, secChUa, secChUaMobile, secChUaPlatform });
|
|
175
|
+
results.push({ accepted: outcome.accepted, eventId: outcome.eventId });
|
|
176
|
+
if (outcome.sessionToken) sessionToken = outcome.sessionToken;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const response = NextResponse.json({ results });
|
|
180
|
+
// Establish-only persist: write the session ONLY when the request arrived
|
|
181
|
+
// without one — and then into BOTH of its homes, which is all
|
|
182
|
+
// `establishSessionCookies` does. A mint against an EXISTING cookie is never
|
|
183
|
+
// persisted here — replacing a live session would vanish the cart it holds
|
|
184
|
+
// (first-touch double-mint race), and replacing a stale one belongs to the
|
|
185
|
+
// cart path, whose SDK capture + persist owns re-mints (see the module
|
|
186
|
+
// docstring).
|
|
187
|
+
if (!incomingSession && sessionToken) establishSessionCookies(response, sessionToken);
|
|
188
|
+
return response;
|
|
189
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DEV-ONLY same-origin error-beacon forwarder.
|
|
3
|
+
*
|
|
4
|
+
* The storefront's client beacon (`ForgeErrorBeacon`) POSTs a browser-surfaced
|
|
5
|
+
* runtime error here — same-origin, so the browser never learns the pod-internal
|
|
6
|
+
* receiver address. This handler forwards the body SERVER-side to the in-pod
|
|
7
|
+
* workspace-manager receiver at `http://127.0.0.1:<FORGE_BEACON_PORT>/__forge_beacon`,
|
|
8
|
+
* which folds it into the supervisor's runtime state so the shop's recovery brain
|
|
9
|
+
* can originate a fix for a crash the dev-server's stderr scan cannot see.
|
|
10
|
+
*
|
|
11
|
+
* Gated entirely on `NODE_ENV === 'development'`: a deployed `next start` storefront
|
|
12
|
+
* answers 404 here and forwards nothing — the whole beacon path ships only in the
|
|
13
|
+
* in-pod dev-server. Request access keeps it out of any static prerender so it
|
|
14
|
+
* behaves identically under `next dev` and a dev build.
|
|
15
|
+
*
|
|
16
|
+
* Folder name: `%5F%5Fforge_beacon` is URL-encoded `__forge_beacon`. The App Router
|
|
17
|
+
* treats a folder whose name starts with `_` as PRIVATE and leaves it out of routing
|
|
18
|
+
* entirely, so the literal spelling this route shipped with answered the not-found
|
|
19
|
+
* page to every POST — silently, because the client swallows delivery failures by
|
|
20
|
+
* design (#2103). The `%5F` escape is Next's documented way to serve a URL segment
|
|
21
|
+
* that really starts with an underscore, and only the FOLDER may change: the URL is
|
|
22
|
+
* a contract with the in-pod receiver (`StorefrontBeaconService`'s `BEACON_PATH`),
|
|
23
|
+
* which this template's server half (`src/instrumentation.ts`) posts to as well.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const BEACON_PORT = process.env.FORGE_BEACON_PORT ?? '3002';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The revision the reporting page was SERVED at, lifted out of the forwarded
|
|
30
|
+
* body for the log line below, or `null` when the report is untagged.
|
|
31
|
+
*
|
|
32
|
+
* Reads the body it is ALREADY forwarding verbatim rather than re-deriving
|
|
33
|
+
* anything: the value was stamped into the document at render time, and this
|
|
34
|
+
* handler is a forwarder, not a second opinion. The parse is guarded because a
|
|
35
|
+
* malformed body must not change what this route does — the forward still
|
|
36
|
+
* happens and the client still gets its 204, exactly as before. `JSON.parse` is
|
|
37
|
+
* the one throw on this path and it carries no code to classify, which is why it
|
|
38
|
+
* is caught here and nowhere else (`instrumentation.ts` swallows its own boot
|
|
39
|
+
* failure in the same template for the same reason); judging the payload stays
|
|
40
|
+
* the receiver's job.
|
|
41
|
+
*/
|
|
42
|
+
function servedRevision(body: string): string | null {
|
|
43
|
+
try {
|
|
44
|
+
const parsed: unknown = JSON.parse(body);
|
|
45
|
+
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
46
|
+
const value = (parsed as { atRevision?: unknown }).atRevision;
|
|
47
|
+
if (typeof value !== 'string' || value.length === 0) return null;
|
|
48
|
+
return value;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function POST(request: Request): Promise<Response> {
|
|
55
|
+
if (process.env.NODE_ENV !== 'development') {
|
|
56
|
+
return new Response(null, { status: 404 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const body = await request.text();
|
|
60
|
+
// Make the dev-server's output ring revision-attributable (#847): this line is
|
|
61
|
+
// written to the ring the supervisor retains and `TailServerLogs` serves, so a
|
|
62
|
+
// human or the recovery brain reading the ring can tell WHICH tree a
|
|
63
|
+
// browser-surfaced crash belongs to instead of guessing from timing.
|
|
64
|
+
//
|
|
65
|
+
// It deliberately carries the revision and NOTHING ELSE — no message, no stack,
|
|
66
|
+
// no route. The ambient scanner matches dev-server output case-insensitively
|
|
67
|
+
// against RUNTIME_ERROR_PATTERNS ('error:', 'typeerror', 'unhandled', …;
|
|
68
|
+
// `runtime-error-scanner.types.ts`), so echoing the reported message here would
|
|
69
|
+
// put the crash text INTO the stream that scanner reads and fold a second,
|
|
70
|
+
// independent ambient error for the very report already on its way to the
|
|
71
|
+
// receiver — a self-inflicted double-fire. An untagged report stays silent
|
|
72
|
+
// rather than logging a placeholder, which keeps today's output byte-identical
|
|
73
|
+
// for every producer that does not stamp (the server `onRequestError` path
|
|
74
|
+
// never reaches this route at all — it POSTs the receiver directly).
|
|
75
|
+
const revision = servedRevision(body);
|
|
76
|
+
if (revision !== null) console.log(`[forge-beacon browser rev=${revision}]`);
|
|
77
|
+
// Forward server-side to the loopback receiver. A delivery failure is swallowed —
|
|
78
|
+
// the beacon is a best-effort backstop, never a hard dependency of the page — but
|
|
79
|
+
// the client still gets a clean 204 so it never retries against a flapping pod.
|
|
80
|
+
await fetch(`http://127.0.0.1:${BEACON_PORT}/__forge_beacon`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'content-type': 'application/json' },
|
|
83
|
+
body,
|
|
84
|
+
}).catch(() => undefined);
|
|
85
|
+
|
|
86
|
+
return new Response(null, { status: 204 });
|
|
87
|
+
}
|