@tokenoftrust/storefront-runner 2.5.1 → 2.5.3
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/apps/storefront/src/lib/assets/captured-original-serve.ts +62 -15
- package/apps/storefront/src/lib/assets/preview-pinned-image-urls.ts +25 -0
- package/apps/storefront/src/lib/content/seo/structuredData.ts +17 -15
- package/apps/storefront/src/lib/content/shared/jsonld.ts +4 -3
- package/apps/storefront/src/lib/content/shared/redirects.ts +22 -0
- package/apps/storefront/src/middleware/index.ts +9 -6
- package/apps/storefront/src/middleware/renderChain.ts +55 -41
- package/apps/storefront/src/middleware/staticBundle.ts +12 -8
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +2 -2
- package/packages/public-runtime/src/declared-tenant-config.mjs +2 -2
- package/packages/public-runtime/src/tenant.ts +1 -1
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* when the sealed revision manifest names the address.
|
|
6
6
|
*/
|
|
7
7
|
import { sha256Hex } from "@tot/public-runtime";
|
|
8
|
+
import { CAPTURABLE_RASTER_MEDIA_TYPES, PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE } from "@tot/public-runtime/hosted-image-media-types";
|
|
9
|
+
import { durableAssetsStatusKey, durableCaptureRevisionId, durableRequiredStores, parseDurableAssetsStatus } from "@tot/public-runtime/durable-assets";
|
|
8
10
|
|
|
11
|
+
const CAPTURE_TYPES = new Set<string>(CAPTURABLE_RASTER_MEDIA_TYPES);
|
|
9
12
|
const ADDRESS_RE = /^[0-9a-f]{64}$/;
|
|
10
13
|
/** Hostname-like tenant ids (no scheme, no path separators). */
|
|
11
14
|
const TENANT_ID_RE = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/i;
|
|
@@ -37,10 +40,11 @@ export type CapturedOriginalBucket = {
|
|
|
37
40
|
get(key: string): Promise<unknown>;
|
|
38
41
|
};
|
|
39
42
|
|
|
40
|
-
type ManifestEntry = {
|
|
43
|
+
export type ManifestEntry = {
|
|
41
44
|
address: string;
|
|
42
45
|
byteLength: number;
|
|
43
46
|
mediaType: string;
|
|
47
|
+
provenance?: { kind: "local" | "remote"; locator: string };
|
|
44
48
|
};
|
|
45
49
|
|
|
46
50
|
function objectKey(tenantId: string, address: string): string {
|
|
@@ -60,12 +64,13 @@ async function readManifestEntries(
|
|
|
60
64
|
revisionId: string,
|
|
61
65
|
): Promise<ManifestEntry[] | null> {
|
|
62
66
|
if (!isSafeTenantId(tenantId) || !isSafeRevisionId(revisionId)) return null;
|
|
67
|
+
const object = await bucket.get(originalImageManifestObjectKey(tenantId, revisionId));
|
|
68
|
+
if (!object || typeof object !== "object") return null;
|
|
69
|
+
const body = object as { arrayBuffer?: () => Promise<ArrayBuffer> };
|
|
70
|
+
if (typeof body.arrayBuffer !== "function") return null;
|
|
71
|
+
const bytes = await body.arrayBuffer();
|
|
63
72
|
try {
|
|
64
|
-
const
|
|
65
|
-
if (!object || typeof object !== "object") return null;
|
|
66
|
-
const body = object as { arrayBuffer?: () => Promise<ArrayBuffer> };
|
|
67
|
-
if (typeof body.arrayBuffer !== "function") return null;
|
|
68
|
-
const parsed = JSON.parse(new TextDecoder().decode(new Uint8Array(await body.arrayBuffer()))) as {
|
|
73
|
+
const parsed = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as {
|
|
69
74
|
version?: unknown;
|
|
70
75
|
revisionId?: unknown;
|
|
71
76
|
entries?: unknown;
|
|
@@ -78,11 +83,13 @@ async function readManifestEntries(
|
|
|
78
83
|
if (!raw || typeof raw !== "object") return null;
|
|
79
84
|
const entry = raw as Partial<ManifestEntry>;
|
|
80
85
|
if (typeof entry.address !== "string" || !ADDRESS_RE.test(entry.address)) return null;
|
|
81
|
-
if (
|
|
86
|
+
if (!Number.isSafeInteger(entry.byteLength) || entry.byteLength! < 1 || entry.byteLength! > 8 * 1024 * 1024 ||
|
|
87
|
+
typeof entry.mediaType !== "string" || !CAPTURE_TYPES.has(entry.mediaType)) return null;
|
|
82
88
|
entries.push({
|
|
83
89
|
address: entry.address,
|
|
84
|
-
byteLength: entry.byteLength
|
|
85
|
-
mediaType: entry.mediaType
|
|
90
|
+
byteLength: entry.byteLength!,
|
|
91
|
+
mediaType: entry.mediaType!,
|
|
92
|
+
provenance: entry.provenance,
|
|
86
93
|
});
|
|
87
94
|
}
|
|
88
95
|
return entries;
|
|
@@ -91,6 +98,31 @@ async function readManifestEntries(
|
|
|
91
98
|
}
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
/** Resolve an active, provider-verified designation through its tenant-scoped sealed capture. */
|
|
102
|
+
export async function readDurableCapturedEntry(input: {
|
|
103
|
+
bucket: CapturedOriginalBucket;
|
|
104
|
+
kv: { get(key: string): Promise<string | null> };
|
|
105
|
+
tenantId: string;
|
|
106
|
+
provider: string | null | undefined;
|
|
107
|
+
address: string;
|
|
108
|
+
}): Promise<ManifestEntry | null> {
|
|
109
|
+
if (!isSafeTenantId(input.tenantId) || !ADDRESS_RE.test(input.address)) return null;
|
|
110
|
+
const required = durableRequiredStores(input.provider);
|
|
111
|
+
if (!required) return null;
|
|
112
|
+
const record = parseDurableAssetsStatus(await input.kv.get(durableAssetsStatusKey(input.tenantId)), input.tenantId);
|
|
113
|
+
if (!record) return null;
|
|
114
|
+
for (const designation of record.entries) {
|
|
115
|
+
if (typeof designation.entry !== "string" || !designation.entry.startsWith(`${input.address}.`) ||
|
|
116
|
+
!Array.isArray(designation.verifiedStores) || !required.every((store) => designation.verifiedStores.includes(store)) ||
|
|
117
|
+
typeof designation.designatedRevision !== "string" || !isSafeRevisionId(designation.designatedRevision)) continue;
|
|
118
|
+
const entries = await readManifestEntries(input.bucket, input.tenantId, durableCaptureRevisionId(designation.designatedRevision));
|
|
119
|
+
const entry = entries?.find((entry) => entry.address === input.address &&
|
|
120
|
+
designation.entry === `${entry.address}${PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE[entry.mediaType]}`);
|
|
121
|
+
if (entry) return entry;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
94
126
|
/**
|
|
95
127
|
* Serve a captured original for the exact tenant+revision, or null to fall through.
|
|
96
128
|
* Missing/unbound/tampered addresses are 404 with no locator or digest in the body.
|
|
@@ -98,18 +130,33 @@ async function readManifestEntries(
|
|
|
98
130
|
export async function tryServeCapturedOriginal(input: {
|
|
99
131
|
bucket: CapturedOriginalBucket;
|
|
100
132
|
tenantId: string;
|
|
101
|
-
revisionId
|
|
133
|
+
revisionId?: string;
|
|
134
|
+
durable?: { kv: { get(key: string): Promise<string | null> }; provider: string | null | undefined };
|
|
102
135
|
pathname: string;
|
|
103
136
|
method?: string;
|
|
104
137
|
}): Promise<Response | null> {
|
|
105
138
|
const parsed = parsePinnedImagePath(input.pathname);
|
|
106
139
|
if (!parsed) return null;
|
|
107
|
-
if (!isSafeTenantId(input.tenantId) || !isSafeRevisionId(input.revisionId)) {
|
|
140
|
+
if (!isSafeTenantId(input.tenantId) || (input.revisionId !== undefined && !isSafeRevisionId(input.revisionId))) {
|
|
108
141
|
return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
|
|
109
142
|
}
|
|
110
143
|
|
|
111
|
-
const entries = await readManifestEntries(input.bucket, input.tenantId, input.revisionId);
|
|
112
|
-
|
|
144
|
+
const entries = input.revisionId ? await readManifestEntries(input.bucket, input.tenantId, input.revisionId).catch(() => null) : null;
|
|
145
|
+
let entry = entries?.find((item) => item.address === parsed.address);
|
|
146
|
+
let durable = false;
|
|
147
|
+
if (!entry && input.revisionId) {
|
|
148
|
+
const declared = await readManifestEntries(input.bucket, input.tenantId, durableCaptureRevisionId(input.revisionId)).catch(() => null);
|
|
149
|
+
entry = declared?.find((item) => item.address === parsed.address);
|
|
150
|
+
durable = !!entry;
|
|
151
|
+
}
|
|
152
|
+
if (!entry && input.durable) {
|
|
153
|
+
try {
|
|
154
|
+
entry = await readDurableCapturedEntry({ bucket: input.bucket, tenantId: input.tenantId,
|
|
155
|
+
address: parsed.address, ...input.durable }) ?? undefined;
|
|
156
|
+
durable = !!entry;
|
|
157
|
+
} catch { /* Unreadable durable authority fails closed. */ }
|
|
158
|
+
}
|
|
159
|
+
if (entry && parsed.ext && EXT_TYPE[parsed.ext.toLowerCase()] !== entry.mediaType) entry = undefined;
|
|
113
160
|
if (!entry) {
|
|
114
161
|
return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
|
|
115
162
|
}
|
|
@@ -134,8 +181,8 @@ export async function tryServeCapturedOriginal(input: {
|
|
|
134
181
|
const contentType = EXT_TYPE[parsed.ext.toLowerCase()] ?? entry.mediaType;
|
|
135
182
|
const headers: Record<string, string> = {
|
|
136
183
|
"content-type": contentType,
|
|
137
|
-
"cache-control": "public, max-age=31536000, immutable",
|
|
138
|
-
"x-tot-original-image": "revision-bound",
|
|
184
|
+
"cache-control": durable ? "no-store" : "public, max-age=31536000, immutable",
|
|
185
|
+
"x-tot-original-image": durable ? "durable" : "revision-bound",
|
|
139
186
|
"content-length": String(bytes.byteLength),
|
|
140
187
|
};
|
|
141
188
|
const method = (input.method ?? "GET").toUpperCase();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Keep captured-image requests inside the selected tenant on path-routed hosted previews. */
|
|
2
|
+
export async function scopePreviewPinnedImageUrls(response: Response, basePath: string): Promise<Response> {
|
|
3
|
+
if (!basePath || !response.body || !(response.headers.get("content-type") ?? "").includes("text/html")) return response;
|
|
4
|
+
const html = await response.text();
|
|
5
|
+
const scoped = prefixPreviewPinnedImageUrls(html, basePath);
|
|
6
|
+
const headers = new Headers(response.headers);
|
|
7
|
+
headers.delete("content-length");
|
|
8
|
+
headers.delete("etag");
|
|
9
|
+
return new Response(scoped, { status: response.status, statusText: response.statusText, headers });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Scope only exact captured-image addresses; platform assets and external URLs retain their host. */
|
|
13
|
+
export function prefixPreviewPinnedImageUrls(html: string, basePath: string): string {
|
|
14
|
+
if (!basePath) return html;
|
|
15
|
+
return html.replace(/([\s"'(=,])\/_a\/([0-9a-f]{64}(?:\.[A-Za-z0-9]+)?)(?=[\s"'?#),<>]|$)/g,
|
|
16
|
+
(_match, before: string, entry: string) => `${before}${basePath}/_a/${entry}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Choose asset routing from the resolved request tenant, never a second host-membership check. */
|
|
20
|
+
export function previewPinnedImageBasePath(tenant: {
|
|
21
|
+
tenant_id: string; appDomain?: string;
|
|
22
|
+
}, routing: { tenantId: string | undefined; basePath: string }): string {
|
|
23
|
+
if (routing.tenantId === tenant.tenant_id) return routing.basePath;
|
|
24
|
+
return tenant.appDomain && /^[a-z0-9.-]+$/i.test(tenant.appDomain) ? `/${tenant.appDomain}` : "";
|
|
25
|
+
}
|
|
@@ -88,26 +88,28 @@ export function composeStructuredData(input: ComposeInput): Node | null {
|
|
|
88
88
|
const nameOf = (type: "Product" | "ProductGroup" | "Article" | "BlogPosting", own: string | undefined, fallback: "documentTitle" | "title") =>
|
|
89
89
|
(profile.names?.[type] ?? fallback) === "documentTitle" ? documentTitle : own || documentTitle;
|
|
90
90
|
|
|
91
|
-
const siteToken = tokens.find((t) => SITE_TOKENS.has(t));
|
|
92
|
-
const siteTypes = siteToken ? siteToken.split("+") : [];
|
|
93
|
-
const siteId = `${base}/#${siteTypes.includes("Person") ? "person" : "organization"}`;
|
|
94
|
-
const imageUrl = page.image ? absolute(page.image, canonicalBase) : null;
|
|
95
|
-
const imageId = imageUrl ?? undefined;
|
|
96
|
-
const pageToken = tokens.find((t) => PAGE_ENTITY_TOKENS.has(t));
|
|
97
|
-
const pageId = `${canonical}#webpage`;
|
|
98
|
-
const authorId = page.author ? `${page.author.url ? absolute(page.author.url, canonicalBase) : canonical}#author` : undefined;
|
|
99
|
-
const ref = (id: string | undefined) => (id ? { "@id": id } : undefined);
|
|
100
|
-
const pageUrl = (href: string) => pageUrlForm(absolute(href, canonicalBase), tenant.seo);
|
|
101
|
-
|
|
102
91
|
const pathOf = (value: string) => value.replace(/\/+$/, "") || "/";
|
|
103
92
|
const here = pathOf(new URL(canonical).pathname);
|
|
104
93
|
const scoped = (token: StructuredDataNodeToken) => {
|
|
105
94
|
const paths = profile.onlyOn?.[token];
|
|
106
95
|
return !paths || paths.some((p) => pathOf(p) === here);
|
|
107
96
|
};
|
|
97
|
+
const imageUrl = page.image ? absolute(page.image, canonicalBase) : null;
|
|
98
|
+
// References and node emission share the same effective selection, including data availability.
|
|
99
|
+
const emittedTokens = tokens.filter(scoped).filter((token) =>
|
|
100
|
+
token === "ImageObject" ? imageUrl !== null : token === "Person:author" ? Boolean(page.author) : true);
|
|
101
|
+
const siteToken = emittedTokens.find((t) => SITE_TOKENS.has(t));
|
|
102
|
+
const siteTypes = siteToken ? siteToken.split("+") : [];
|
|
103
|
+
const siteId = `${base}/#${siteTypes.includes("Person") ? "person" : "organization"}`;
|
|
104
|
+
const imageId = emittedTokens.includes("ImageObject") ? imageUrl : undefined;
|
|
105
|
+
const pageToken = emittedTokens.find((t) => PAGE_ENTITY_TOKENS.has(t));
|
|
106
|
+
const pageId = `${canonical}#webpage`;
|
|
107
|
+
const authorId = emittedTokens.includes("Person:author") && page.author
|
|
108
|
+
? `${page.author.url ? absolute(page.author.url, canonicalBase) : canonical}#author` : undefined;
|
|
109
|
+
const pageUrl = (href: string) => pageUrlForm(absolute(href, canonicalBase), tenant.seo);
|
|
108
110
|
|
|
109
111
|
const nodes: Node[] = [];
|
|
110
|
-
for (const token of
|
|
112
|
+
for (const token of emittedTokens) {
|
|
111
113
|
const node = buildNode(token);
|
|
112
114
|
if (node) nodes.push(node);
|
|
113
115
|
}
|
|
@@ -120,7 +122,7 @@ export function composeStructuredData(input: ComposeInput): Node | null {
|
|
|
120
122
|
"@type": siteTypes.length > 1 ? siteTypes : siteTypes[0],
|
|
121
123
|
"@id": siteId,
|
|
122
124
|
name: tenant.displayName,
|
|
123
|
-
url: base,
|
|
125
|
+
url: pageUrlForm(base, tenant.seo),
|
|
124
126
|
...(logo && siteTypes.includes("Organization") ? { logo: { "@type": "ImageObject", url: absolute(logo, canonicalBase) } } : {}),
|
|
125
127
|
};
|
|
126
128
|
}
|
|
@@ -131,7 +133,7 @@ export function composeStructuredData(input: ComposeInput): Node | null {
|
|
|
131
133
|
url: canonical,
|
|
132
134
|
name: documentTitle,
|
|
133
135
|
...(page.description ? { description: page.description } : {}),
|
|
134
|
-
isPartOf: { "@id": `${base}/#website` },
|
|
136
|
+
...(emittedTokens.includes("WebSite") ? { isPartOf: { "@id": `${base}/#website` } } : {}),
|
|
135
137
|
...(imageId ? { primaryImageOfPage: { "@id": imageId } } : {}),
|
|
136
138
|
...(page.datePublished ? { datePublished: page.datePublished } : {}),
|
|
137
139
|
...(page.dateModified ? { dateModified: page.dateModified } : {}),
|
|
@@ -143,7 +145,7 @@ export function composeStructuredData(input: ComposeInput): Node | null {
|
|
|
143
145
|
return {
|
|
144
146
|
"@type": "WebSite",
|
|
145
147
|
"@id": `${base}/#website`,
|
|
146
|
-
url: base,
|
|
148
|
+
url: pageUrlForm(base, tenant.seo),
|
|
147
149
|
name: tenant.displayName,
|
|
148
150
|
...(siteToken ? { publisher: { "@id": siteId } } : {}),
|
|
149
151
|
inLanguage: lang,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* BreadcrumbList, Organization. Kept pure so pages compose them and pass to <Seo>.
|
|
4
4
|
*/
|
|
5
5
|
import type { CatalogProduct, Review, TenantConfig } from "@tot/public-runtime";
|
|
6
|
+
import { pageUrlForm } from "../seo/urlForm.js";
|
|
6
7
|
import { isAvailable } from "../../catalog/tot/query.js";
|
|
7
8
|
|
|
8
9
|
export function organizationLd(tenant: TenantConfig, base: string) {
|
|
@@ -20,7 +21,7 @@ export function organizationLd(tenant: TenantConfig, base: string) {
|
|
|
20
21
|
"@context": "https://schema.org",
|
|
21
22
|
"@type": "Organization",
|
|
22
23
|
name: tenant.displayName,
|
|
23
|
-
url: base,
|
|
24
|
+
url: pageUrlForm(base, tenant.seo),
|
|
24
25
|
logo,
|
|
25
26
|
};
|
|
26
27
|
}
|
|
@@ -53,7 +54,7 @@ export function websiteLd(tenant: TenantConfig, base: string) {
|
|
|
53
54
|
"@context": "https://schema.org",
|
|
54
55
|
"@type": "WebSite",
|
|
55
56
|
name: tenant.displayName,
|
|
56
|
-
url: base,
|
|
57
|
+
url: pageUrlForm(base, tenant.seo),
|
|
57
58
|
potentialAction: {
|
|
58
59
|
"@type": "SearchAction",
|
|
59
60
|
target: {
|
|
@@ -204,7 +205,7 @@ export function productLd(
|
|
|
204
205
|
tenant: TenantConfig,
|
|
205
206
|
base: string,
|
|
206
207
|
) {
|
|
207
|
-
const url = new URL(`/products/${p.handle}`, base + "/").toString();
|
|
208
|
+
const url = pageUrlForm(new URL(`/products/${p.handle}`, base + "/").toString(), tenant.seo);
|
|
208
209
|
const images = p.images
|
|
209
210
|
.map((i) => i.url)
|
|
210
211
|
.filter((u) => !/cdn\.shopify\.com/.test(u)); // skip dead fixture URLs
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Content redirects for ordinary preview/local renders, bound to the page's selected provider. */
|
|
2
|
+
import { buildRedirectSet, COLLECTION_REDIRECTS_PATH, declaredRedirectsFromContent, resolveRedirect } from "@tot/public-runtime/publication-redirects";
|
|
3
|
+
import type { ContentProvider } from "../storyblok/provider.js";
|
|
4
|
+
|
|
5
|
+
export async function contentRedirectLocation(
|
|
6
|
+
content: Pick<ContentProvider, "readArtifact" | "listArtifactPaths">,
|
|
7
|
+
requestPath: string,
|
|
8
|
+
): Promise<string | null> {
|
|
9
|
+
try {
|
|
10
|
+
const paths = await content.listArtifactPaths();
|
|
11
|
+
const declarations = ["redirects.json", ...(paths ?? []).filter((path) => COLLECTION_REDIRECTS_PATH.test(`content/${path}`))];
|
|
12
|
+
const entries = await Promise.all(declarations.map(async (relative) => {
|
|
13
|
+
const raw = await content.readArtifact(relative);
|
|
14
|
+
return raw === null ? null : { path: `content/${relative}`, json: JSON.parse(raw) as unknown };
|
|
15
|
+
}));
|
|
16
|
+
const set = buildRedirectSet(declaredRedirectsFromContent(entries.filter((entry) => entry !== null)));
|
|
17
|
+
return set.ok ? resolveRedirect(set.redirects, requestPath)?.location ?? null : null;
|
|
18
|
+
} catch {
|
|
19
|
+
// An unreadable/invalid map must never produce an unsafe or partial redirect set.
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -73,10 +73,12 @@ function routeTierIndexFor(tenant: TenantConfig): RouteTierIndexV1 {
|
|
|
73
73
|
* appended after the branch has read or written any edge cache.
|
|
74
74
|
*/
|
|
75
75
|
export const onRequest = defineMiddleware(async (context, next) => {
|
|
76
|
-
const seen = { cdnForwarded: false };
|
|
76
|
+
const seen: { cdnForwarded: boolean; assetBasePath?: string } = { cdnForwarded: false };
|
|
77
77
|
const response = await serveRequest(context, next, seen);
|
|
78
|
+
const { scopePreviewPinnedImageUrls } = await import("@/lib/assets/preview-pinned-image-urls");
|
|
79
|
+
const scoped = await scopePreviewPinnedImageUrls(response, context.locals.apiBasePath ?? seen.assetBasePath ?? "");
|
|
78
80
|
return withVisitorRegionCookie(
|
|
79
|
-
|
|
81
|
+
scoped,
|
|
80
82
|
visitorRegionFor(context.request, { cdnForwarded: seen.cdnForwarded, dev: import.meta.env.DEV }),
|
|
81
83
|
);
|
|
82
84
|
});
|
|
@@ -84,7 +86,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
84
86
|
async function serveRequest(
|
|
85
87
|
context: APIContext,
|
|
86
88
|
next: MiddlewareNext,
|
|
87
|
-
seen: { cdnForwarded: boolean },
|
|
89
|
+
seen: { cdnForwarded: boolean; assetBasePath?: string },
|
|
88
90
|
): Promise<Response> {
|
|
89
91
|
const { request, locals } = context;
|
|
90
92
|
const url = new URL(request.url);
|
|
@@ -163,6 +165,7 @@ async function serveRequest(
|
|
|
163
165
|
if (!tenant) {
|
|
164
166
|
return new Response("Unknown host", { status: 404 });
|
|
165
167
|
}
|
|
168
|
+
seen.assetBasePath = basePath;
|
|
166
169
|
|
|
167
170
|
// ---- The one route classification -----------------------------------------
|
|
168
171
|
// An EXPLICITLY unpaid store is never fully public: the ONLY way to reach its
|
|
@@ -282,7 +285,7 @@ async function serveRequest(
|
|
|
282
285
|
});
|
|
283
286
|
const capturedRevisionId = candidatePin?.versionId ?? cookieRevision;
|
|
284
287
|
const originalsBucket =
|
|
285
|
-
capturedRevisionId
|
|
288
|
+
(capturedRevisionId || renderPath.startsWith("/_a/"))
|
|
286
289
|
? ((await readR2("PRODUCT_IMAGES")) ?? (await readR2("TENANT_ASSETS")) ?? null)
|
|
287
290
|
: null;
|
|
288
291
|
const bundled = await tryServeStaticBundle({
|
|
@@ -298,11 +301,11 @@ async function serveRequest(
|
|
|
298
301
|
hasCandidatePin: !!candidatePin,
|
|
299
302
|
hasDraftCookie: !!context.cookies.get(DEV_DRAFT_COOKIE)?.value,
|
|
300
303
|
hasManagedInstalls: false,
|
|
301
|
-
...(
|
|
304
|
+
...(originalsBucket
|
|
302
305
|
? {
|
|
303
306
|
capturedOriginal: {
|
|
304
307
|
bucket: originalsBucket as { get(key: string): Promise<unknown> },
|
|
305
|
-
revisionId: capturedRevisionId,
|
|
308
|
+
...(capturedRevisionId ? { revisionId: capturedRevisionId } : {}),
|
|
306
309
|
},
|
|
307
310
|
}
|
|
308
311
|
: {}),
|
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
HTML_CACHE_POLICY_CAPTURE_HEADER,
|
|
84
84
|
HTML_CACHE_POLICY_CAPTURE_PROBE,
|
|
85
85
|
} from "@tot/public-runtime";
|
|
86
|
+
import { contentRedirectLocation } from "@/lib/content/shared/redirects.js";
|
|
86
87
|
import { publicationContentRedirectLocationV1 } from "@tot/public-runtime/publication-content-redirects";
|
|
87
88
|
import { loadTenantIntegrationDeclarations } from "@/lib/integrations/integration/tenantDeclarations";
|
|
88
89
|
import { resolveTenantCommerce } from "@/lib/checkout/shared/checkoutCommerce";
|
|
@@ -269,6 +270,57 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
269
270
|
kv ? (key: string) => kv.get(key) : async () => null,
|
|
270
271
|
{ env: customization.env, ...(candidatePin ? { pinnedVersionId: candidatePin.versionId } : {}) },
|
|
271
272
|
);
|
|
273
|
+
// Request-scoped read memo: every content read resolves the same pointer and
|
|
274
|
+
// version manifest, and a catalog render reads one artifact per catalog file.
|
|
275
|
+
const kvReads = new Map<string, Promise<string | null>>();
|
|
276
|
+
const kvGet = kv
|
|
277
|
+
? (key: string) => {
|
|
278
|
+
let read = kvReads.get(key);
|
|
279
|
+
if (!read) {
|
|
280
|
+
read = kv.get(key);
|
|
281
|
+
kvReads.set(key, read);
|
|
282
|
+
}
|
|
283
|
+
return read;
|
|
284
|
+
}
|
|
285
|
+
: async () => null;
|
|
286
|
+
const readRuntimeArtifact = (path: string) => candidatePin
|
|
287
|
+
? readWithImagePreviewRewriteOverlay(
|
|
288
|
+
kvGet,
|
|
289
|
+
tenant.tenant_id,
|
|
290
|
+
candidatePin.versionId,
|
|
291
|
+
path,
|
|
292
|
+
() => readVersionArtifact(kvGet, tenant.tenant_id, candidatePin.versionId, path),
|
|
293
|
+
)
|
|
294
|
+
: readPublishedArtifact(kvGet, tenant.tenant_id, customization.env, path);
|
|
295
|
+
|
|
296
|
+
// Resolve the shared root adapter first. An opened publication version then
|
|
297
|
+
// becomes authoritative for selected content while a mounted space may still
|
|
298
|
+
// reuse root-owned chrome. Neither source falls through to another space.
|
|
299
|
+
const sharedRootContent = candidatePin
|
|
300
|
+
? createContentProvider(tenant, undefined, { get: kvGet, versionId: candidatePin.versionId })
|
|
301
|
+
: createContentProvider(tenant, {
|
|
302
|
+
get: kvGet,
|
|
303
|
+
env: customization.env,
|
|
304
|
+
});
|
|
305
|
+
locals.content = bindPublicationSpaceContentProviderV1(
|
|
306
|
+
sharedRootContent,
|
|
307
|
+
publicationSource.status === "selected" ? publicationSource.artifact : undefined,
|
|
308
|
+
// A discovery document lists the whole space it is bound to, not one selected page.
|
|
309
|
+
{ wholeSpace: isPlatformPublicDiscoveryPath(renderPath) },
|
|
310
|
+
);
|
|
311
|
+
// Ordinary host, path-prefixed and pinned previews read the same declarations as their pages.
|
|
312
|
+
// Public CDN redirects remain precomputed in the publication artifact.
|
|
313
|
+
if (publicationSource.status === "legacy-root") {
|
|
314
|
+
const location = await contentRedirectLocation(locals.content, renderPath);
|
|
315
|
+
if (location) {
|
|
316
|
+
const response = new Response(null, {
|
|
317
|
+
status: 308,
|
|
318
|
+
headers: { location: location.startsWith("/") ? `${url.origin}${basePath}${location}${url.search}` : location },
|
|
319
|
+
});
|
|
320
|
+
applySecurityHeaders(response.headers);
|
|
321
|
+
return response;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
272
324
|
const publicationTimestamp = publicationSource.status === "selected"
|
|
273
325
|
? publicationSource.publicationTimestamp
|
|
274
326
|
: exactPublicationTimestamp(await readEnv("TOT_PUBLICATION_TIMESTAMP"));
|
|
@@ -301,7 +353,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
301
353
|
});
|
|
302
354
|
const capturedRevisionId = candidatePin?.versionId ?? cookieRevision;
|
|
303
355
|
const originalsBucket =
|
|
304
|
-
capturedRevisionId
|
|
356
|
+
(capturedRevisionId || renderPath.startsWith("/_a/"))
|
|
305
357
|
? ((await readR2("PRODUCT_IMAGES")) ?? (await readR2("TENANT_ASSETS")) ?? null)
|
|
306
358
|
: null;
|
|
307
359
|
const bundled = publicationSource.status === "legacy-root" ? await tryServeStaticBundle({
|
|
@@ -316,11 +368,11 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
316
368
|
hasCandidatePin: !!candidatePin,
|
|
317
369
|
hasDraftCookie: !!draftCookie,
|
|
318
370
|
hasManagedInstalls: managedIntegrationDeclarations.length > 0,
|
|
319
|
-
...(
|
|
371
|
+
...(originalsBucket
|
|
320
372
|
? {
|
|
321
373
|
capturedOriginal: {
|
|
322
374
|
bucket: originalsBucket as { get(key: string): Promise<unknown> },
|
|
323
|
-
revisionId: capturedRevisionId,
|
|
375
|
+
...(capturedRevisionId ? { revisionId: capturedRevisionId } : {}),
|
|
324
376
|
},
|
|
325
377
|
}
|
|
326
378
|
: {}),
|
|
@@ -484,29 +536,6 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
484
536
|
// test->preview) and resolves it through the immutable site version. A
|
|
485
537
|
// candidate must bypass that pointer so a reviewer sees the candidate's own
|
|
486
538
|
// theme, capabilities, and embed opt-in with its content.
|
|
487
|
-
// Request-scoped read memo: every content read resolves the same pointer and
|
|
488
|
-
// version manifest, and a catalog render reads one artifact per catalog file.
|
|
489
|
-
const kvReads = new Map<string, Promise<string | null>>();
|
|
490
|
-
const kvGet = kv
|
|
491
|
-
? (key: string) => {
|
|
492
|
-
let read = kvReads.get(key);
|
|
493
|
-
if (!read) {
|
|
494
|
-
read = kv.get(key);
|
|
495
|
-
kvReads.set(key, read);
|
|
496
|
-
}
|
|
497
|
-
return read;
|
|
498
|
-
}
|
|
499
|
-
: async () => null;
|
|
500
|
-
const readRuntimeArtifact = (path: string) => candidatePin
|
|
501
|
-
? readWithImagePreviewRewriteOverlay(
|
|
502
|
-
kvGet,
|
|
503
|
-
tenant.tenant_id,
|
|
504
|
-
candidatePin.versionId,
|
|
505
|
-
path,
|
|
506
|
-
() => readVersionArtifact(kvGet, tenant.tenant_id, candidatePin.versionId, path),
|
|
507
|
-
)
|
|
508
|
-
: readPublishedArtifact(kvGet, tenant.tenant_id, customization.env, path);
|
|
509
|
-
|
|
510
539
|
// Self-heal a DANGLING channel pointer: the pointer for this env's channel may
|
|
511
540
|
// name a version that was never built (or whose manifest was evicted).
|
|
512
541
|
// `readVersionedArtifact` already serves last-built content instead of emptying
|
|
@@ -622,21 +651,6 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
622
651
|
? await resolveTenantCommerce(tenant)
|
|
623
652
|
: null;
|
|
624
653
|
locals.commerce = commerce ?? undefined;
|
|
625
|
-
// Resolve the shared root adapter first. An opened publication version then
|
|
626
|
-
// becomes authoritative for selected content while a mounted space may still
|
|
627
|
-
// reuse root-owned chrome. Neither source falls through to another space.
|
|
628
|
-
const sharedRootContent = candidatePin
|
|
629
|
-
? createContentProvider(tenant, undefined, { get: kvGet, versionId: candidatePin.versionId })
|
|
630
|
-
: createContentProvider(tenant, {
|
|
631
|
-
get: kvGet,
|
|
632
|
-
env: customization.env,
|
|
633
|
-
});
|
|
634
|
-
locals.content = bindPublicationSpaceContentProviderV1(
|
|
635
|
-
sharedRootContent,
|
|
636
|
-
publicationSource.status === "selected" ? publicationSource.artifact : undefined,
|
|
637
|
-
// A discovery document lists the whole space it is bound to, not one selected page.
|
|
638
|
-
{ wholeSpace: isPlatformPublicDiscoveryPath(renderPath) },
|
|
639
|
-
);
|
|
640
654
|
// Developer-draft content overlay — serves the AI-edited artifacts to the draft
|
|
641
655
|
// owner only; every other viewer keeps the published/base chain above.
|
|
642
656
|
if (draft) {
|
|
@@ -37,7 +37,7 @@ export interface StaticBundleAttemptInput {
|
|
|
37
37
|
*/
|
|
38
38
|
capturedOriginal?: {
|
|
39
39
|
bucket: { get(key: string): Promise<unknown> };
|
|
40
|
-
revisionId
|
|
40
|
+
revisionId?: string;
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -60,15 +60,17 @@ export async function tryServeStaticBundle(
|
|
|
60
60
|
): Promise<Response | null> {
|
|
61
61
|
const { request, url, tenant, cache, kv } = input;
|
|
62
62
|
const method = request.method.toUpperCase();
|
|
63
|
+
const assetPath = input.basePath && url.pathname.startsWith(`${input.basePath}/_a/`)
|
|
64
|
+
? url.pathname.slice(input.basePath.length) : url.pathname;
|
|
63
65
|
|
|
64
66
|
// Version-independent assets (`/_a/<sha256>`, `/_astro/<name>`) are exact addresses: the same
|
|
65
67
|
// bytes for every viewer, preview or not, whatever the live pointer says. A page from an
|
|
66
68
|
// older version — or a preview of one — keeps resolving its own sub-resources.
|
|
67
|
-
if ((method === "GET" || method === "HEAD") && versionIndependentAssetKey(
|
|
69
|
+
if ((method === "GET" || method === "HEAD") && versionIndependentAssetKey(assetPath)) {
|
|
68
70
|
const bucket = await readR2("STATIC_BUNDLES");
|
|
69
71
|
if (bucket) {
|
|
70
72
|
const pinnedKey = cache && method === "GET"
|
|
71
|
-
? staticBundleCacheKey(tenant.tenant_id, "pinned",
|
|
73
|
+
? staticBundleCacheKey(tenant.tenant_id, "pinned", assetPath, "")
|
|
72
74
|
: null;
|
|
73
75
|
if (pinnedKey && cache) {
|
|
74
76
|
const hit = await getCachedPage(cache, pinnedKey);
|
|
@@ -76,7 +78,7 @@ export async function tryServeStaticBundle(
|
|
|
76
78
|
}
|
|
77
79
|
const pinned = await serveVersionIndependentAsset(
|
|
78
80
|
bucket as unknown as StaticBundleR2BucketLike,
|
|
79
|
-
|
|
81
|
+
assetPath,
|
|
80
82
|
method,
|
|
81
83
|
);
|
|
82
84
|
if (pinned && pinned.ok) {
|
|
@@ -84,26 +86,28 @@ export async function tryServeStaticBundle(
|
|
|
84
86
|
return pinned;
|
|
85
87
|
}
|
|
86
88
|
// `/_a/` miss: try revision-bound captured originals before the pinned 404.
|
|
87
|
-
if (input.capturedOriginal &&
|
|
89
|
+
if (input.capturedOriginal && assetPath.startsWith("/_a/")) {
|
|
88
90
|
const { tryServeCapturedOriginal } = await import("@/lib/assets/captured-original-serve");
|
|
89
91
|
const captured = await tryServeCapturedOriginal({
|
|
90
92
|
bucket: input.capturedOriginal.bucket,
|
|
91
93
|
tenantId: tenant.tenant_id,
|
|
92
94
|
revisionId: input.capturedOriginal.revisionId,
|
|
93
|
-
pathname:
|
|
95
|
+
pathname: assetPath,
|
|
94
96
|
method,
|
|
97
|
+
...(kv ? { durable: { kv, provider: tenant.serving?.provider } } : {}),
|
|
95
98
|
});
|
|
96
99
|
if (captured) return captured;
|
|
97
100
|
}
|
|
98
101
|
if (pinned) return pinned;
|
|
99
|
-
} else if (input.capturedOriginal &&
|
|
102
|
+
} else if (input.capturedOriginal && assetPath.startsWith("/_a/")) {
|
|
100
103
|
const { tryServeCapturedOriginal } = await import("@/lib/assets/captured-original-serve");
|
|
101
104
|
const captured = await tryServeCapturedOriginal({
|
|
102
105
|
bucket: input.capturedOriginal.bucket,
|
|
103
106
|
tenantId: tenant.tenant_id,
|
|
104
107
|
revisionId: input.capturedOriginal.revisionId,
|
|
105
|
-
pathname:
|
|
108
|
+
pathname: assetPath,
|
|
106
109
|
method,
|
|
110
|
+
...(kv ? { durable: { kv, provider: tenant.serving?.provider } } : {}),
|
|
107
111
|
});
|
|
108
112
|
if (captured) return captured;
|
|
109
113
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.3",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -107,8 +107,8 @@ export function seoDeclarationProblems(seo) {
|
|
|
107
107
|
problems.push(`\`${at}\`: unknown page type (${STRUCTURED_DATA_PAGE_TYPES.join(", ")})`);
|
|
108
108
|
continue;
|
|
109
109
|
}
|
|
110
|
-
if (!Array.isArray(tokens)
|
|
111
|
-
problems.push(`\`${at}\` must be
|
|
110
|
+
if (!Array.isArray(tokens)) {
|
|
111
|
+
problems.push(`\`${at}\` must be an array of node types (empty emits no nodes)`);
|
|
112
112
|
continue;
|
|
113
113
|
}
|
|
114
114
|
const seen = new Set();
|
|
@@ -153,8 +153,8 @@ export function seoDeclarationProblems(seo) {
|
|
|
153
153
|
problems.push(`\`${at}\`: unknown page type (${STRUCTURED_DATA_PAGE_TYPES.join(", ")})`);
|
|
154
154
|
continue;
|
|
155
155
|
}
|
|
156
|
-
if (!Array.isArray(tokens)
|
|
157
|
-
problems.push(`\`${at}\` must be
|
|
156
|
+
if (!Array.isArray(tokens)) {
|
|
157
|
+
problems.push(`\`${at}\` must be an array of node types (empty emits no nodes)`);
|
|
158
158
|
continue;
|
|
159
159
|
}
|
|
160
160
|
const seen = new Set();
|
|
@@ -790,7 +790,7 @@ export interface SeoDeclaration {
|
|
|
790
790
|
* The structured-data profile. When present it is the ONLY JSON-LD a declared page type
|
|
791
791
|
* emits: one `@graph` holding exactly the listed nodes, in order. A node whose data the
|
|
792
792
|
* page lacks (no image, no author, no FAQ) is omitted. A page type without a graph keeps
|
|
793
|
-
* the platform default.
|
|
793
|
+
* the platform default. An explicit empty list emits no nodes for that page type.
|
|
794
794
|
*/
|
|
795
795
|
structuredData?: {
|
|
796
796
|
graphs: Partial<Record<StructuredDataPageType, StructuredDataNodeToken[]>>;
|