@certrev/cert-block 1.2.0-canary.7 → 1.2.1-canary.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +155 -63
  2. package/README.md +130 -21
  3. package/dist/builder/card-reader-css.d.ts +14 -0
  4. package/dist/builder/card-reader-css.d.ts.map +1 -0
  5. package/dist/builder/card-reader-css.js +17 -0
  6. package/dist/builder/card-reader-css.js.map +1 -0
  7. package/dist/builder/cert-review-card.d.ts +8 -0
  8. package/dist/builder/cert-review-card.d.ts.map +1 -1
  9. package/dist/builder/cert-review-card.js +271 -41
  10. package/dist/builder/cert-review-card.js.map +1 -1
  11. package/dist/builder/delivery-runtime.d.ts +15 -1
  12. package/dist/builder/delivery-runtime.d.ts.map +1 -1
  13. package/dist/builder/delivery-runtime.js +31 -3
  14. package/dist/builder/delivery-runtime.js.map +1 -1
  15. package/dist/builder/ssr-seed.d.ts +168 -0
  16. package/dist/builder/ssr-seed.d.ts.map +1 -0
  17. package/dist/builder/ssr-seed.js +421 -0
  18. package/dist/builder/ssr-seed.js.map +1 -0
  19. package/dist/verify/cache.d.ts +39 -2
  20. package/dist/verify/cache.d.ts.map +1 -1
  21. package/dist/verify/cache.js +61 -12
  22. package/dist/verify/cache.js.map +1 -1
  23. package/dist/verify/get-verified-delivery.d.ts +28 -3
  24. package/dist/verify/get-verified-delivery.d.ts.map +1 -1
  25. package/dist/verify/get-verified-delivery.js +46 -9
  26. package/dist/verify/get-verified-delivery.js.map +1 -1
  27. package/package.json +112 -112
  28. package/src/builder/card-reader-css.ts +19 -0
  29. package/src/builder/cert-review-card.tsx +359 -56
  30. package/src/builder/delivery-runtime.ts +33 -3
  31. package/src/builder/ssr-seed.ts +496 -0
  32. package/src/verify/cache.ts +87 -11
  33. package/src/verify/get-verified-delivery.ts +77 -11
@@ -0,0 +1,496 @@
1
+ /**
2
+ * The server render's envelope seed (1.2.0): what lets a hydrated delivery card paint with NO browser read.
3
+ *
4
+ * Through 1.1.1 the browser re-read the Delivery API for every card it hydrated, because nothing handed the server's
5
+ * read to it. A storefront whose Content-Security-Policy `connect-src` does not name the CertREV origin (e.l.f.'s
6
+ * Hydrogen store is one) blocked that read, the read settled as a suppress, and the card the server had painted
7
+ * vanished. So the painted face now carries the envelope it was painted from, as one attribute on its wrapper:
8
+ * `data-certrev-envelope` = `{ v: 1, baseUrl, externalId, design?, payload, signature?, renderDef }`. `payload` is the
9
+ * signed envelope's payload as the Delivery API served it, less the fields the def hides (below); `renderDef` is the
10
+ * def the face was painted with; `design` is the design pin the read was made with (the canary replica's `?design=N`),
11
+ * present only on a pinned read. React escapes the attribute like any other, so no `<script>` and no CSP `script-src`
12
+ * are involved.
13
+ *
14
+ * HIDDEN FIELDS ARE ABSENT FROM THE PAGE, NOT JUST FROM THE FACE. A def's `hidden` set (`reviewerPhoto`, `memo`) is the
15
+ * brand's statement that the page does not show those fields; a seed that carried them would put the reviewer's photo
16
+ * URL or the memo text in the page source all the same. So `withoutHiddenContent` removes them from the payload
17
+ * BEFORE the card paints and before it serializes, and both the server's face and the browser's are painted from the
18
+ * stripped payload (so they are identical, and a hidden memo is not on the page in either place):
19
+ * - `reviewerPhoto` hides `content.expert.photoUrl` and the author's `content.author.photoUrl` (the reader face paints
20
+ * the author's photo under the same switch);
21
+ * - `memo` hides `content.memo`.
22
+ * `display.showExpertPhoto: false` is the payload's own switch for the same photos and is stripped the same way (the
23
+ * portal never writes the URL for it either): both photo URLs go, as for `reviewerPhoto`, whatever the def hides.
24
+ * `content.expert.compensationCue` is NEVER touched: the cue is a locked disclosure no def can hide, and the face
25
+ * paints "Compensated expert" from it in the banner, the memo and the sidebar (the floating pill's own markup has
26
+ * no disclosure line, as in the portal's snippets; its modal paints the cue from the same field).
27
+ *
28
+ * THE SIGNATURE IS LEFT OUT WHENEVER ANYTHING WAS STRIPPED. The signature signs the full payload, so a payload with a
29
+ * field removed no longer verifies against it, and a seed that carried both would hand a verifier a pair that fails.
30
+ * Leaving it out costs nothing: the browser never re-checks a seed's signature (next paragraph), it re-runs only
31
+ * `renderVerdict` (lifecycle and subject) at its own clock, and neither reads a field the strip removes. A seed whose
32
+ * payload was not stripped keeps the signature, so a verifier can still check the page's HTML against the issuer key.
33
+ *
34
+ * The browser trusts a seed as far as it trusts the server HTML the seed sits in: whoever can write that HTML can
35
+ * already write any card they like, so the signature is NOT re-verified here (which would make hydration async, and
36
+ * Ed25519 in WebCrypto is not in every browser). The client still re-runs the lifecycle and subject policy
37
+ * (`renderVerdict`) at its own clock once hydration is done; `cert-review-card.tsx` does that.
38
+ *
39
+ * A PINNED CARD TAKES ONLY A SEED WITH THE SAME PIN (and an unpinned card only an unpinned one): the pin is part of
40
+ * the seed's key, as it is part of the read's, so the canary replica's `?design=7` card never paints the design the
41
+ * page carried for another version.
42
+ *
43
+ * WHEN THE SERVER HAD NO ENVELOPE TO PAINT (1.2.0) the same attribute carries a fallback record instead of a seed:
44
+ * `{ v: 1, baseUrl, externalId, design?, face }`, where `face` is `'options'` (CertREV could not be reached, so the
45
+ * server painted the block's own options face) or `'none'` (the server painted nothing: revoked, expired, unverifiable,
46
+ * for another entry). A browser that renders the card again (hydration, or React's client re-render after a hydration
47
+ * error elsewhere on the page) paints what the record says, so it neither reads the Delivery API nor shows a card the
48
+ * server withheld. A record holds no payload, so `parseSeed` never takes one for a seed. A `'none'` record sits on a
49
+ * `hidden` element with nothing in it.
50
+ *
51
+ * React-free and browser-only in effect: with no `document` (a server) `pageSeedFor` finds nothing, so a server never
52
+ * reads or keeps a seed and a multi-tenant render process holds nothing per page.
53
+ */
54
+
55
+ import { HIDEABLE_FIELDS } from '../components/render-def.js'
56
+ import type { CertContent, CertPayload, CertSignature } from '../contract/kernel.js'
57
+ import { type DesignPin, parseDesignPin, parseRenderDef } from '../verify/get-verified-delivery.js'
58
+ import type { CertChromeRenderDef } from './cert-chrome-data.js'
59
+
60
+ export const SEED_ATTRIBUTE = 'data-certrev-envelope'
61
+
62
+ export interface DeliverySeed {
63
+ readonly baseUrl: string
64
+ readonly externalId: string
65
+ /** The design pin the read was made with; absent on an unpinned read (every production brand). */
66
+ readonly design?: DesignPin
67
+ /** The payload as painted: with every field the def hides removed (`withoutHiddenContent`). */
68
+ readonly payload: CertPayload
69
+ /**
70
+ * The envelope's signature as served; absent when the payload was stripped (it no longer verifies), and when a
71
+ * cache was filled by hand (a test, a custom cache).
72
+ */
73
+ readonly signature?: CertSignature
74
+ readonly renderDef: CertChromeRenderDef | null
75
+ }
76
+
77
+ /** A seed as read from the page: the parsed seed and the attribute's exact text (re-rendered verbatim on hydration). */
78
+ export interface PageSeed {
79
+ readonly seed: DeliverySeed
80
+ readonly raw: string
81
+ }
82
+
83
+ /** What the server painted with no verified envelope: the block's options face, or nothing. */
84
+ export type FallbackFace = 'options' | 'none'
85
+
86
+ /** The record a server writes where it had no envelope to paint (the file header). */
87
+ export interface DeliveryFallback {
88
+ readonly baseUrl: string
89
+ readonly externalId: string
90
+ readonly design?: DesignPin
91
+ readonly face: FallbackFace
92
+ }
93
+
94
+ /** A fallback record as read from the page, with the attribute's exact text. */
95
+ export interface PageFallback {
96
+ readonly fallback: DeliveryFallback
97
+ readonly raw: string
98
+ }
99
+
100
+ /** Whatever the server wrote for one placement: a seed or a fallback record. */
101
+ export type PageRecord = PageSeed | PageFallback
102
+
103
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
104
+
105
+ /** A value worth hiding: anything but nothing (absent, null, the empty string). */
106
+ const hasValue = (v: unknown): boolean => v !== undefined && v !== null && v !== ''
107
+
108
+ /**
109
+ * The payload with the named people's photo URLs removed (`null`); the same object when neither had one. The card uses
110
+ * it for a photo the browser could not load (a brand's CSP `img-src`, a 404), so the face repaints with the initials.
111
+ */
112
+ export function withoutPhotos(payload: CertPayload, people: readonly ('expert' | 'author')[]): CertPayload {
113
+ let content: Record<string, unknown> = { ...payload.content }
114
+ let stripped = false
115
+ for (const person of people) {
116
+ const fields = content[person]
117
+ if (isRecord(fields) && hasValue(fields.photoUrl)) {
118
+ content = { ...content, [person]: { ...fields, photoUrl: null } }
119
+ stripped = true
120
+ }
121
+ }
122
+ return stripped ? { ...payload, content: content as unknown as CertContent } : payload
123
+ }
124
+
125
+ /**
126
+ * The payload with every content field the def's `hidden` set controls removed, and whether anything was; the two
127
+ * photo URLs go as well when the payload's own `display.showExpertPhoto` is `false` (exactly `false`, as the reader
128
+ * face reads it: absent or `null` shows the photo).
129
+ *
130
+ * Only the ids in `HIDEABLE_FIELDS` count (a def may list others; the faces ignore them, so they hide nothing). A
131
+ * removed text field becomes `null`, so the content keeps the shape the contract types give it and every face reads it
132
+ * as absent. Nothing but those fields changes: the subject and lifecycle (what `renderVerdict` reads), the cue, the
133
+ * dates, the links and the names are the payload's own. Never throws on a hostile shape: a payload whose `expert` or
134
+ * `author` is not an object has no photo to strip.
135
+ */
136
+ export function withoutHiddenContent(
137
+ payload: CertPayload,
138
+ renderDef: CertChromeRenderDef | null,
139
+ ): { readonly payload: CertPayload; readonly stripped: boolean } {
140
+ const hidden = (renderDef?.hidden ?? []).filter((id) => (HIDEABLE_FIELDS as readonly string[]).includes(id))
141
+ const display: unknown = (payload.content as unknown as Record<string, unknown> | undefined)?.display
142
+ const photoOff = isRecord(display) && display.showExpertPhoto === false
143
+ if (hidden.length === 0 && !photoOff) return { payload, stripped: false }
144
+ let next = photoOff || hidden.includes('reviewerPhoto') ? withoutPhotos(payload, ['expert', 'author']) : payload
145
+ if (hidden.includes('memo') && hasValue(next.content.memo)) {
146
+ next = { ...next, content: { ...next.content, memo: null } as unknown as CertContent }
147
+ }
148
+ return { payload: next, stripped: next !== payload }
149
+ }
150
+
151
+ /**
152
+ * The seed for a verified read: the payload stripped of what the def hides, and the signature only when nothing was
153
+ * stripped. The card paints from this seed's payload, on the server and in the browser alike.
154
+ */
155
+ export function seedFor(read: {
156
+ readonly baseUrl: string
157
+ readonly externalId: string
158
+ readonly design?: DesignPin
159
+ readonly payload: CertPayload
160
+ readonly signature?: CertSignature
161
+ readonly renderDef: CertChromeRenderDef | null
162
+ }): DeliverySeed {
163
+ const { payload, stripped } = withoutHiddenContent(read.payload, read.renderDef)
164
+ return {
165
+ baseUrl: read.baseUrl,
166
+ externalId: read.externalId,
167
+ ...(read.design ? { design: read.design } : {}),
168
+ payload,
169
+ ...(read.signature && !stripped ? { signature: read.signature } : {}),
170
+ renderDef: read.renderDef,
171
+ }
172
+ }
173
+
174
+ const serializedPin = (design: DesignPin | undefined) =>
175
+ design ? { design: { version: design.version, ...(design.brand === undefined ? {} : { brand: design.brand }) } } : {}
176
+
177
+ export function serializeSeed(seed: DeliverySeed): string {
178
+ return JSON.stringify({
179
+ v: 1,
180
+ baseUrl: seed.baseUrl,
181
+ externalId: seed.externalId,
182
+ ...serializedPin(seed.design),
183
+ payload: seed.payload,
184
+ ...(seed.signature ? { signature: seed.signature } : {}),
185
+ renderDef: seed.renderDef,
186
+ })
187
+ }
188
+
189
+ export function serializeFallback(fallback: DeliveryFallback): string {
190
+ return JSON.stringify({
191
+ v: 1,
192
+ baseUrl: fallback.baseUrl,
193
+ externalId: fallback.externalId,
194
+ ...serializedPin(fallback.design),
195
+ face: fallback.face,
196
+ })
197
+ }
198
+
199
+ /** A pin a record names, judged by the same grammar the card's own pin is: undefined when absent, null when bad. */
200
+ function parsedPin(design: unknown): DesignPin | undefined | null {
201
+ if (design === undefined) return undefined
202
+ const judged = isRecord(design)
203
+ ? parseDesignPin({ design: design.version, ...(design.brand === undefined ? {} : { designBrand: design.brand }) })
204
+ : 'invalid'
205
+ return judged === 'invalid' || judged === undefined ? null : judged
206
+ }
207
+
208
+ /** The seed in one attribute value, or null for anything that is not one (never throws). */
209
+ export function parseSeed(raw: string): DeliverySeed | null {
210
+ let value: unknown
211
+ try {
212
+ value = JSON.parse(raw)
213
+ } catch {
214
+ return null
215
+ }
216
+ if (!isRecord(value) || value.v !== 1) return null
217
+ const { baseUrl, externalId, design, payload, signature, renderDef } = value
218
+ if (typeof baseUrl !== 'string' || typeof externalId !== 'string') return null
219
+ if (!isRecord(payload) || !isRecord(payload.subject) || !isRecord(payload.lifecycle) || !isRecord(payload.content)) {
220
+ return null
221
+ }
222
+ // A pin the seed names is judged by the same grammar the card's own pin is; one that fails it is not a seed.
223
+ const pin = parsedPin(design)
224
+ if (pin === null) return null
225
+ return {
226
+ baseUrl,
227
+ externalId,
228
+ ...(pin ? { design: pin } : {}),
229
+ payload: payload as unknown as CertPayload,
230
+ ...(isRecord(signature) ? { signature: signature as unknown as CertSignature } : {}),
231
+ renderDef: parseRenderDef(renderDef),
232
+ }
233
+ }
234
+
235
+ /** A fallback record in one attribute value, or null for anything that is not one (a seed included; never throws). */
236
+ export function parseFallback(raw: string): DeliveryFallback | null {
237
+ let value: unknown
238
+ try {
239
+ value = JSON.parse(raw)
240
+ } catch {
241
+ return null
242
+ }
243
+ if (!isRecord(value) || value.v !== 1 || 'payload' in value) return null
244
+ const { baseUrl, externalId, design, face } = value
245
+ if (typeof baseUrl !== 'string' || typeof externalId !== 'string') return null
246
+ if (face !== 'options' && face !== 'none') return null
247
+ const pin = parsedPin(design)
248
+ if (pin === null) return null
249
+ return { baseUrl, externalId, ...(pin ? { design: pin } : {}), face }
250
+ }
251
+
252
+ /** One placement's key: the origin, the entry and the pin (the read's own key, so a pin never shares a seed). */
253
+ function seedKey(baseUrl: string, externalId: string, design: DesignPin | undefined): string {
254
+ return design ? `${baseUrl}|${externalId}|design=${design.version}|${design.brand ?? ''}` : `${baseUrl}|${externalId}`
255
+ }
256
+
257
+ /**
258
+ * The seeds (and fallback records) this page's server HTML carried, by placement, for the page's lifetime: a card
259
+ * mounted later by a client-side render (an in-app navigation back to a server-rendered article, or React's client
260
+ * re-render after a hydration error) paints what the server painted, as the hydrated card it replaces would have.
261
+ */
262
+ const pageSeeds = new Map<string, PageRecord>()
263
+
264
+ const recordKey = (record: PageRecord): string => {
265
+ const r = 'seed' in record ? record.seed : record.fallback
266
+ return seedKey(r.baseUrl, r.externalId, r.design)
267
+ }
268
+
269
+ /** The record one element's attribute carries: a seed, a fallback record, or null for anything else. */
270
+ function recordOf(el: Element): PageRecord | null {
271
+ const raw = el.getAttribute(SEED_ATTRIBUTE)
272
+ if (!raw) return null
273
+ const seed = parseSeed(raw)
274
+ if (seed) return { seed, raw }
275
+ const fallback = parseFallback(raw)
276
+ return fallback ? { fallback, raw } : null
277
+ }
278
+
279
+ /** Every seed and fallback record under `root` (the root element itself included), in document order. */
280
+ function scanRecords(root: Document | Element): PageRecord[] {
281
+ const found: PageRecord[] = []
282
+ const elements = Array.from(root.querySelectorAll(`[${SEED_ATTRIBUTE}]`))
283
+ if (root.nodeType === 1 && (root as Element).hasAttribute(SEED_ATTRIBUTE)) elements.unshift(root as Element)
284
+ for (const el of elements) {
285
+ const record = recordOf(el)
286
+ if (record) found.push(record)
287
+ }
288
+ return found
289
+ }
290
+
291
+ /** Every seed and fallback record the live document carries, in document order. */
292
+ function scanDocument(): PageRecord[] {
293
+ return scanRecords(document)
294
+ }
295
+
296
+ /**
297
+ * The record the server wrote for this placement and pin: its seed, or its fallback record. By default: from the map,
298
+ * else from one scan of the document (which also records every other one on the page). With `live`, the map is not
299
+ * consulted: the document is scanned and decides, so what a hydrating card learns is what the server HTML in front of
300
+ * it carries, not what an earlier page in this process left behind. `undefined` when the page carries none, and always
301
+ * on a server.
302
+ */
303
+ export function pageRecordFor(
304
+ baseUrl: string,
305
+ externalId: string,
306
+ design?: DesignPin,
307
+ options: { readonly live?: boolean } = {},
308
+ ): PageRecord | undefined {
309
+ const key = seedKey(baseUrl, externalId, design)
310
+ if (typeof document === 'undefined') return undefined
311
+ const known = options.live ? undefined : pageSeeds.get(key)
312
+ if (known) return known
313
+ let inDocument = false
314
+ for (const found of scanDocument()) {
315
+ const k = recordKey(found)
316
+ if (k === key) inDocument = true
317
+ if (options.live || !pageSeeds.has(k)) pageSeeds.set(k, found)
318
+ }
319
+ // A live read answers for the document alone: a record an earlier page left in the map is not one this page carries.
320
+ return options.live && !inDocument ? undefined : pageSeeds.get(key)
321
+ }
322
+
323
+ /** `pageRecordFor`, seeds only: the seed the server painted for this placement and pin. */
324
+ export function pageSeedFor(
325
+ baseUrl: string,
326
+ externalId: string,
327
+ design?: DesignPin,
328
+ options: { readonly live?: boolean } = {},
329
+ ): PageSeed | undefined {
330
+ const record = pageRecordFor(baseUrl, externalId, design, options)
331
+ return record && 'seed' in record ? record : undefined
332
+ }
333
+
334
+ /** A Suspense boundary's comment marker as React's server render writes it: `$` (done), `$?` (pending), `$!`, `$~`. */
335
+ const BOUNDARY_MARKER = /^\$[?!~]?$/
336
+
337
+ /**
338
+ * Whether the live document holds server-rendered React HTML: any Suspense boundary marker. The delivery card sits in
339
+ * a `<Suspense>` of its own, so a server render of one always leaves a marker, a card the server painted nothing for
340
+ * (revoked, expired, unreachable) included, and a client render never does. This is how a card that is hydrating tells
341
+ * a browser hydrating server HTML (the document holds it) from a server render that merely runs where a `document`
342
+ * exists (a test's jsdom, a shim): there the document does not hold the HTML being produced.
343
+ */
344
+ export function pageHoldsServerHtml(): boolean {
345
+ if (typeof document === 'undefined' || !document.documentElement) return false
346
+ const walker = document.createTreeWalker(document.documentElement, 128 /* NodeFilter.SHOW_COMMENT */)
347
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
348
+ if (BOUNDARY_MARKER.test((node as Comment).data)) return true
349
+ }
350
+ return false
351
+ }
352
+
353
+ /**
354
+ * RECORDS THAT STREAM IN AFTER THE CARD RENDERS (1.2.1). A streaming server (Hydrogen and React Router 7 stream every
355
+ * visitor that is not a crawler) sends the page's shell first, and a card whose read is still running after it, in a
356
+ * hidden `<div id="S:n">` past `</html>` that React's inline `$RC` script moves into the card's place. The browser
357
+ * starts hydrating as soon as its module script loads, which can be before that part arrives. React then waits for
358
+ * the card's boundary, but two things on a real page make it give up the server HTML and render the card on the
359
+ * client before the record is in the document:
360
+ * - an update to the waiting boundary (a parent that sets state as it mounts): React renders the boundary on the
361
+ * client (React error #421) and deletes its markers, so `$RC` finds no place and drops the streamed part;
362
+ * - a mismatch above it (a page `<style>` whose text the server escaped, #418/#425): React renders the whole document
363
+ * on the client (#423). It removes the server's `<html>` element and appends its own, and the parser writes the
364
+ * rest of the stream into the removed element, where no script runs and no query of the document looks.
365
+ * Either way the card rendered on the client with no record in front of it, and through 1.2.0 painted the options face
366
+ * at once (nothing, for a block with no options) and never looked again: the record the server wrote arrived a moment
367
+ * later and was thrown away. Now a card the client renders while the document is still loading, with no record for its
368
+ * placement, waits (`StreamedRecordCard` in `cert-review-card.tsx`): `watchStreamedRecords` watches the document, and
369
+ * the server's `<html>` element should React remove it, for records added while the page loads, keeps each new one
370
+ * as the scan does (a placement already known keeps its record), and tells the waiting cards. When the page has
371
+ * loaded it scans both once more, lets the removed element go and stops. A card whose record never came paints what a
372
+ * client mount paints. A page that never renders a card this way never starts a watch.
373
+ */
374
+
375
+ /**
376
+ * The document element the page was still parsing when this module loaded: the server's `<html>`, which a card first
377
+ * rendered after React replaced it could not otherwise find. Held weakly, so a page that never watches keeps nothing.
378
+ */
379
+ const rootWhileLoading: WeakRef<Element> | null =
380
+ typeof document !== 'undefined' &&
381
+ document.readyState === 'loading' &&
382
+ document.documentElement &&
383
+ typeof WeakRef === 'function'
384
+ ? new WeakRef(document.documentElement)
385
+ : null
386
+
387
+ interface StreamWatch {
388
+ readonly observer: MutationObserver | null
389
+ /** The server's `<html>` element(s), watched in their own right so a removed one is still read. */
390
+ readonly roots: Element[]
391
+ readonly listeners: Set<() => void>
392
+ readonly onLoaded: () => void
393
+ loaded: boolean
394
+ }
395
+
396
+ let streamWatch: StreamWatch | null = null
397
+
398
+ /** Keep every record under `node` whose placement the map does not hold yet; true when one was new. */
399
+ function keepNewRecords(node: Node): boolean {
400
+ if (node.nodeType !== 1) return false
401
+ let kept = false
402
+ for (const record of scanRecords(node as Element)) {
403
+ const key = recordKey(record)
404
+ if (pageSeeds.has(key)) continue
405
+ pageSeeds.set(key, record)
406
+ kept = true
407
+ }
408
+ return kept
409
+ }
410
+
411
+ /**
412
+ * Start watching for streamed records, once per page and only while the document is loading; a no-op otherwise. The
413
+ * card calls it as it renders, not from an effect: React's client render of the whole document removes the server's
414
+ * `<html>` in the commit that follows that render, so the watch must hold the element before then.
415
+ */
416
+ export function watchStreamedRecords(): void {
417
+ if (streamWatch || typeof document === 'undefined' || document.readyState !== 'loading') return
418
+ const roots: Element[] = []
419
+ for (const root of [rootWhileLoading?.deref(), document.documentElement]) {
420
+ if (root && !roots.includes(root)) roots.push(root)
421
+ }
422
+ const listeners = new Set<() => void>()
423
+ const notify = () => {
424
+ for (const listener of Array.from(listeners)) listener()
425
+ }
426
+ const keepAdded = (mutations: readonly MutationRecord[]) => {
427
+ let kept = false
428
+ for (const mutation of mutations) {
429
+ for (const node of Array.from(mutation.addedNodes)) if (keepNewRecords(node)) kept = true
430
+ }
431
+ return kept
432
+ }
433
+ const observer =
434
+ typeof MutationObserver === 'function'
435
+ ? new MutationObserver((mutations) => {
436
+ if (keepAdded(mutations)) notify()
437
+ })
438
+ : null
439
+ observer?.observe(document, { childList: true, subtree: true })
440
+ for (const root of roots) observer?.observe(root, { childList: true, subtree: true })
441
+ const watch: StreamWatch = {
442
+ observer,
443
+ roots,
444
+ listeners,
445
+ loaded: false,
446
+ onLoaded: () => {
447
+ document.removeEventListener('DOMContentLoaded', watch.onLoaded)
448
+ if (observer) {
449
+ keepAdded(observer.takeRecords())
450
+ observer.disconnect()
451
+ }
452
+ if (document.documentElement) keepNewRecords(document.documentElement)
453
+ for (const root of roots) keepNewRecords(root)
454
+ roots.length = 0
455
+ watch.loaded = true
456
+ notify()
457
+ },
458
+ }
459
+ document.addEventListener('DOMContentLoaded', watch.onLoaded)
460
+ streamWatch = watch
461
+ }
462
+
463
+ /** `useSyncExternalStore`'s subscribe for a waiting card: told when a streamed record is kept, and when the page has loaded. */
464
+ export function subscribeStreamedRecords(listener: () => void): () => void {
465
+ watchStreamedRecords()
466
+ const watch = streamWatch
467
+ if (!watch || watch.loaded) return () => {}
468
+ watch.listeners.add(listener)
469
+ return () => {
470
+ watch.listeners.delete(listener)
471
+ }
472
+ }
473
+
474
+ /**
475
+ * A waiting card's answer: the record for its placement and pin once the page holds one (`pageRecordFor`), else
476
+ * `'pending'` while a watch runs and the document is still loading, else `'done'`: the page loaded with no record.
477
+ */
478
+ export function streamedRecordFor(
479
+ baseUrl: string,
480
+ externalId: string,
481
+ design?: DesignPin,
482
+ ): PageRecord | 'pending' | 'done' {
483
+ const record = pageRecordFor(baseUrl, externalId, design)
484
+ if (record) return record
485
+ return streamWatch && !streamWatch.loaded ? 'pending' : 'done'
486
+ }
487
+
488
+ /** Test seam: forget every seed (a new page), and stop any watch for streamed records. */
489
+ export function forgetPageSeedsForTests(): void {
490
+ pageSeeds.clear()
491
+ if (streamWatch) {
492
+ document.removeEventListener('DOMContentLoaded', streamWatch.onLoaded)
493
+ streamWatch.observer?.disconnect()
494
+ streamWatch = null
495
+ }
496
+ }
@@ -11,6 +11,13 @@
11
11
  * ONE fetch, not N.
12
12
  * 3. Negative-caches failures briefly so a hard-down origin doesn't get hammered, while
13
13
  * still recovering quickly (short negative TTL).
14
+ * 4. (1.2.0, opt-in through `staleMs`) STALE-WHILE-REVALIDATE and STALE-IF-ERROR: a positive
15
+ * entry past its TTL but inside the stale window is served at once while ONE background
16
+ * refresh replaces it; a refresh that only failed to reach the origin (`isTransient`)
17
+ * keeps the stale entry instead of overwriting it. So an origin that is slow or briefly
18
+ * down costs the page nothing it already had. A definitive answer (a revocation, say)
19
+ * replaces the entry the moment the refresh lands. With the default `staleMs` of 0 an
20
+ * expired entry is gone, exactly as before 1.2.0.
14
21
  *
15
22
  * It is deliberately tiny + dependency-free (a Map + timestamps) so it runs in any server
16
23
  * runtime (Node, edge, workerd). For multi-instance deployments this is per-instance L1 and
@@ -25,6 +32,18 @@ export interface TtlCacheOptions {
25
32
  readonly negativeTtlMs?: number
26
33
  /** Max entries before LRU-ish eviction of the oldest insert (default 1000). */
27
34
  readonly maxEntries?: number
35
+ /**
36
+ * 1.2.0: how long past its TTL a POSITIVE entry may still be served while a refresh runs (default 0: never). A
37
+ * negative entry is never served stale.
38
+ */
39
+ readonly staleMs?: number
40
+ /**
41
+ * 1.2.0: how long an unsettled load may be joined by later callers (default: no limit). Past it, the next caller
42
+ * starts a fresh load. WHY: a runtime may cancel the I/O of a request that has finished (Cloudflare workerd does),
43
+ * which can leave a background refresh's promise pending forever; without a limit every later caller would join
44
+ * it and none would ever refresh again.
45
+ */
46
+ readonly inflightMaxMs?: number
28
47
  /** Injectable clock for deterministic tests. */
29
48
  readonly now?: () => number
30
49
  }
@@ -35,28 +54,64 @@ interface Entry<V> {
35
54
  readonly negative: boolean
36
55
  }
37
56
 
57
+ interface Inflight<V> {
58
+ readonly promise: Promise<V>
59
+ readonly startedAt: number
60
+ }
61
+
38
62
  export class TtlCache<V> {
39
63
  private readonly store = new Map<string, Entry<V>>()
40
- private readonly inflight = new Map<string, Promise<V>>()
64
+ private readonly inflight = new Map<string, Inflight<V>>()
41
65
  private readonly ttlMs: number
42
66
  private readonly negativeTtlMs: number
43
67
  private readonly maxEntries: number
68
+ private readonly staleMs: number
69
+ private readonly inflightMaxMs: number
44
70
  private readonly now: () => number
45
71
 
46
72
  constructor(opts: TtlCacheOptions = {}) {
47
73
  this.ttlMs = opts.ttlMs ?? 60_000
48
74
  this.negativeTtlMs = opts.negativeTtlMs ?? 5_000
49
75
  this.maxEntries = opts.maxEntries ?? 1000
76
+ this.staleMs = opts.staleMs !== undefined && opts.staleMs > 0 ? opts.staleMs : 0
77
+ this.inflightMaxMs = opts.inflightMaxMs ?? Number.POSITIVE_INFINITY
50
78
  this.now = opts.now ?? Date.now
51
79
  }
52
80
 
53
- /** Read a live (non-expired) entry, or undefined. Prunes the entry if expired. */
54
- peek(key: string): V | undefined {
81
+ /** The entry while it may still be served (fresh, or positive and inside the stale window); prunes it past that. */
82
+ private servable(key: string): Entry<V> | undefined {
55
83
  const e = this.store.get(key)
56
84
  if (!e) return undefined
85
+ const now = this.now()
86
+ if (e.expiresAt > now) return e
87
+ if (!e.negative && now < e.expiresAt + this.staleMs) return e
88
+ this.store.delete(key)
89
+ return undefined
90
+ }
91
+
92
+ /** Read a live (non-expired) entry, or undefined. Never serves a stale one; prunes an entry past its stale window. */
93
+ peek(key: string): V | undefined {
94
+ const e = this.servable(key)
95
+ return e && e.expiresAt > this.now() ? e.value : undefined
96
+ }
97
+
98
+ /**
99
+ * The synchronous stale-while-revalidate read: a fresh value; or a positive entry past its TTL but inside the stale
100
+ * window, returned at once while this starts (or joins) ONE background refresh through `loader`; else undefined,
101
+ * and the caller loads (`getOrLoad`). The refresh caches as `getOrLoad` does, with the same arguments; its failure
102
+ * is swallowed (the stale value was already served, and a thrown loader is never cached).
103
+ */
104
+ peekOrRefresh(
105
+ key: string,
106
+ loader: () => Promise<V>,
107
+ isNegative?: (v: V) => boolean,
108
+ positiveTtlMs?: number,
109
+ isTransient?: (v: V) => boolean,
110
+ ): V | undefined {
111
+ const e = this.servable(key)
112
+ if (!e) return undefined
57
113
  if (e.expiresAt <= this.now()) {
58
- this.store.delete(key)
59
- return undefined
114
+ this.load(key, loader, isNegative, positiveTtlMs, isTransient).catch(() => undefined)
60
115
  }
61
116
  return e.value
62
117
  }
@@ -74,29 +129,50 @@ export class TtlCache<V> {
74
129
  * It deliberately does NOT apply to a negative result: a caller asking for a 10-minute
75
130
  * positive TTL is asking for fewer origin reads, not for a transient failure to be
76
131
  * remembered for ten minutes (see the negative-TTL rationale in the module header).
132
+ *
133
+ * 1.2.0: inside the stale window (`staleMs`) an expired positive entry is returned at once and refreshed in the
134
+ * background (`peekOrRefresh`). `isTransient(value)` marks a result that says only "the origin was not reached"
135
+ * (a timeout, a 5xx): when a servable positive entry exists, such a result is NOT cached over it and the caller
136
+ * gets that entry's value instead (stale-if-error). With no entry to keep, it is cached as `isNegative` says.
77
137
  */
78
138
  async getOrLoad(
79
139
  key: string,
80
140
  loader: () => Promise<V>,
81
141
  isNegative?: (v: V) => boolean,
82
142
  positiveTtlMs?: number,
143
+ isTransient?: (v: V) => boolean,
83
144
  ): Promise<V> {
84
- const cached = this.peek(key)
85
- if (cached !== undefined) return cached
145
+ const served = this.peekOrRefresh(key, loader, isNegative, positiveTtlMs, isTransient)
146
+ if (served !== undefined) return served
147
+ return this.load(key, loader, isNegative, positiveTtlMs, isTransient)
148
+ }
86
149
 
150
+ /** Single-flight load and cache (joins an unsettled load younger than `inflightMaxMs`). */
151
+ private load(
152
+ key: string,
153
+ loader: () => Promise<V>,
154
+ isNegative: ((v: V) => boolean) | undefined,
155
+ positiveTtlMs: number | undefined,
156
+ isTransient: ((v: V) => boolean) | undefined,
157
+ ): Promise<V> {
87
158
  const existing = this.inflight.get(key)
88
- if (existing) return existing
159
+ if (existing && this.now() - existing.startedAt < this.inflightMaxMs) return existing.promise
89
160
 
90
- const promise = (async () => {
161
+ const promise: Promise<V> = (async () => {
91
162
  const value = await loader()
163
+ if (isTransient?.(value)) {
164
+ const kept = this.servable(key)
165
+ if (kept && !kept.negative) return kept.value
166
+ }
92
167
  const negative = isNegative ? isNegative(value) : false
93
168
  this.set(key, value, negative, negative ? undefined : positiveTtlMs)
94
169
  return value
95
170
  })().finally(() => {
96
- this.inflight.delete(key)
171
+ // Only this load's own slot: a newer load that replaced an abandoned one keeps its place.
172
+ if (this.inflight.get(key)?.promise === promise) this.inflight.delete(key)
97
173
  })
98
174
 
99
- this.inflight.set(key, promise)
175
+ this.inflight.set(key, { promise, startedAt: this.now() })
100
176
  return promise
101
177
  }
102
178