@tokenoftrust/storefront-runner 3.4.0 → 3.5.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.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Responsive renditions on Worker-rendered pages (preview on both providers, and every page the
3
+ * Worker renders rather than serves from a static bundle). The Worker never encodes: renditions are
4
+ * produced and registered on the node side (the candidate prebuild and every publish,
5
+ * `scripts/publish/lib/hosted-original-publication.mjs`), and a rendered page points at the ones the
6
+ * R2 asset registry (`registry/assets.json` in `STATIC_BUNDLES`) holds, with the same `<picture>` the
7
+ * static bundle carries (`@tot/public-runtime/responsive-renditions`). A source without a complete
8
+ * registered set keeps its original tag.
9
+ *
10
+ * The parsed registry is kept per isolate under the document's ETag; each render pays one `head`
11
+ * while it is unchanged, and none when the page names no pinned raster.
12
+ */
13
+ import {
14
+ ASSET_REGISTRY_KEY,
15
+ MAX_ASSET_REGISTRY_BYTES,
16
+ parseAssetRegistry,
17
+ registeredRenditionSet,
18
+ type AssetRegistry,
19
+ } from "@tot/public-runtime/asset-registry";
20
+ import {
21
+ pinnedRasterRef,
22
+ renditionRecipes,
23
+ responsiveImgSrcs,
24
+ rewritePinnedRenditions,
25
+ } from "@tot/public-runtime/responsive-renditions";
26
+ import { R2_ASSET_REGISTRY_STORE } from "@/lib/assets/assetRegistryR2";
27
+
28
+ export interface RenditionRegistryBucket {
29
+ head(key: string): Promise<{ etag: string; size: number } | null>;
30
+ get(key: string): Promise<{ etag: string; size: number; arrayBuffer(): Promise<ArrayBuffer> } | null>;
31
+ }
32
+
33
+ let cached: { etag: string; registry: AssetRegistry } | null = null;
34
+
35
+ /** The R2 asset registry, or null when it is absent, oversized or unreadable. */
36
+ export async function readRenditionRegistry(bucket: RenditionRegistryBucket): Promise<AssetRegistry | null> {
37
+ const head = await bucket.head(ASSET_REGISTRY_KEY);
38
+ if (!head || head.size > MAX_ASSET_REGISTRY_BYTES) return null;
39
+ if (cached?.etag === head.etag) return cached.registry;
40
+ const object = await bucket.get(ASSET_REGISTRY_KEY);
41
+ if (!object || object.size > MAX_ASSET_REGISTRY_BYTES) return null;
42
+ const registry = parseAssetRegistry(new Uint8Array(await object.arrayBuffer()), R2_ASSET_REGISTRY_STORE);
43
+ if (registry) cached = { etag: object.etag, registry };
44
+ return registry;
45
+ }
46
+
47
+ /** `html` with every pinned raster `<img>` pointed at `tenantId`'s registered renditions. */
48
+ export function applyRegisteredRenditions(html: string, tenantId: string, registry: AssetRegistry): string {
49
+ return rewritePinnedRenditions(html, (entry) =>
50
+ registeredRenditionSet(registry, tenantId, entry, (width) => renditionRecipes(entry, width)));
51
+ }
52
+
53
+ /**
54
+ * A rendered HTML document with registered renditions applied; any other response unchanged. A
55
+ * static-bundle response is served verbatim (its renditions were decided when it was built), and a
56
+ * registry that cannot be read leaves the page as rendered.
57
+ */
58
+ export async function withRegisteredRenditions(response: Response, input: {
59
+ tenantId: string | undefined;
60
+ readBucket: () => Promise<RenditionRegistryBucket | null | undefined>;
61
+ }): Promise<Response> {
62
+ if (!input.tenantId || response.status !== 200 || !response.body ||
63
+ !(response.headers.get("content-type") ?? "").includes("text/html") ||
64
+ response.headers.has("x-tot-static-bundle")) return response;
65
+ const html = await response.text();
66
+ const rebuilt = (body: string) => {
67
+ const headers = new Headers(response.headers);
68
+ headers.delete("content-length");
69
+ headers.delete("etag");
70
+ return new Response(body, { status: response.status, statusText: response.statusText, headers });
71
+ };
72
+ if (!responsiveImgSrcs(html).some((src) => pinnedRasterRef(src))) return rebuilt(html);
73
+ let registry: AssetRegistry | null = null;
74
+ try {
75
+ const bucket = await input.readBucket();
76
+ registry = bucket ? await readRenditionRegistry(bucket) : null;
77
+ } catch {
78
+ registry = null;
79
+ }
80
+ return rebuilt(registry ? applyRegisteredRenditions(html, input.tenantId, registry) : html);
81
+ }
82
+
83
+ /** Test seam: forget the isolate's cached registry. */
84
+ export function resetRenditionRegistryCache(): void {
85
+ cached = null;
86
+ }
@@ -1,18 +1,20 @@
1
1
  /**
2
- * The document's server response time for the evidence panel: the `total` the Worker stamps in
3
- * `Server-Timing` (`lib/observability/shared/serverTiming.ts`) next to the time this self-fetch
4
- * waited for the response headers (TTFB as seen from the Worker). Advisory — never gates promotion.
2
+ * The document's server response time for the evidence panel, measured in the viewer's browser
3
+ * (a Worker fetching its own zone is answered by the origin, never by this Worker, so a
4
+ * server-side self-fetch cannot see the render). The panel script fetches the home document
5
+ * same-origin with the viewer's credentials and `__preview` grant, then reports the `total` the
6
+ * Worker stamps in `Server-Timing` (`lib/observability/shared/serverTiming.ts`) beside the time to
7
+ * first byte. Advisory — never gates promotion.
5
8
  *
6
- * The self-fetch carries the viewer's preview grant so it measures the render a reviewer gets. A
7
- * gate page or any non-200 answer is not that render, so it is reported as not measured rather
8
- * than as a number.
9
+ * A gate page or any non-200 answer is not the render a reviewer gets, so it is reported as not
10
+ * measured rather than as a number.
9
11
  */
10
12
 
11
13
  export interface ServerResponseTime {
12
14
  measured: true;
13
15
  /** `total;dur` from the document's `Server-Timing`, or null when the header is absent. */
14
16
  serverTotalMs: number | null;
15
- /** Wall time until the response headers arrived. */
17
+ /** Time to first byte. */
16
18
  ttfbMs: number;
17
19
  /** The isolate was serving its first request (`isolate;desc="cold"`). */
18
20
  cold: boolean;
@@ -23,6 +25,8 @@ export interface ServerResponseTimeUnmeasured {
23
25
  reason: string;
24
26
  }
25
27
 
28
+ export type ServerResponseTimeResult = ServerResponseTime | ServerResponseTimeUnmeasured;
29
+
26
30
  /** Pull `total;dur` and the cold marker out of a `Server-Timing` header value. */
27
31
  export function parseServerTiming(header: string | null | undefined): Pick<ServerResponseTime, "serverTotalMs" | "cold"> {
28
32
  if (!header) return { serverTotalMs: null, cold: false };
@@ -34,35 +38,54 @@ export function parseServerTiming(header: string | null | undefined): Pick<Serve
34
38
  };
35
39
  }
36
40
 
37
- /**
38
- * The URL and headers that make a self-fetch of `path` the viewer's own render: the request's
39
- * `__preview` grant (query) and cookies are forwarded.
40
- */
41
- export function viewerRenderRequest(requestUrl: string, path: string, cookie: string | null): { url: URL; headers: Headers } {
42
- const source = new URL(requestUrl);
43
- const url = new URL(path, source.origin);
44
- const grant = source.searchParams.get("__preview");
45
- if (grant) url.searchParams.set("__preview", grant);
46
- const headers = new Headers();
47
- if (cookie) headers.set("cookie", cookie);
48
- return { url, headers };
41
+ /** Turn the home document's response into a measurement, or the reason it is not one. */
42
+ export function classifyHomeResponse(
43
+ res: { status: number; headers: { get(name: string): string | null } },
44
+ ttfbMs: number,
45
+ ): ServerResponseTimeResult {
46
+ const gate = res.headers.get("x-tot-gate");
47
+ if (gate) return { measured: false, reason: `the fetch was answered by the access gate (${gate}), not the store render` };
48
+ if (res.status !== 200) return { measured: false, reason: `the home document answered ${res.status}, not a render` };
49
+ return { measured: true, ...parseServerTiming(res.headers.get("server-timing")), ttfbMs: Math.round(ttfbMs) };
49
50
  }
50
51
 
51
- /** Self-fetch and report the server response time, or why it could not be measured. */
52
- export async function measureServerResponseTime(
53
- request: { url: URL; headers: Headers },
54
- now: () => number = () => performance.now(),
55
- ): Promise<ServerResponseTime | ServerResponseTimeUnmeasured> {
52
+ /** The sentence the panel shows for a result. */
53
+ export function describeServerResponseTime(result: ServerResponseTimeResult): string {
54
+ if (!result.measured) return `Not measured: ${result.reason}.`;
55
+ const server = result.serverTotalMs !== null ? `${result.serverTotalMs} ms server total (Server-Timing)` : "no Server-Timing header";
56
+ return `Home document: ${server} · ${result.ttfbMs} ms to first byte${result.cold ? " · cold isolate" : ""}. A hosted preview is a Worker render; a published static site is served from the CDN and is faster.`;
57
+ }
58
+
59
+ export interface BrowserDeps {
60
+ fetch: typeof fetch;
61
+ /** Resource-timing entries for a URL (`performance.getEntriesByName`). */
62
+ entries: (url: string) => PerformanceEntry[];
63
+ now: () => number;
64
+ }
65
+
66
+ /**
67
+ * Fetch the home document as the viewer and measure it. TTFB is the resource-timing
68
+ * `responseStart - requestStart` for that request, falling back to the wall time to headers.
69
+ */
70
+ export async function measureHomeDocument(homeUrl: URL, deps: BrowserDeps): Promise<ServerResponseTimeResult> {
56
71
  try {
57
- const started = now();
58
- const res = await fetch(request.url, { headers: request.headers, redirect: "manual" });
59
- const ttfbMs = now() - started;
60
- await res.body?.cancel();
61
- const gate = res.headers.get("x-tot-gate");
62
- if (gate) return { measured: false, reason: `the fetch was answered by the access gate (${gate}), not the store render` };
63
- if (res.status !== 200) return { measured: false, reason: `the home document answered ${res.status}, not a render` };
64
- return { measured: true, ...parseServerTiming(res.headers.get("server-timing")), ttfbMs: Math.round(ttfbMs) };
72
+ const started = deps.now();
73
+ const res = await deps.fetch(homeUrl.href, { credentials: "include", redirect: "manual", cache: "no-store" });
74
+ const wallMs = deps.now() - started;
75
+ await res.arrayBuffer();
76
+ const timing = deps.entries(homeUrl.href).at(-1) as PerformanceResourceTiming | undefined;
77
+ const ttfbMs = timing && timing.responseStart > 0 && timing.requestStart > 0 ? timing.responseStart - timing.requestStart : wallMs;
78
+ return classifyHomeResponse(res, ttfbMs);
65
79
  } catch {
66
80
  return { measured: false, reason: "the home document could not be fetched" };
67
81
  }
68
82
  }
83
+
84
+ /** Measure the home document and write the sentence into `target`. */
85
+ export async function mountServerResponseTime(target: Element, pageUrl: string, deps: BrowserDeps): Promise<void> {
86
+ const page = new URL(pageUrl);
87
+ const homeUrl = new URL(target.getAttribute("data-home-path") ?? "/", page.origin);
88
+ const grant = page.searchParams.get("__preview");
89
+ if (grant) homeUrl.searchParams.set("__preview", grant);
90
+ target.textContent = describeServerResponseTime(await measureHomeDocument(homeUrl, deps));
91
+ }
@@ -27,6 +27,8 @@ import { resolveCdnForwardRouting, resolveTenantRouting } from "@/lib/access/sha
27
27
  import { crossSiteFormRefusal, verifyCdnForward } from "@/lib/publication/shared/cdnForward";
28
28
  import { SAMPLE_CART_HANDOFF_URL, compileRouteTierIndexV1, resolvePublicationRoutePlanV1, type RouteTierIndexV1 , storefrontModeRequiresSignIn } from "@tot/public-runtime";
29
29
  import type { PublicationArtifactBucketV1, TenantConfig } from "@tot/public-runtime";
30
+ import { HTML_CACHE_POLICY_CAPTURE_PROBE } from "@tot/public-runtime";
31
+ import type { RenditionRegistryBucket } from "@/lib/assets/registeredRenditions";
30
32
  import { DEV_DRAFT_COOKIE } from "@/lib/developer-experience/dev/devDraft";
31
33
  import { applySecurityHeaders } from "@/lib/access/shared/securityHeaders";
32
34
  import { visitorRegionFor, withVisitorRegionCookie } from "@/lib/compliance/shared/visitorRegionCookie";
@@ -80,7 +82,16 @@ export const onRequest = defineMiddleware(async (context, next) => {
80
82
  const timing = context.locals.serverTiming ?? new ServerTiming();
81
83
  context.locals.serverTiming = timing;
82
84
  const seen: { cdnForwarded: boolean; assetBasePath?: string } = { cdnForwarded: false };
83
- const response = await serveRequest(context, next, seen, timing);
85
+ const served = await serveRequest(context, next, seen, timing);
86
+ // Registered responsive renditions on a rendered page, by the same addresses and markup the static
87
+ // bundle carries. The materialize render is left to the bundle build, which owns its renditions.
88
+ const { withRegisteredRenditions } = await import("@/lib/assets/registeredRenditions");
89
+ const response = outer && !context.request.headers.get(HTML_CACHE_POLICY_CAPTURE_PROBE)
90
+ ? await withRegisteredRenditions(served, {
91
+ tenantId: context.locals.tenant?.tenant_id,
92
+ readBucket: async () => (await readR2("STATIC_BUNDLES")) as unknown as RenditionRegistryBucket | undefined,
93
+ })
94
+ : served;
84
95
  const { scopePreviewPinnedImageUrls } = await import("@/lib/assets/preview-pinned-image-urls");
85
96
  const scoped = await scopePreviewPinnedImageUrls(response, context.locals.apiBasePath ?? seen.assetBasePath ?? "");
86
97
  const finished = withVisitorRegionCookie(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
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",
@@ -20,6 +20,7 @@
20
20
  "./hosted-image-media-types": "./src/hosted-image-media-types.ts",
21
21
  "./durable-assets": "./src/durable-assets.ts",
22
22
  "./asset-registry": "./src/asset-registry.ts",
23
+ "./responsive-renditions": "./src/responsive-renditions.ts",
23
24
  "./svg-sanitize": "./src/svg-sanitize.ts",
24
25
  "./pool-asset-headers": "./src/pool-asset-headers.ts",
25
26
  "./catalog-files": "./src/catalog-files.ts",
@@ -442,6 +442,32 @@ export function registeredRendition(
442
442
  return null;
443
443
  }
444
444
 
445
+ /** A source's complete rendition set: each rendition's width and pool entry, and the source's dimensions. */
446
+ export interface RenditionSet { renditions: { width: number; entry: string }[]; width: number; height: number }
447
+
448
+ /**
449
+ * Every registered rendition of `parent` for the recipes a build makes for a source of its registered
450
+ * width (`recipesFor`), or null unless the registry records the source's dimensions and holds every
451
+ * one of them for `tenantId` ({@link registeredRendition}). A partial set is null: its source is
452
+ * encoded whole, or served as its original.
453
+ */
454
+ export function registeredRenditionSet(
455
+ registry: AssetRegistry | null | undefined, tenantId: string, parent: string,
456
+ recipesFor: (width: number) => readonly VariantRecipe[],
457
+ ): RenditionSet | null {
458
+ const record = registry?.assets[parent];
459
+ if (!record?.width || !record.height) return null;
460
+ const recipes = recipesFor(record.width);
461
+ if (recipes.length === 0) return null;
462
+ const renditions: RenditionSet["renditions"] = [];
463
+ for (const recipe of recipes) {
464
+ const found = recipe.parent === parent ? registeredRendition(registry, tenantId, recipe) : null;
465
+ if (!found) return null;
466
+ renditions.push({ width: recipe.width, entry: found.entry });
467
+ }
468
+ return { renditions, width: record.width, height: record.height };
469
+ }
470
+
445
471
  /**
446
472
  * The tenants a record lists other than `tenantId`, without naming them: true when the address is
447
473
  * registered and this tenant is not among its tenants. Capture uses it to refuse a reference to
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Responsive renditions of a tenant's raster images: the recipe every producer makes them with and
3
+ * the markup every renderer points at them with. One module for both, so the static bundle
4
+ * (`scripts/publish/lib/responsive-images.mjs`, which encodes or reuses renditions while it
5
+ * materializes) and the Worker's rendered pages (`apps/storefront/src/lib/assets/registeredRenditions.ts`,
6
+ * which only reads the asset registry) name the same renditions by the same addresses with the same
7
+ * `<picture>` (ADR 0038 §13).
8
+ *
9
+ * A rendition's identity is its source's pool entry plus its recipe; its bytes live at their own
10
+ * address, `/_a/<sha256>.webp`. An `<img>` with renditions is wrapped in
11
+ * `<picture style="display:contents">` with a WebP `<source srcset>`; the `<img>` itself is untouched
12
+ * and stays the fallback. `sizes` comes from the tag's own `sizes`, else `100vw`. A tag that already
13
+ * has a `srcset`, sits inside a `<picture>`, or names a URL with a query or fragment is left alone.
14
+ *
15
+ * Import-free at runtime (type imports only), so plain node runs it from the publish scripts.
16
+ */
17
+ import type { RenditionSet, VariantRecipe } from "./asset-registry.js";
18
+
19
+ /** Widths a rendition is produced at, ascending; each is used only below the original's own width. */
20
+ export const RESPONSIVE_WIDTHS: readonly number[] = Object.freeze([480, 768, 1080, 1440, 1920, 2560]);
21
+ export const RENDITION_FORMAT = "webp";
22
+ /** Encoder quality for the WebP renditions. */
23
+ export const WEBP_QUALITY = 78;
24
+ /**
25
+ * The encoder every rendition is made with, part of each rendition's identity. It names the sharp
26
+ * release the lockfile pins (a test holds them equal), so an encoder upgrade is a recipe change:
27
+ * the next build mints new renditions beside the old ones.
28
+ */
29
+ export const RENDITION_ENCODER = "sharp-0.34.5";
30
+
31
+ /**
32
+ * The widths to emit for an original `width` px wide: every standard width below it, plus the
33
+ * original width itself (never upscaled).
34
+ */
35
+ export function responsiveWidths(width: number): number[] {
36
+ if (!Number.isInteger(width) || width <= 0) return [];
37
+ return [...RESPONSIVE_WIDTHS.filter((w) => w < width), width];
38
+ }
39
+
40
+ /** The recipe of `parent`'s rendition at `width`; `parent` is the source's pool entry, `<sha256><ext>`. */
41
+ export function renditionRecipe(parent: string, width: number): VariantRecipe {
42
+ return { kind: "variant", parent, width, format: RENDITION_FORMAT, quality: WEBP_QUALITY, encoder: RENDITION_ENCODER };
43
+ }
44
+
45
+ /** Every recipe a build makes for `parent` when the source is `width` px wide. */
46
+ export function renditionRecipes(parent: string, width: number): VariantRecipe[] {
47
+ return responsiveWidths(width).map((w) => renditionRecipe(parent, w));
48
+ }
49
+
50
+ const PICTURE_OR_IMG_RE = /<picture\b[\s\S]*?<\/picture\s*>|<img\b[^>]*>/gi;
51
+ const PINNED_RASTER_RE = /^((?:\/[A-Za-z0-9._~%-]+)*)\/_a\/([0-9a-f]{64}\.(?:jpe?g|png|webp))$/i;
52
+
53
+ /** The value of the double-quoted attribute `name` on `tag`, or undefined. */
54
+ export function imgAttr(tag: string, name: string): string | undefined {
55
+ return new RegExp(`\\s${name}="([^"]*)"`, "i").exec(tag)?.[1];
56
+ }
57
+
58
+ /** The `src` of an `<img>` tag renditions may be added to, or null. */
59
+ function eligibleSrc(tag: string): string | null {
60
+ if (imgAttr(tag, "srcset") !== undefined) return null;
61
+ const src = imgAttr(tag, "src")?.trim();
62
+ return src && !/[?#]/.test(src) ? src : null;
63
+ }
64
+
65
+ /** The `src` of every `<img>` in `html` renditions may be added to: no srcset, outside a `<picture>`. */
66
+ export function responsiveImgSrcs(html: string): string[] {
67
+ const out: string[] = [];
68
+ for (const [match] of html.matchAll(PICTURE_OR_IMG_RE)) {
69
+ if (/^<picture/i.test(match)) continue;
70
+ const src = eligibleSrc(match);
71
+ if (src) out.push(src);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * A pinned pool raster reference: its pool entry and the path prefix it is served under (`""` at the
78
+ * root, or a path-routed view's base, e.g. `/<domain>` on a shared preview host).
79
+ */
80
+ export function pinnedRasterRef(src: string): { prefix: string; entry: string } | null {
81
+ const match = PINNED_RASTER_RE.exec(src);
82
+ return match ? { prefix: match[1]!, entry: match[2]! } : null;
83
+ }
84
+
85
+ /**
86
+ * Wrap every eligible `<img>` whose `src` has a rendition set in a `<picture>` with a WebP `<source>`.
87
+ * `setFor` returns the set and the prefix each rendition address is served under.
88
+ */
89
+ export function rewriteResponsiveImgTags(
90
+ html: string,
91
+ setFor: (src: string) => { set: RenditionSet; prefix: string } | null | undefined,
92
+ ): string {
93
+ return html.replace(PICTURE_OR_IMG_RE, (match) => {
94
+ if (/^<picture/i.test(match)) return match;
95
+ const src = eligibleSrc(match);
96
+ const found = src ? setFor(src) : undefined;
97
+ if (!found?.set.renditions.length) return match;
98
+ const srcset = found.set.renditions.map(({ width, entry }) => `${found.prefix}/_a/${entry} ${width}w`).join(", ");
99
+ const sizes = imgAttr(match, "sizes") ?? "100vw";
100
+ return `<picture style="display:contents"><source type="image/webp" srcset="${srcset}" sizes="${sizes}">${match}</picture>`;
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Point every `<img>` that names a pinned pool raster at that source's registered renditions.
106
+ * `setForEntry` is the registry lookup for a source's pool entry; a source with no complete set keeps
107
+ * its original tag, so a missing rendition never puts an unserved address on the page.
108
+ */
109
+ export function rewritePinnedRenditions(html: string, setForEntry: (entry: string) => RenditionSet | null): string {
110
+ return rewriteResponsiveImgTags(html, (src) => {
111
+ const ref = pinnedRasterRef(src);
112
+ const set = ref ? setForEntry(ref.entry) : null;
113
+ return ref && set ? { set, prefix: ref.prefix } : null;
114
+ });
115
+ }