@forgecart/cli 2.202609221621.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.
- package/package.json +1 -1
- package/templates/storefront/README.md +2 -0
- package/templates/storefront/SEO-MIGRATION.md +705 -0
- package/templates/storefront/src/app/%5F%5Fforge_beacon/route.ts +43 -0
- package/templates/storefront/src/app/layout.tsx +36 -1
- package/templates/storefront/src/components/ForgeErrorBeacon.tsx +31 -0
- package/templates/storefront/src/instrumentation.ts +7 -1
- package/templates/storefront/src/lib/seo/metadata.ts +84 -0
- package/templates/storefront/src/lib/seo/site-verification.ts +98 -0
- package/templates/storefront/src/server/forge/live-revision.ts +158 -0
|
@@ -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
|
-
|
|
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
|
+
}
|