@tokenoftrust/storefront-runner 3.4.1 → 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.
- package/apps/storefront/src/lib/assets/registeredRenditions.ts +86 -0
- package/apps/storefront/src/middleware/index.ts +12 -1
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/asset-registry.ts +26 -0
- package/packages/public-runtime/src/responsive-renditions.ts +115 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
|
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.
|
|
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
|
+
}
|