@forgecart/cli 2.202609220738.0 → 2.202609240732.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.
@@ -25,12 +25,55 @@
25
25
 
26
26
  const BEACON_PORT = process.env.FORGE_BEACON_PORT ?? '3002';
27
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
+
28
54
  export async function POST(request: Request): Promise<Response> {
29
55
  if (process.env.NODE_ENV !== 'development') {
30
56
  return new Response(null, { status: 404 });
31
57
  }
32
58
 
33
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}]`);
34
77
  // Forward server-side to the loopback receiver. A delivery failure is swallowed —
35
78
  // the beacon is a best-effort backstop, never a hard dependency of the page — but
36
79
  // the client still gets a clean 204 so it never retries against a flapping pod.
@@ -10,7 +10,9 @@ import { LocaleLink } from '../components/LocaleLink';
10
10
  import { CartProvider } from '../lib/cart-context';
11
11
  import { getRequestLocale } from '../lib/locale/request-binding';
12
12
  import { shellMetadata } from '../lib/seo/metadata';
13
+ import { getSiteVerifications } from '../lib/seo/site-verification';
13
14
  import { getBrowserShopConfig } from '../lib/shop-config';
15
+ import { FORGE_REVISION_META, resolveLiveRevision } from '../server/forge/live-revision';
14
16
 
15
17
  import './globals.css';
16
18
 
@@ -34,10 +36,43 @@ import './globals.css';
34
36
  * It reads the SAME resolution the layout body reads below — React's request
35
37
  * cache makes `getRequestLocale()` one channel read per request no matter how
36
38
  * many callers there are, so the floor costs nothing extra.
39
+ *
40
+ * The site-verification placements (#1530) are read on the same request cache
41
+ * and handed to the SAME composer, so `lib/seo/metadata.ts` stays the only
42
+ * thing in this template that turns a placement into a `<meta>`: the
43
+ * alternative — a hand-written tag in the markup below — would be a second
44
+ * emitter with its own idea of when a placement is servable. They ride the
45
+ * FLOOR rather than a route, because the verifier chooses which page it fetches
46
+ * and a tag on only some of them is a verification that works until it does not.
47
+ *
48
+ * It also carries the DEV-ONLY revision stamp (#847). The tag has to be written
49
+ * where the page is RENDERED, because that is the only moment that knows which
50
+ * tree the shopper is actually looking at: the browser beacon reads it back at
51
+ * throw time, so a promote landing between serve and throw can no longer make
52
+ * the report blame the new revision for the old one's crash. It rides
53
+ * `generateMetadata` rather than a `<meta>` in the JSX below because arbitrary
54
+ * head tags are what `other` is for, and the shell's own metadata contract then
55
+ * stays untouched — the stamp is spread ON TOP of `shellMetadata`'s answer and
56
+ * adds a key no SEO surface reads.
57
+ *
58
+ * Dev-gated at this call site, like every other file in the beacon path: the
59
+ * literal `NODE_ENV` check is what lets a production build eliminate the
60
+ * resolution entirely, so a deployed storefront emits no tag and leaks no
61
+ * revision. Unresolvable is a NORMAL answer and means no tag — the report then
62
+ * arrives untagged and the receiver keeps its existing behaviour.
37
63
  */
38
64
  export async function generateMetadata(): Promise<Metadata> {
39
65
  const { shopName, channelResolved } = await getRequestLocale();
40
- return shellMetadata({ shopName, channelResolved });
66
+ const siteVerifications = await getSiteVerifications();
67
+ const shell = shellMetadata({ shopName, channelResolved, siteVerifications });
68
+ if (process.env.NODE_ENV !== 'development') return shell;
69
+ const revision = resolveLiveRevision();
70
+ if (revision === null) return shell;
71
+ // MERGED into the shell's `other`, never written over it: that key carries the
72
+ // site-verification placements (#1530), and a storefront pod serves through
73
+ // `npm run dev`, so an overwrite here would strip the provider's tag from
74
+ // every page its verifier can fetch.
75
+ return { ...shell, other: { ...shell.other, [FORGE_REVISION_META]: revision } };
41
76
  }
42
77
 
43
78
  /**
@@ -23,6 +23,14 @@ import { useEffect } from 'react';
23
23
  * the page's own error boundary still renders. The full
24
24
  * browser → API route → pod → supervisor-fold path is exercised at the e2e layer,
25
25
  * not in this template.
26
+ *
27
+ * Every report carries the revision the PAGE WAS SERVED AT (#847), read out of the
28
+ * `forge-revision` meta tag the server wrote into this document rather than looked
29
+ * up fresh at throw time. That distinction is the entire point: a fresh lookup would
30
+ * answer whatever is live NOW, so a promote landing between serve and throw would
31
+ * make this report blame the new revision for the old one's crash — and the pod's
32
+ * same-revision fold would then mark a healthy tree DEGRADED. The tag cannot drift,
33
+ * because it was rendered with the markup that broke.
26
34
  */
27
35
  export function ForgeErrorBeacon() {
28
36
  useEffect(() => {
@@ -30,12 +38,35 @@ export function ForgeErrorBeacon() {
30
38
  return;
31
39
  }
32
40
 
41
+ /**
42
+ * The revision this document was SERVED at, or `undefined` when the server
43
+ * did not stamp one (production, or a pod that cannot resolve it — both
44
+ * normal). `'forge-revision'` is repeated here as a literal on purpose: the
45
+ * resolver lives in `src/server/forge/live-revision.ts`, which a client
46
+ * component must not import, so this is the same repeated-contract shape the
47
+ * `/__forge_beacon` URL already uses across this feature's producers.
48
+ *
49
+ * An absent tag, an absent attribute and an empty value all answer
50
+ * `undefined`, which `JSON.stringify` then omits from the body entirely —
51
+ * so an unstamped page sends exactly the payload it sent before, and the pod
52
+ * keeps its existing untagged behaviour instead of receiving a blank string
53
+ * it would have to interpret.
54
+ */
55
+ const readServedRevision = (): string | undefined => {
56
+ const content = document
57
+ .querySelector('meta[name="forge-revision"]')
58
+ ?.getAttribute('content');
59
+ if (!content) return undefined;
60
+ return content;
61
+ };
62
+
33
63
  const send = (message: string, stack?: string) => {
34
64
  const body = JSON.stringify({
35
65
  message,
36
66
  stack,
37
67
  route: window.location.pathname,
38
68
  source: 'browser',
69
+ atRevision: readServedRevision(),
39
70
  });
40
71
  // Same-origin POST; keepalive lets it survive a navigation/unload. A failed
41
72
  // delivery is intentionally swallowed — the beacon is a backstop signal, not
@@ -27,6 +27,11 @@
27
27
  * The hook NEVER throws out of itself: a failed delivery is swallowed (the beacon is a
28
28
  * best-effort backstop, never a hard dependency of the request), so a flapping receiver
29
29
  * can never mask or replace the underlying request error Next is already reporting.
30
+ *
31
+ * The error's own `code` rides along verbatim — a fact, never a verdict: a request
32
+ * that races a recompile evaluates Turbopack's stub for a module that no longer parses
33
+ * and throws `code: 'MODULE_UNPARSABLE'`, and the receiver reads that code to leave a
34
+ * BUILD failure to the build detectors instead of folding it as a render crash.
30
35
  */
31
36
 
32
37
  const BEACON_PORT = process.env.FORGE_BEACON_PORT ?? '3002';
@@ -86,10 +91,11 @@ export async function onRequestError(
86
91
  return;
87
92
  }
88
93
 
89
- const err = error as { message?: string; digest?: string } | undefined;
94
+ const err = error as { message?: string; digest?: string; code?: unknown } | undefined;
90
95
  const body = JSON.stringify({
91
96
  message: typeof err?.message === 'string' ? err.message : String(error),
92
97
  digest: typeof err?.digest === 'string' ? err.digest : undefined,
98
+ code: typeof err?.code === 'string' ? err.code : undefined,
93
99
  route: request.path,
94
100
  routeType: context.routeType,
95
101
  source: 'server',
@@ -39,11 +39,42 @@ import { getPublicOrigin } from './public-origin';
39
39
  * whose fields mean the opposite is a defect waiting to be written.
40
40
  */
41
41
 
42
+ /**
43
+ * One site-verification placement, narrowed to what a `<meta>` tag needs
44
+ * (#1530).
45
+ *
46
+ * A structural subset of the SDK's `SeoSiteVerification`, for the same reason
47
+ * `SeoMetaRow` narrows `SeoMetaLanguage`: this module has to stay reachable
48
+ * from the template-spec project, which imports it by relative path and so
49
+ * cannot follow anything that pulls in `next` or the SDK. The request-time read
50
+ * that produces these lives in `site-verification.ts`, which can.
51
+ *
52
+ * `name` is the PROVIDER's own, always. A storefront that spelled one vendor's
53
+ * meta name into its source would serve that vendor and silently ignore every
54
+ * other one the merchant connects — and would have to be edited again for the
55
+ * next, which is precisely the thing a descriptor exists to avoid.
56
+ */
57
+ export interface SiteVerificationMeta {
58
+ name: string;
59
+ content: string;
60
+ }
61
+
62
+ /**
63
+ * Bare `<meta name content>` pairs, keyed by name.
64
+ *
65
+ * The value is a LIST because Next emits one tag per element: two placements
66
+ * that happen to share a name — the same provider under two accounts — both
67
+ * reach the document, where a plain string would have kept whichever was folded
68
+ * last and dropped the other without a trace.
69
+ */
70
+ export type SiteVerificationTags = Record<string, string[]>;
71
+
42
72
  /** The floor the root layout emits — no route, so no route-specific claims. */
43
73
  export interface ShellMetadata {
44
74
  title: string;
45
75
  description: string;
46
76
  robots?: RobotsDirective;
77
+ other?: SiteVerificationTags;
47
78
  }
48
79
 
49
80
  /**
@@ -76,11 +107,17 @@ export interface PageMetadata {
76
107
  alternates?: Alternates;
77
108
  openGraph?: OpenGraphMetadata;
78
109
  twitter?: TwitterMetadata;
110
+ other?: SiteVerificationTags;
79
111
  }
80
112
 
81
113
  export interface ShellMetadataInput extends DeploymentPosture {
82
114
  /** The merchant's shop name, or null/empty when unset. */
83
115
  shopName?: string | null;
116
+ /**
117
+ * What the channel's SEO providers need this deployment to serve (#1530),
118
+ * read per request by `site-verification.ts`. Absent or empty emits nothing.
119
+ */
120
+ siteVerifications?: readonly SiteVerificationMeta[];
84
121
  }
85
122
 
86
123
  export interface MetadataInput extends DeploymentPosture {
@@ -105,6 +142,18 @@ export interface MetadataInput extends DeploymentPosture {
105
142
  socialImage?: string | null;
106
143
  /** The content's own indexability — see `noindex.ts`. */
107
144
  contentIndexable?: boolean;
145
+ /**
146
+ * The same placements the shell emits (#1530) — for a route that has a
147
+ * reason of its own to set `other`.
148
+ *
149
+ * Next merges a page's metadata OVER the layout's one key at a time, so a
150
+ * page that sets `other` at all REPLACES the floor's, verification tags
151
+ * included. Passing them here is how such a route keeps them. Every route
152
+ * this template ships sets no `other` and therefore passes nothing, and
153
+ * inherits the shell's — absent means inherit, exactly as it does for
154
+ * `description` and `robots` above.
155
+ */
156
+ siteVerifications?: readonly SiteVerificationMeta[];
108
157
  }
109
158
 
110
159
  /**
@@ -202,12 +251,14 @@ export function buildShellMetadata({
202
251
  shopName,
203
252
  publicOrigin,
204
253
  channelResolved,
254
+ siteVerifications,
205
255
  }: ShellMetadataInput): ShellMetadata {
206
256
  const refuses = deploymentRefusesIndexing({ publicOrigin, channelResolved });
207
257
  return {
208
258
  title: pageTitle(null, shopName),
209
259
  description: SITE_DESCRIPTION,
210
260
  ...(refuses ? { robots: NOINDEX_ROBOTS } : {}),
261
+ ...verificationTags(siteVerifications),
211
262
  };
212
263
  }
213
264
 
@@ -250,6 +301,7 @@ export function buildMetadata(input: MetadataInput): PageMetadata {
250
301
  ...(noindex ? { robots: NOINDEX_ROBOTS } : {}),
251
302
  ...(alternates === undefined ? {} : { alternates }),
252
303
  ...socialTags({ ...input, title, description, alternates }),
304
+ ...verificationTags(input.siteVerifications),
253
305
  };
254
306
  }
255
307
 
@@ -321,3 +373,35 @@ function socialTags({
321
373
  twitter: { card: 'summary_large_image', ...shared },
322
374
  };
323
375
  }
376
+
377
+ /**
378
+ * The verification tags, or nothing at all (#1530).
379
+ *
380
+ * The SINGLE place in this template where a placement becomes a `<meta>`, and
381
+ * both emitters above route through it — the shell, which is what every route
382
+ * inherits, and a page that sets `other` for its own reasons and would
383
+ * otherwise replace the shell's. Two spellings of one tag is how a document
384
+ * ends up serving a token the platform has already re-minted.
385
+ *
386
+ * Deliberately NOT Next's `metadata.verification` block. Every key in it is
387
+ * named after one named vendor, so which key a token landed in would be a
388
+ * decision this template makes about which provider the merchant uses — and a
389
+ * provider without a key of its own could not be served at all. `other` takes
390
+ * the name from the descriptor, which is the whole point of there being a
391
+ * descriptor.
392
+ *
393
+ * An empty list emits no `other` KEY rather than an empty object, for the
394
+ * reason stated above `buildMetadata`: a key present with a falsy value still
395
+ * wins Next's merge, so a page with no placements would blank the shell's.
396
+ */
397
+ function verificationTags(
398
+ siteVerifications: readonly SiteVerificationMeta[] | undefined,
399
+ ): Pick<PageMetadata, 'other'> {
400
+ if (siteVerifications === undefined || siteVerifications.length === 0) return {};
401
+
402
+ const other: SiteVerificationTags = {};
403
+ for (const { name, content } of siteVerifications) {
404
+ other[name] = [...(other[name] ?? []), content];
405
+ }
406
+ return { other };
407
+ }
@@ -0,0 +1,98 @@
1
+ import 'server-only';
2
+
3
+ import { cache } from 'react';
4
+
5
+ import { getShopClient } from '../forgecart';
6
+ import type { SiteVerificationMeta } from './metadata';
7
+
8
+ /**
9
+ * What the channel's SEO providers need this storefront to serve (#1530).
10
+ *
11
+ * A provider proves the merchant owns this address by asking them to place a
12
+ * token on it. The platform mints that token per account and per origin, and
13
+ * this read is the storefront's half of the handshake: it asks the shop API
14
+ * what to serve, and `metadata.ts` turns each descriptor into one
15
+ * `<meta name content>` tag. Nothing here knows which provider minted what —
16
+ * the NAME arrives in the descriptor, so a second provider is a server change
17
+ * and no template change at all.
18
+ *
19
+ * Only `META` placements are served. The mechanism rides in the descriptor
20
+ * precisely so that a provider verifying some other way — a DNS record, a file
21
+ * at a path this template does not route — cannot have its token silently
22
+ * rendered as a meta tag, which would be a placement that looks made and is
23
+ * not.
24
+ *
25
+ * PER REQUEST, and no further. React's `cache()` collapses the callers of one
26
+ * render into a single read; there is deliberately no TTL and no process memo
27
+ * above it. A verifier makes ONE fetch, and a token re-minted while a replica
28
+ * still serves a cached copy of the old one is a verification that fails with
29
+ * every part of the system reporting health — the same reason
30
+ * `sitemap-cache.ts`'s window is safe (a stale URL list costs a crawl) and one
31
+ * here would not be.
32
+ *
33
+ * Failure is ALWAYS an empty list, never a throw. This read happens inside the
34
+ * root layout's `generateMetadata`, structurally outside every Suspense
35
+ * boundary (D8), so a throw here is a 500 on every route of the store — and
36
+ * what is lost instead is one meta tag whose absence the platform already
37
+ * detects and retries on its next sync. An unconfigured scaffold reaches the
38
+ * same arm: `getShopClient()` throws when `.env` carries no coordinates, and
39
+ * the prewarm contract says a storefront with no configuration must SERVE.
40
+ */
41
+
42
+ /** The answer whenever there is nothing honest to serve. */
43
+ const NONE: readonly SiteVerificationMeta[] = [];
44
+
45
+ /**
46
+ * The one mechanism this template can satisfy — a tag in the document it
47
+ * renders. Compared against the descriptor's own value rather than assumed.
48
+ */
49
+ const META_METHOD = 'META';
50
+
51
+ /**
52
+ * How long the document shell may wait for this read.
53
+ *
54
+ * The same bound, for the same reason, as the channel read in
55
+ * `locale/channel-locales-loader.ts`: the shell has no Suspense boundary above
56
+ * it, so an unbounded wait holds the whole document open with nothing to time
57
+ * it out. Past this the storefront serves without the tag, which is the lesser
58
+ * of the two failures by a wide margin.
59
+ */
60
+ const VERIFICATION_READ_TIMEOUT_MS = 2_000;
61
+
62
+ /**
63
+ * The placements this storefront must serve, for THIS request.
64
+ *
65
+ * Read through the generated SDK like every other shop-API call in the
66
+ * template — there are no hand-written query documents here, which is what
67
+ * keeps the storefront's reads and the API's schema from drifting apart.
68
+ *
69
+ * The request's own client is used rather than one pinned to the channel
70
+ * default: the answer is the same in every language, so the cheaper of two
71
+ * correct options wins, and that is the socket this render already has open.
72
+ */
73
+ export const getSiteVerifications = cache(async (): Promise<readonly SiteVerificationMeta[]> => {
74
+ let timer: ReturnType<typeof setTimeout> | undefined;
75
+ try {
76
+ return await Promise.race([
77
+ readPlacements(),
78
+ new Promise<readonly SiteVerificationMeta[]>((resolve) => {
79
+ timer = setTimeout(() => {
80
+ resolve(NONE);
81
+ }, VERIFICATION_READ_TIMEOUT_MS);
82
+ }),
83
+ ]);
84
+ } catch {
85
+ // The template's sanctioned catch: an external SDK call, wrapped at its
86
+ // call site, exactly as `forgecart.ts#runAccountOperation` and
87
+ // `shop-session.ts` do. Nothing is swallowed that anyone here could act on
88
+ // — the platform's sync ladder is what observes an unserved placement.
89
+ return NONE;
90
+ } finally {
91
+ clearTimeout(timer);
92
+ }
93
+ });
94
+
95
+ async function readPlacements(): Promise<readonly SiteVerificationMeta[]> {
96
+ const { seoSiteVerifications } = await (await getShopClient()).seo.seoSiteVerifications();
97
+ return seoSiteVerifications.filter((placement) => placement.method === META_METHOD);
98
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The revision this server is SERVING, for the dev-only error beacon (#847).
3
+ *
4
+ * A browser error report used to be stamped with whatever revision was live at
5
+ * the moment the pod RECEIVED it (`storefront-beacon.service.ts`, `atRevision:
6
+ * this.target.getLiveRevision()`). A promote that advances between serve and
7
+ * throw therefore made the report blame the NEW revision for the OLD one's
8
+ * crash, and the receiver's same-revision fold then marked a healthy tree
9
+ * DEGRADED. The cure is to resolve the revision where the page is RENDERED —
10
+ * here — stamp it into the HTML, and let the report carry it back.
11
+ *
12
+ * WHERE THE RAW VALUE COMES FROM (settled by ruling ANSWER-0425Z-agent-004):
13
+ * the pod's supervisor publishes the serving sha to a DERIVED, read-only file
14
+ * inside the workspace's `.forge/` island by the same act that re-publishes its
15
+ * runtime state, and this module reads that file per request. Explicitly NOT a
16
+ * beacon hop on the render path, and explicitly NOT an environment variable —
17
+ * one dev-server process outlives many promotes, so a value handed to it at
18
+ * spawn time could only ever name the revision it BOOTED at, which is this
19
+ * module's own bug reintroduced one layer down. The retired
20
+ * `.forge/liveRevision` sentinel (appendix design law 5, §7.3) is not that file
21
+ * and is not read here; see `FORGE_SERVING_REVISION_FILE` below.
22
+ *
23
+ * The split is still the shape of the feature:
24
+ *
25
+ * - `parseLiveRevision` is PURE and fully specified by vectors. It owns the
26
+ * whole question of what counts as a revision, independently of where the
27
+ * raw value came from.
28
+ * - `readLiveRevisionSource` is the ONE impure line — the file read, and
29
+ * nothing else in the template touches the filesystem for this.
30
+ *
31
+ * FAIL OPEN, ALWAYS. Every unresolvable case answers `null`, which means no
32
+ * meta tag, which means an untagged report, which means the receiver keeps its
33
+ * existing behaviour (confirm probe included). A page must never fail to render
34
+ * because the revision is unknown, so there is no throw on this path: an
35
+ * unknown revision is an ordinary, expected state — every non-storefront pod,
36
+ * and every pod before its bootstrap primes the ref — not a fault.
37
+ */
38
+
39
+ import { readFileSync } from 'node:fs';
40
+ import { join } from 'node:path';
41
+
42
+ /**
43
+ * The meta tag's `name`. The CONTRACT between this module (which resolves the
44
+ * value), `app/layout.tsx` (which emits the tag) and
45
+ * `components/ForgeErrorBeacon.tsx` (which reads it back out of the DOM at send
46
+ * time). The beacon is a CLIENT component and must not import a `src/server/`
47
+ * module, so it repeats the literal with a comment pointing here — the same way
48
+ * the `/__forge_beacon` URL is repeated across this feature's three producers
49
+ * rather than shared through an import.
50
+ */
51
+ export const FORGE_REVISION_META = 'forge-revision';
52
+
53
+ /**
54
+ * The workspace-relative path the pod's supervisor publishes the serving sha to.
55
+ *
56
+ * MIRRORS `SERVING_REVISION_FILE` in
57
+ * `app/workspace-manager/src/supervisor/workspace-supervisor.service.ts`, whose
58
+ * doc carries the full reasoning for the path — `.forge/` because that island is
59
+ * the board repo's ignore authority and a file anywhere else would dirty the
60
+ * serving tree on every publish, and NEITHER retired legacy path, both of which
61
+ * the pod's own bootstrap deletes at boot. It is repeated here instead of
62
+ * imported because this template is scaffolding DATA — copied verbatim into a
63
+ * merchant's project, with its own `package.json` and toolchain, outside that
64
+ * monorepo's build graph — exactly like the `/__forge_beacon` URL. Nothing in a
65
+ * compiler connects the two copies: a rename on either side would silently stop
66
+ * the file from being found and every report would go back to untagged, so
67
+ * `tool/storefront-template-spec/src/live-revision.spec.ts` compares them.
68
+ *
69
+ * Resolved against `process.cwd()`, which IS the workspace root the supervisor
70
+ * writes into: it spawns `npm run dev` with `cwd` = the configured workspace
71
+ * path (resolved in `process-supervisor.service.ts`, handed to the child in
72
+ * `managed-process.ts`), and `npm` runs the script from the package root without
73
+ * moving it. `lib/seo/scaffolded-routes.ts` already reads a workspace-relative
74
+ * path this way on a request path.
75
+ */
76
+ export const FORGE_SERVING_REVISION_FILE = '.forge/serving-revision';
77
+
78
+ /**
79
+ * A revision is a full git commit sha and nothing else: exactly 40 hex digits,
80
+ * case-insensitive, surrounding whitespace trimmed. That is precisely what the
81
+ * supervisor's source produces — `git rev-parse --verify refs/heads/serving^{commit}`
82
+ * (`board-git.service.ts`) — so anything else reaching here is not a revision
83
+ * and must not be stamped.
84
+ *
85
+ * The rejections are the load-bearing half, because a stamped non-sha is worse
86
+ * than no stamp at all: the receiver compares the payload's value to the live
87
+ * revision by EQUALITY to decide whether to fold without probing, so a
88
+ * placeholder that could also BE the live value would skip the probe on
89
+ * evidence that means nothing. `'unversioned'` is the concrete instance of that
90
+ * hazard — it is literally what the supervisor reports for a pod with no board
91
+ * repo, so an unversioned pod stamping `'unversioned'` would match itself and
92
+ * defeat the guard. Rejecting it here is what makes the receiver's equality
93
+ * check safe.
94
+ *
95
+ * @param raw the candidate revision, exactly as obtained from the source
96
+ * @returns the normalised lowercase sha, or `null` when `raw` is not one
97
+ */
98
+ export function parseLiveRevision(raw: string): string | null {
99
+ const trimmed = raw.trim();
100
+ if (!/^[0-9a-f]{40}$/i.test(trimmed)) return null;
101
+ return trimmed.toLowerCase();
102
+ }
103
+
104
+ /**
105
+ * Obtain the raw revision value — THE ONE impure line in this module.
106
+ *
107
+ * A plain read of {@link FORGE_SERVING_REVISION_FILE}, the file the pod's
108
+ * supervisor re-publishes on every serving advance. There is deliberately NO
109
+ * environment fallback: the ruling excluded one, and an env value read here
110
+ * would be strictly worse than nothing, because the only process that could
111
+ * supply it is the dev-server's own spawn — which happens once and then serves
112
+ * every later promote under the revision it started at.
113
+ *
114
+ * Read per CALL, never captured at module scope, for the same reason:
115
+ * a value snapshotted at first import would keep stamping the boot revision
116
+ * forever. `lib/seo/sitemap-cache.ts` carries the same getter-not-a-constant
117
+ * reasoning.
118
+ *
119
+ * SYNCHRONOUS, unlike `lib/seo/scaffolded-routes.ts`, which reads on a request
120
+ * path asynchronously and explains why. That reasoning does not transfer: it
121
+ * fans out over a directory of arbitrarily many JSON fragments, whereas this is
122
+ * ONE page-cached read of at most 41 bytes, sitting beside an awaited channel
123
+ * read in the same `generateMetadata` that dwarfs it. Going async would cost
124
+ * `resolveLiveRevision` its synchronous signature for nothing measurable.
125
+ *
126
+ * Every failure answers `null`, and the ordinary case IS a failure: the file is
127
+ * absent on every pod before its first publish, on a non-storefront workspace
128
+ * that publishes none, and in a merchant's own checkout of this template, where
129
+ * no supervisor exists at all. Absent, unreadable and garbage all mean the same
130
+ * thing here — no tag, an untagged report, the receiver's existing behaviour —
131
+ * so there is nothing worth distinguishing between them and nothing worth
132
+ * throwing over on a render path.
133
+ */
134
+ export function readLiveRevisionSource(): string | null {
135
+ try {
136
+ return readFileSync(join(process.cwd(), FORGE_SERVING_REVISION_FILE), 'utf8');
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The revision to stamp into this response, or `null` when it cannot be
144
+ * resolved. Composes the source and the acceptance rule and holds no policy of
145
+ * its own, so the ruling can move the source without touching any caller.
146
+ *
147
+ * The DEV-ONLY gate lives at the call site (`app/layout.tsx`), not here — the
148
+ * placement every other file in this feature uses (`ForgeErrorBeacon.tsx`,
149
+ * `app/%5F%5Fforge_beacon/route.ts` and `instrumentation.ts` each check
150
+ * `NODE_ENV` at their own top), which is what lets a production build
151
+ * dead-code-eliminate the whole path rather than merely reach a function that
152
+ * answers `null`.
153
+ */
154
+ export function resolveLiveRevision(): string | null {
155
+ const raw = readLiveRevisionSource();
156
+ if (raw === null) return null;
157
+ return parseLiveRevision(raw);
158
+ }