@tokenoftrust/storefront-runner 3.3.5 → 3.4.1

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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.3.5",
3
+ "version": "3.4.1",
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",
@@ -34,10 +34,17 @@ export interface AssetProvenance {
34
34
  at: string;
35
35
  }
36
36
 
37
+ /**
38
+ * How a derived pool object was made from its `parent` entry. A rendition's identity is its parent's
39
+ * address plus this recipe; the bytes it produces get their own address, so a changed recipe or
40
+ * encoder mints new renditions and never rewrites a URL already served.
41
+ */
42
+ export interface VariantRecipe { kind: "variant"; parent: string; width: number; format: string; quality: number; encoder: string }
43
+
37
44
  export type AssetRecipe =
38
45
  | { kind: "original" }
39
46
  | { kind: "sanitized"; sanitizer: string; source: string }
40
- | { kind: "variant"; parent: string; width: number; format: string; encoder: string };
47
+ | VariantRecipe;
41
48
 
42
49
  export type RetentionClass = "release" | "durable" | "external-use" | "removal-requested";
43
50
  export interface RetentionTransition {
@@ -55,6 +62,9 @@ export interface AssetRecord {
55
62
  address: string;
56
63
  mediaType?: string;
57
64
  size?: number;
65
+ /** Intrinsic pixel dimensions of an image, as decoded (orientation applied). */
66
+ width?: number;
67
+ height?: number;
58
68
  provenance?: AssetProvenance;
59
69
  /** Tenants authorized to reference this address: each one proved it holds the bytes. */
60
70
  tenants?: string[];
@@ -79,6 +89,8 @@ export interface AssetWrite {
79
89
  entry: string;
80
90
  mediaType?: string;
81
91
  size?: number;
92
+ width?: number;
93
+ height?: number;
82
94
  provenance?: AssetProvenance;
83
95
  tenant?: string;
84
96
  owner?: string;
@@ -103,6 +115,11 @@ const MAX_TENANTS = 256;
103
115
  const MAX_RENDITIONS = 64;
104
116
  const MAX_TRANSITIONS = 32;
105
117
  const MAX_LOCATOR = 2048;
118
+ const MAX_DIMENSION = 65535;
119
+
120
+ function validDimension(value: unknown): value is number {
121
+ return Number.isSafeInteger(value) && (value as number) > 0 && (value as number) <= MAX_DIMENSION;
122
+ }
106
123
 
107
124
  /**
108
125
  * The retention transitions an owner or a designation may make. A record with no retention facet is
@@ -176,8 +193,10 @@ function normalizeRecipe(raw: unknown): AssetRecipe | null {
176
193
  if (raw.kind === "variant" && typeof raw.parent === "string" && entryAddress(raw.parent) !== null &&
177
194
  Number.isSafeInteger(raw.width) && (raw.width as number) > 0 && (raw.width as number) <= 16384 &&
178
195
  typeof raw.format === "string" && TOKEN_RE.test(raw.format) &&
196
+ Number.isSafeInteger(raw.quality) && (raw.quality as number) >= 1 && (raw.quality as number) <= 100 &&
179
197
  typeof raw.encoder === "string" && TOKEN_RE.test(raw.encoder)) {
180
- return { kind: "variant", parent: raw.parent, width: raw.width as number, format: raw.format, encoder: raw.encoder };
198
+ return { kind: "variant", parent: raw.parent, width: raw.width as number, format: raw.format,
199
+ quality: raw.quality as number, encoder: raw.encoder };
181
200
  }
182
201
  return null;
183
202
  }
@@ -227,6 +246,11 @@ export function normalizeAssetRecord(entry: string, raw: unknown): AssetRecord |
227
246
  const record: AssetRecord = { address };
228
247
  if (typeof raw.mediaType === "string" && MEDIA_TYPE_RE.test(raw.mediaType)) record.mediaType = raw.mediaType;
229
248
  if (Number.isSafeInteger(raw.size) && (raw.size as number) > 0) record.size = raw.size as number;
249
+ // Dimensions are one fact: both or neither.
250
+ if (validDimension(raw.width) && validDimension(raw.height)) {
251
+ record.width = raw.width;
252
+ record.height = raw.height;
253
+ }
230
254
  const provenance = normalizeProvenance(raw.provenance);
231
255
  if (provenance) record.provenance = provenance;
232
256
  if (Array.isArray(raw.tenants)) {
@@ -294,6 +318,10 @@ export function applyAssetWrite(existing: AssetRecord | undefined, write: AssetW
294
318
  const record: AssetRecord = existing ? structuredCloneRecord(existing) : { address };
295
319
  if (record.mediaType === undefined && write.mediaType !== undefined && MEDIA_TYPE_RE.test(write.mediaType)) record.mediaType = write.mediaType;
296
320
  if (record.size === undefined && Number.isSafeInteger(write.size) && (write.size as number) > 0) record.size = write.size as number;
321
+ if (record.width === undefined && validDimension(write.width) && validDimension(write.height)) {
322
+ record.width = write.width;
323
+ record.height = write.height;
324
+ }
297
325
  if (record.provenance === undefined && write.provenance) {
298
326
  const provenance = normalizeProvenance(write.provenance);
299
327
  if (provenance) record.provenance = provenance;
@@ -386,6 +414,34 @@ export function authorizeAssetReference(
386
414
  };
387
415
  }
388
416
 
417
+ export interface RegisteredRendition { entry: string; size: number }
418
+
419
+ /**
420
+ * The registered rendition of `recipe.parent` made by exactly `recipe`, or null. It counts only when
421
+ * the parent lists it among its renditions, its record's recipe has the same width, format, quality
422
+ * and encoder, it is registered to `tenantId`, and the store holds a byte verification of it (so it
423
+ * is in the pool). A recipe or encoder that differs in any field finds nothing: it mints its own
424
+ * renditions. The record's own `parent` is not compared: two sources with the same pixels share
425
+ * rendition bytes, the record keeps its first writer's parent, and each parent's link is the proof
426
+ * that a build made it from that parent.
427
+ */
428
+ export function registeredRendition(
429
+ registry: AssetRegistry | null | undefined, tenantId: string, recipe: VariantRecipe,
430
+ ): RegisteredRendition | null {
431
+ const wanted = normalizeRecipe(recipe);
432
+ if (!registry || !wanted || wanted.kind !== "variant") return null;
433
+ for (const entry of registry.assets[wanted.parent]?.renditions ?? []) {
434
+ const record = registry.assets[entry];
435
+ const made = record?.recipe;
436
+ if (!record?.verified || !record.tenants?.includes(tenantId) || made?.kind !== "variant") continue;
437
+ if (made.width === wanted.width && made.format === wanted.format &&
438
+ made.quality === wanted.quality && made.encoder === wanted.encoder) {
439
+ return { entry, size: record.verified.size };
440
+ }
441
+ }
442
+ return null;
443
+ }
444
+
389
445
  /**
390
446
  * The tenants a record lists other than `tenantId`, without naming them: true when the address is
391
447
  * registered and this tenant is not among its tenants. Capture uses it to refuse a reference to