@ultimat3/render 27.7.0 → 27.8.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.
- package/CLAUDE.md +1 -1
- package/README.md +16 -0
- package/package.json +6 -6
- package/src/duration.ts +14 -0
- package/src/index.ts +2 -1
- package/src/modes.ts +4 -0
- package/src/registry.ts +18 -26
- package/src/render-isr-cooldown.ts +44 -0
- package/src/render-isr-key.ts +103 -0
- package/src/render-isr-result.ts +125 -0
- package/src/render-isr-routes.ts +98 -0
- package/src/render-isr-store.ts +55 -11
- package/src/render-isr-types.ts +102 -0
- package/src/render-isr.ts +251 -296
- package/src/revalidate-shape.ts +100 -0
- package/src/route-status.ts +21 -0
- package/src/route.ts +29 -0
- package/src/server.ts +19 -10
package/src/render-isr.ts
CHANGED
|
@@ -5,125 +5,39 @@
|
|
|
5
5
|
* and tag-driven staleness (an action's `invalidates` marks exactly the dependent routes).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { CacheTag, Revalidator } from '@ultimat3/cache';
|
|
8
|
+
import type { CacheTag, Revalidator, TagRevalidator } from '@ultimat3/cache';
|
|
9
9
|
import {
|
|
10
10
|
dependentsOfKind,
|
|
11
|
+
EVERY_TAG,
|
|
11
12
|
invalidateTags,
|
|
12
13
|
markInvalidated,
|
|
14
|
+
parseTag,
|
|
13
15
|
registerDependent,
|
|
14
16
|
registerRevalidator,
|
|
15
17
|
sampleFence,
|
|
16
|
-
|
|
18
|
+
tagsIntersect,
|
|
17
19
|
unregisterDependent,
|
|
18
20
|
} from '@ultimat3/cache';
|
|
19
|
-
import type { Scheduler } from '@ultimat3/core';
|
|
20
21
|
import { finiteCount, logger, renderThrowable, singleFlight } from '@ultimat3/core';
|
|
21
|
-
import {
|
|
22
|
-
import { parseTtlMs } from './duration';
|
|
23
|
-
import { finiteStatus, isRenderStatus } from './finite-status';
|
|
22
|
+
import { finiteStatus } from './finite-status';
|
|
24
23
|
import type { RouteDescriptor } from './registry';
|
|
25
24
|
import { describePages } from './registry';
|
|
26
|
-
import
|
|
25
|
+
import { DEFAULT_ISR_FAILURE_COOLDOWN_MS, failureCooldown } from './render-isr-cooldown';
|
|
26
|
+
import { entryTtlMs, isrResult } from './render-isr-result';
|
|
27
|
+
import type { IsrPolicy } from './render-isr-routes';
|
|
28
|
+
import { isrPolicyOf, isrRouteFor } from './render-isr-routes';
|
|
29
|
+
import type { IsrEntry } from './render-isr-store';
|
|
27
30
|
import { memoryIsrStore } from './render-isr-store';
|
|
28
|
-
import {
|
|
31
|
+
import type {
|
|
32
|
+
Generation,
|
|
33
|
+
IsrController,
|
|
34
|
+
IsrControllerOptions,
|
|
35
|
+
IsrFence,
|
|
36
|
+
IsrRendered,
|
|
37
|
+
IsrRenderFn,
|
|
38
|
+
} from './render-isr-types';
|
|
39
|
+
import { contentHash } from './render-static';
|
|
29
40
|
import type { RenderResult } from './route';
|
|
30
|
-
import { compilePattern } from './route-pattern';
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The reserved query parameter the negotiated locale rides in. A parameter and not a prefix
|
|
34
|
-
* because `routePathOf` splits a key at its `?`: a `es:/blog` key would match no route, so
|
|
35
|
-
* `descriptorFor` would answer `undefined` and a declared `revalidate: { ttl }` would silently
|
|
36
|
-
* become tag-only. Reserved spelling, so an app's own `?locale=` stays its own dimension.
|
|
37
|
-
*/
|
|
38
|
-
export const ISR_LOCALE_PARAM = '__x_locale';
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The ISR store key for one request URL: pathname, the negotiated LOCALE, and the query, **params
|
|
42
|
-
* sorted**.
|
|
43
|
-
*
|
|
44
|
-
* Exported because deriving it is the caller's job and there may only be ONE derivation — a
|
|
45
|
-
* server that keyed on `url.pathname` while the store believed it held a whole URL is the shape
|
|
46
|
-
* of #171. Sorting makes `?a=1&b=2` and `?b=2&a=1` one entry rather than two renders of one page.
|
|
47
|
-
*
|
|
48
|
-
* The locale is REQUIRED, and required as an argument rather than read from the ambient context so
|
|
49
|
-
* that every call site has to answer: a document is rendered with `<html lang>` and every `t()` in
|
|
50
|
-
* the request's own locale, so one entry per path served visitor 2 the document negotiated for
|
|
51
|
-
* visitor 1 — for the whole TTL, and with `s-maxage` telling the CDN to do the same. The time zone
|
|
52
|
-
* is deliberately NOT a dimension: it is unbounded where a locale set is declared, and an `isr`
|
|
53
|
-
* page is a shared artifact, so a date on one belongs in an explicit zone the page itself names.
|
|
54
|
-
*
|
|
55
|
-
* A query-carrying URL therefore gets its own entry, which is correct and is not free: a crawler
|
|
56
|
-
* appending `?utm_source=…` mints one entry per value. That is bounded, not unbounded —
|
|
57
|
-
* `DEFAULT_ISR_MAX_ENTRIES` evicts least-recently-generated first — and a bounded cache that
|
|
58
|
-
* thrashes is the right failure next to an unbounded one that answers the wrong document.
|
|
59
|
-
*/
|
|
60
|
-
export function isrKey(url: URL, locale: string): string {
|
|
61
|
-
const params = new URLSearchParams(url.search);
|
|
62
|
-
params.set(ISR_LOCALE_PARAM, locale);
|
|
63
|
-
params.sort();
|
|
64
|
-
return `${url.pathname}?${params.toString()}`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* The path the ROUTE TABLE knows a key by. Every lookup that asks the table a question — the
|
|
69
|
-
* descriptor, and therefore the TTL and the tags — has to undo what the key carries beyond the
|
|
70
|
-
* route, in the order the router does before it matches (`@ultimat3/http`'s `routing` stage):
|
|
71
|
-
*
|
|
72
|
-
* - the query: `descriptorFor('/blog?page=2')` matches no route;
|
|
73
|
-
* - the routed locale prefix: `/en/blog/a` is the `/blog/:slug` route in English. The key KEEPS the
|
|
74
|
-
* prefix — it is the URL the document was rendered for, `canonical` and all — and the table has
|
|
75
|
-
* never heard of it. Left on, a prefixed page matched no route: `ttlMs: null` and no tag edge, so
|
|
76
|
-
* it was fresh forever while the default locale's copy of the same page went stale on schedule.
|
|
77
|
-
*
|
|
78
|
-
* `unlocalizedPath` is i18n's, the one the router's own split is built on: a segment that names no
|
|
79
|
-
* routed locale is left alone, so `/fr/blog` is still no route.
|
|
80
|
-
*/
|
|
81
|
-
function routePathOf(key: string): string {
|
|
82
|
-
const query = key.indexOf('?');
|
|
83
|
-
return unlocalizedPath(query === -1 ? key : key.slice(0, query));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* A render that also answers a status — what a loader's `withStatus(404, …)` becomes once the
|
|
88
|
-
* document is built. A bare string is the 200 every render before this one was: the union is
|
|
89
|
-
* additive, and a render function that never learned the object shape keeps compiling.
|
|
90
|
-
*/
|
|
91
|
-
export interface IsrRendered {
|
|
92
|
-
readonly html: string;
|
|
93
|
-
readonly status: number;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export type IsrRenderFn = (path: string) => string | IsrRendered | Promise<string | IsrRendered>;
|
|
97
|
-
|
|
98
|
-
/** One shape for the generator, so nothing below branches on what the render handed back. */
|
|
99
|
-
function renderedOf(rendered: string | IsrRendered): IsrRendered {
|
|
100
|
-
return typeof rendered === 'string' ? { html: rendered, status: 200 } : rendered;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export interface IsrServeResult {
|
|
104
|
-
readonly state: IsrState;
|
|
105
|
-
readonly entry: IsrEntry;
|
|
106
|
-
readonly result: RenderResult;
|
|
107
|
-
/** True when this request started a background regeneration. */
|
|
108
|
-
readonly regenerating: boolean;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export interface IsrControllerOptions {
|
|
112
|
-
readonly store?: IsrStore;
|
|
113
|
-
readonly buildId?: string;
|
|
114
|
-
readonly now?: () => number;
|
|
115
|
-
/** Route table provider — defaults to the real registry. */
|
|
116
|
-
readonly routes?: () => readonly RouteDescriptor[];
|
|
117
|
-
/** ISR-route dependents for a tag set; defaults to `@ultimat3/cache`'s graph. */
|
|
118
|
-
readonly isrDependents?: (tags: readonly CacheTag[]) => readonly string[];
|
|
119
|
-
/**
|
|
120
|
-
* How long one regeneration may hold its path, in whole ms (≥ 1). Defaults to
|
|
121
|
-
* `DEFAULT_ISR_REGENERATE_DEADLINE_MS`. There is no "forever": that is the bug it bounds.
|
|
122
|
-
*/
|
|
123
|
-
readonly regenerateDeadlineMs?: number | undefined;
|
|
124
|
-
/** Injected so the deadline is provable without waiting one out. */
|
|
125
|
-
readonly schedule?: Scheduler | undefined;
|
|
126
|
-
}
|
|
127
41
|
|
|
128
42
|
/**
|
|
129
43
|
* The ceiling on one regeneration's hold on its path — `@ultimat3/http`'s default
|
|
@@ -135,21 +49,18 @@ export interface IsrControllerOptions {
|
|
|
135
49
|
*/
|
|
136
50
|
export const DEFAULT_ISR_REGENERATE_DEADLINE_MS = 30_000;
|
|
137
51
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
* memo, LRU, Redis and the CDN. Returns a detach function for tests and reloads.
|
|
151
|
-
*/
|
|
152
|
-
attach(): () => void;
|
|
52
|
+
/** A 5xx is the app's "the read failed, ask again" — an answer about a moment, never a document. */
|
|
53
|
+
const isServerError = (status: number): boolean => status >= 500;
|
|
54
|
+
|
|
55
|
+
const PROCESS_FENCE: IsrFence = { sample: sampleFence, mark: markInvalidated };
|
|
56
|
+
|
|
57
|
+
/** The store's fence is keyed by tag ENTITY: `post` for `post` and `post:1` alike. */
|
|
58
|
+
const entitiesOf = (tags: readonly CacheTag[]): readonly string[] =>
|
|
59
|
+
[...new Set(tags.map((one) => one.entity))].sort();
|
|
60
|
+
|
|
61
|
+
/** One shape for the generator, so nothing below branches on what the render handed back. */
|
|
62
|
+
function renderedOf(rendered: string | IsrRendered): IsrRendered {
|
|
63
|
+
return typeof rendered === 'string' ? { html: rendered, status: 200 } : rendered;
|
|
153
64
|
}
|
|
154
65
|
|
|
155
66
|
/**
|
|
@@ -185,16 +96,35 @@ export function isrController(options: IsrControllerOptions = {}): IsrController
|
|
|
185
96
|
*/
|
|
186
97
|
const latest = new Map<string, object>();
|
|
187
98
|
const registered = new Set<string>();
|
|
99
|
+
const fences = options.fence ?? PROCESS_FENCE;
|
|
100
|
+
const failed = failureCooldown<Generation>(
|
|
101
|
+
finiteCount(
|
|
102
|
+
'isrController',
|
|
103
|
+
'failureCooldownMs',
|
|
104
|
+
options.failureCooldownMs ?? DEFAULT_ISR_FAILURE_COOLDOWN_MS,
|
|
105
|
+
),
|
|
106
|
+
now,
|
|
107
|
+
);
|
|
188
108
|
|
|
189
|
-
|
|
190
|
-
const path = routePathOf(key);
|
|
191
|
-
const table = routes();
|
|
192
|
-
return table.find((r) => r.path === path) ?? matchersOf(table).find((m) => m.test(path))?.route;
|
|
193
|
-
}
|
|
109
|
+
const descriptorFor = (key: string): RouteDescriptor | undefined => isrRouteFor(routes(), key);
|
|
194
110
|
|
|
195
|
-
/**
|
|
196
|
-
|
|
197
|
-
|
|
111
|
+
/**
|
|
112
|
+
* A key NO route owns is rendered for the request that asked and never stored. Its ttl and tags
|
|
113
|
+
* are its route's, so stored it had neither: fresh for ever, on no tag edge, and found by no
|
|
114
|
+
* bust — the one page a purge could not take down. `@ultimat3/cli` keys every request on the
|
|
115
|
+
* path the router matched, so this is a caller that derived a key some other way. Told once.
|
|
116
|
+
*/
|
|
117
|
+
let toldUnowned = false;
|
|
118
|
+
function tellUnowned(path: string): void {
|
|
119
|
+
if (toldUnowned) return;
|
|
120
|
+
toldUnowned = true;
|
|
121
|
+
logger.warn('isr.key.unowned', {
|
|
122
|
+
path,
|
|
123
|
+
cause:
|
|
124
|
+
'no route in the table matches this key, so it has no ttl and no tags to be revalidated by — it was rendered and not stored',
|
|
125
|
+
fix: 'isrKey(url, locale) // key on the URL the router matched, base path removed; never on a hand-built string',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
198
128
|
|
|
199
129
|
/**
|
|
200
130
|
* A rendered page joins the invalidation graph under its route's tags, so `/blog/a` and
|
|
@@ -203,10 +133,15 @@ export function isrController(options: IsrControllerOptions = {}): IsrController
|
|
|
203
133
|
function registerPath(path: string, descriptor: RouteDescriptor | undefined): void {
|
|
204
134
|
if (descriptor === undefined || registered.has(path)) return;
|
|
205
135
|
if (descriptor.revalidateTags.length === 0) return;
|
|
206
|
-
registerDependent(descriptor.revalidateTags.map(
|
|
136
|
+
registerDependent(descriptor.revalidateTags.map(parseTag), { kind: 'isr-route', id: path });
|
|
207
137
|
registered.add(path);
|
|
208
138
|
}
|
|
209
139
|
|
|
140
|
+
function forgetPath(path: string): void {
|
|
141
|
+
if (!registered.delete(path)) return;
|
|
142
|
+
unregisterDependent({ kind: 'isr-route', id: path });
|
|
143
|
+
}
|
|
144
|
+
|
|
210
145
|
/**
|
|
211
146
|
* A registration is only true while the store still holds the page. The store evicts silently
|
|
212
147
|
* and offers no callback — and a custom `IsrStore` need not have one at all — so the store's own
|
|
@@ -216,12 +151,10 @@ export function isrController(options: IsrControllerOptions = {}): IsrController
|
|
|
216
151
|
*/
|
|
217
152
|
function forgetEvictedPaths(): void {
|
|
218
153
|
if (registered.size === 0) return;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
registered.delete(path);
|
|
224
|
-
}
|
|
154
|
+
// A page whose FIRST render is still running is in no store yet and is not evicted: forgotten
|
|
155
|
+
// here, a bust arriving before it settled found it in neither the graph nor the store.
|
|
156
|
+
const live = new Set([...store.paths(), ...flight.keys()]);
|
|
157
|
+
for (const path of registered) if (!live.has(path)) forgetPath(path);
|
|
225
158
|
}
|
|
226
159
|
|
|
227
160
|
function isFresh(entry: IsrEntry): boolean {
|
|
@@ -231,13 +164,25 @@ export function isrController(options: IsrControllerOptions = {}): IsrController
|
|
|
231
164
|
return now() - entry.generatedAt < ttlMs;
|
|
232
165
|
}
|
|
233
166
|
|
|
167
|
+
/**
|
|
168
|
+
* `revalidate.maxStale`: past its TTL by more than the route allows, a copy is not an answer any
|
|
169
|
+
* more. Without a bound, stale-while-revalidate serves the stored page ONCE MORE however old it
|
|
170
|
+
* is — a page nobody asked for in a week answers its next visitor with last week's document.
|
|
171
|
+
*/
|
|
172
|
+
function pastMaxStale(entry: IsrEntry, policy: IsrPolicy): boolean {
|
|
173
|
+
const ttlMs = entryTtlMs(entry);
|
|
174
|
+
if (policy.maxStaleMs === null || ttlMs === null) return false;
|
|
175
|
+
return now() - entry.generatedAt >= ttlMs + policy.maxStaleMs;
|
|
176
|
+
}
|
|
177
|
+
|
|
234
178
|
/** One regeneration of `path`; `led` is told when THIS call started it rather than joined one. */
|
|
235
|
-
function
|
|
236
|
-
return flight.run(path, async (): Promise<
|
|
179
|
+
function generate(path: string, render: IsrRenderFn, led?: () => void): Promise<Generation> {
|
|
180
|
+
return flight.run(path, async (): Promise<Generation> => {
|
|
237
181
|
led?.();
|
|
238
182
|
const run = {};
|
|
239
183
|
latest.set(path, run);
|
|
240
184
|
const descriptor = descriptorFor(path);
|
|
185
|
+
const policy = isrPolicyOf(descriptor);
|
|
241
186
|
try {
|
|
242
187
|
// BEFORE the render, not after: `revalidateByTags` reads the graph, so a bust arriving
|
|
243
188
|
// while a cold path's first render was in flight could not see the page it was
|
|
@@ -248,46 +193,147 @@ export function isrController(options: IsrControllerOptions = {}): IsrController
|
|
|
248
193
|
// and a `markStale` landing in between was then ERASED by `store.set({ stale: false })`.
|
|
249
194
|
// For a tag-only route `isFresh` is true forever, so the process went on serving pre-write
|
|
250
195
|
// HTML for the rest of its life. One mechanism, not a second one grown here.
|
|
251
|
-
const fence =
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
196
|
+
const fence = fences.sample({ key: path, tags: policy.tags });
|
|
197
|
+
// And the STORE's own fence, for a store another process writes: read before the rows are.
|
|
198
|
+
const entities = entitiesOf(policy.tags);
|
|
199
|
+
const reading = store.tagFence?.sample(entities);
|
|
200
|
+
const rendered = renderedOf(await render(path));
|
|
256
201
|
const entry: IsrEntry = {
|
|
257
202
|
path,
|
|
258
|
-
html,
|
|
259
|
-
hash: contentHash(html),
|
|
203
|
+
html: rendered.html,
|
|
204
|
+
hash: contentHash(rendered.html),
|
|
260
205
|
generatedAt: now(),
|
|
261
|
-
ttlMs:
|
|
206
|
+
ttlMs: policy.ttlMs,
|
|
262
207
|
stale: false,
|
|
263
208
|
// Screened at generation, the one place a status enters the store: a `NaN` written here
|
|
264
209
|
// would be served for the whole TTL as a `RangeError` on every hit.
|
|
265
|
-
status: finiteStatus('IsrRenderFn', status),
|
|
210
|
+
status: finiteStatus('IsrRenderFn', rendered.status),
|
|
266
211
|
};
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
212
|
+
const current = latest.get(path) === run;
|
|
213
|
+
const unstorable =
|
|
214
|
+
descriptor === undefined || isServerError(rendered.status) || rendered.noStore === true;
|
|
215
|
+
// Kept only when nothing invalidated the page while it rendered — in this process (the
|
|
216
|
+
// fence) or in any other (the store's) — and no newer run started. Refused, never
|
|
217
|
+
// published stale-flagged: the next request re-renders from rows that include the write.
|
|
218
|
+
const kept = !unstorable && fence.isValid() && current && write(entry, entities, reading);
|
|
219
|
+
if (isServerError(rendered.status)) {
|
|
220
|
+
// Never stored, and never over a good copy: one upstream blip stored for the TTL plus a
|
|
221
|
+
// stale serve was minutes of 503 for a read that recovered in a second. The copy the
|
|
222
|
+
// store holds stays (still stale, so the next request tries again); a miss has none, and
|
|
223
|
+
// answers this 5xx to its own request only.
|
|
224
|
+
logger.warn('isr.render.unstored', { path, status: String(rendered.status) });
|
|
225
|
+
} else if (descriptor === undefined) {
|
|
226
|
+
tellUnowned(path);
|
|
227
|
+
} else if (rendered.noStore === true) {
|
|
228
|
+
// The loader's answer is not a failure, so the copy held is no longer the page.
|
|
229
|
+
if (current) store.delete(path);
|
|
230
|
+
}
|
|
271
231
|
forgetEvictedPaths();
|
|
272
|
-
|
|
232
|
+
// What the store did not keep, the edge may not keep either: a page rendered from rows
|
|
233
|
+
// read BEFORE a purge would otherwise reach the CDN after the purge had already run there.
|
|
234
|
+
return { entry, cacheable: kept, fenced: !unstorable && !kept };
|
|
273
235
|
} finally {
|
|
274
236
|
if (latest.get(path) === run) latest.delete(path);
|
|
275
237
|
}
|
|
276
238
|
});
|
|
277
239
|
}
|
|
278
240
|
|
|
279
|
-
|
|
280
|
-
|
|
241
|
+
/** The one write: through the store's own fence when it has one, so a stale reading is refused. */
|
|
242
|
+
function write(
|
|
243
|
+
entry: IsrEntry,
|
|
244
|
+
entities: readonly string[],
|
|
245
|
+
reading: string | undefined,
|
|
246
|
+
): boolean {
|
|
247
|
+
if (store.tagFence === undefined || reading === undefined) {
|
|
248
|
+
store.set(entry);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
return store.tagFence.setIfCurrent(entry, entities, reading);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A generation FOR one request. A request that joined a render someone else started may have
|
|
256
|
+
* arrived after the bust that fenced it, so it does not take that page: it renders once more,
|
|
257
|
+
* and by then the fenced flight has settled, so this one began after the bust. A 5xx is
|
|
258
|
+
* remembered for the cooldown, so the requests right behind it are not each a failing render.
|
|
259
|
+
*/
|
|
260
|
+
async function generateFor(path: string, render: IsrRenderFn): Promise<Generation> {
|
|
261
|
+
let led = false;
|
|
262
|
+
const lead = (): void => {
|
|
263
|
+
led = true;
|
|
264
|
+
};
|
|
265
|
+
const joined = await generate(path, render, lead);
|
|
266
|
+
const made = !led && joined.fenced ? await generate(path, render) : joined;
|
|
267
|
+
if (isServerError(made.entry.status ?? 200)) failed.remember(path, made);
|
|
268
|
+
return made;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function regenerate(path: string, render: IsrRenderFn): Promise<IsrEntry> {
|
|
272
|
+
return (await generate(path, render)).entry;
|
|
281
273
|
}
|
|
282
274
|
|
|
283
275
|
function markStale(path: string): boolean {
|
|
284
276
|
// The mark is recorded whether or not the store holds the page: a regeneration already in
|
|
285
277
|
// flight for a path this store has never held is exactly the case the fence above exists for,
|
|
286
278
|
// and `invalidateTags`' own fanout only marks the TAGS.
|
|
287
|
-
|
|
279
|
+
fences.mark({ key: path });
|
|
288
280
|
return store.markStale(path);
|
|
289
281
|
}
|
|
290
282
|
|
|
283
|
+
/**
|
|
284
|
+
* What a tag bust does to one page, as its route declared (`revalidate.onInvalidate`). `'purge'`
|
|
285
|
+
* DELETES: the next request is a miss that renders and blocks, where `'stale'` answers the copy
|
|
286
|
+
* once more, however old — the right trade for a price list, and the wrong one for an article
|
|
287
|
+
* that was withdrawn. The fence is voided either way, so a render in flight cannot put it back —
|
|
288
|
+
* and its FLIGHT is evicted, so a request arriving after this call leads a render of its own
|
|
289
|
+
* instead of joining one that read its rows before the write (up to the 30 s deadline of them).
|
|
290
|
+
*/
|
|
291
|
+
function invalidate(path: string): void {
|
|
292
|
+
failed.clear(path);
|
|
293
|
+
flight.evict(path);
|
|
294
|
+
if (isrPolicyOf(descriptorFor(path)).onInvalidate !== 'purge') {
|
|
295
|
+
markStale(path);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
fences.mark({ key: path });
|
|
299
|
+
store.delete(path);
|
|
300
|
+
forgetPath(path);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Everything HELD under these tags, asked of the store, the flights and the route table rather
|
|
305
|
+
* than of the graph. The graph is this process's memory of what it rendered: an entry another
|
|
306
|
+
* controller wrote into a shared store, one that outlived a restart, or a cold page whose first
|
|
307
|
+
* render is still running, has no edge there — and under `'purge'` that is the withdrawn page,
|
|
308
|
+
* still served. No route carrying the tags is the common case, and costs no store read.
|
|
309
|
+
*
|
|
310
|
+
* By ROUTE tags: a row bust (`post:1`) reaches every stored page of a route tagged `post`. An
|
|
311
|
+
* entry records no tags of its own, so this errs toward one render too many, never one too few.
|
|
312
|
+
*/
|
|
313
|
+
function revalidateHeld(tags: readonly CacheTag[] | typeof EVERY_TAG): readonly string[] {
|
|
314
|
+
const table = routes();
|
|
315
|
+
// A flush (`EVERY_TAG`) is a bust of every tag at once: every route that carries one owns.
|
|
316
|
+
const owns = (route: RouteDescriptor): boolean =>
|
|
317
|
+
tags === EVERY_TAG
|
|
318
|
+
? route.revalidateTags.length > 0
|
|
319
|
+
: tagsIntersect(tags, route.revalidateTags.map(parseTag));
|
|
320
|
+
const owners = new Set(table.filter(owns));
|
|
321
|
+
if (owners.size === 0) return [];
|
|
322
|
+
const owned = (key: string): boolean => {
|
|
323
|
+
const route = isrRouteFor(table, key);
|
|
324
|
+
return route !== undefined && owners.has(route);
|
|
325
|
+
};
|
|
326
|
+
// The store's fence FIRST: a write racing this call from another process is refused from here.
|
|
327
|
+
const busted =
|
|
328
|
+
tags === EVERY_TAG ? [...owners].flatMap((r) => r.revalidateTags.map(parseTag)) : tags;
|
|
329
|
+
store.tagFence?.bump(entitiesOf(busted));
|
|
330
|
+
for (const key of flight.keys()) if (owned(key)) invalidate(key);
|
|
331
|
+
const held = store.paths().filter(owned);
|
|
332
|
+
// A page the graph already named was marked a moment ago; it is reported, not marked twice.
|
|
333
|
+
for (const path of held) if (store.get(path)?.stale !== true) invalidate(path);
|
|
334
|
+
return held;
|
|
335
|
+
}
|
|
336
|
+
|
|
291
337
|
return {
|
|
292
338
|
store: () => store,
|
|
293
339
|
inflight: () => flight.size,
|
|
@@ -295,56 +341,75 @@ export function isrController(options: IsrControllerOptions = {}): IsrController
|
|
|
295
341
|
markStale,
|
|
296
342
|
|
|
297
343
|
async serve(path, render) {
|
|
298
|
-
const
|
|
344
|
+
const descriptor = descriptorFor(path);
|
|
345
|
+
const policy = isrPolicyOf(descriptor);
|
|
346
|
+
const answer = (entry: IsrEntry, servedStale = false, cacheable = true): RenderResult =>
|
|
347
|
+
isrResult(entry, {
|
|
348
|
+
buildId,
|
|
349
|
+
policy,
|
|
350
|
+
servedStale,
|
|
351
|
+
cacheable,
|
|
352
|
+
ageMs: Math.max(0, now() - entry.generatedAt),
|
|
353
|
+
});
|
|
354
|
+
let cached = store.get(path);
|
|
355
|
+
// A copy under a key no route owns (a shared store an earlier release wrote) is not served:
|
|
356
|
+
// nothing could have invalidated it since.
|
|
357
|
+
if (cached !== undefined && (descriptor === undefined || pastMaxStale(cached, policy))) {
|
|
358
|
+
store.delete(path);
|
|
359
|
+
cached = undefined;
|
|
360
|
+
}
|
|
299
361
|
|
|
300
362
|
if (cached === undefined) {
|
|
301
|
-
const
|
|
302
|
-
const result =
|
|
303
|
-
return { state: 'miss', entry, result, regenerating: false };
|
|
363
|
+
const made = failed.get(path) ?? (await generateFor(path, render));
|
|
364
|
+
const result = answer(made.entry, false, made.cacheable);
|
|
365
|
+
return { state: 'miss', entry: made.entry, result, regenerating: false };
|
|
304
366
|
}
|
|
305
367
|
|
|
368
|
+
// A hit on a page ANOTHER controller rendered into a shared store joins the graph here, so
|
|
369
|
+
// the report of the next bust names it; the bust itself reaches it through `heldUnder`.
|
|
370
|
+
registerPath(path, descriptor);
|
|
306
371
|
if (isFresh(cached)) {
|
|
307
|
-
return {
|
|
308
|
-
state: 'hit',
|
|
309
|
-
entry: cached,
|
|
310
|
-
result: toResult(cached, buildId, tagsOf(path)),
|
|
311
|
-
regenerating: false,
|
|
312
|
-
};
|
|
372
|
+
return { state: 'hit', entry: cached, result: answer(cached), regenerating: false };
|
|
313
373
|
}
|
|
314
374
|
|
|
315
|
-
// stale-while-revalidate: answer from the stale copy now, refresh behind the request
|
|
375
|
+
// stale-while-revalidate: answer from the stale copy now, refresh behind the request —
|
|
376
|
+
// unless the last refresh just failed, which the next one a moment later would repeat.
|
|
316
377
|
let started = false;
|
|
317
|
-
|
|
378
|
+
if (failed.get(path) !== undefined) {
|
|
379
|
+
return { state: 'stale', entry: cached, result: answer(cached, true), regenerating: false };
|
|
380
|
+
}
|
|
381
|
+
void generate(path, render, () => {
|
|
318
382
|
started = true;
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
};
|
|
383
|
+
})
|
|
384
|
+
.then((made) => {
|
|
385
|
+
if (isServerError(made.entry.status ?? 200)) failed.remember(path, made);
|
|
386
|
+
})
|
|
387
|
+
.catch((error: unknown) => {
|
|
388
|
+
// `renderThrowable`, never `.message`/`String()`: this `.catch` is the last frame under a
|
|
389
|
+
// route's own render function, and `String()` raises on a null-prototype object — the
|
|
390
|
+
// handler that exists to REPORT the failure became a second, unhandled rejection.
|
|
391
|
+
logger.warn('isr.regenerate.failed', { path, error: renderThrowable(error) });
|
|
392
|
+
});
|
|
393
|
+
return { state: 'stale', entry: cached, result: answer(cached, true), regenerating: started };
|
|
331
394
|
},
|
|
332
395
|
|
|
333
396
|
revalidateByTags(tags) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
397
|
+
// By TAG first, as `invalidateTags` does before it calls in here: a cold page whose first
|
|
398
|
+
// render is running may be in neither the graph nor the store, and only its fence finds it.
|
|
399
|
+
fences.mark({ tags });
|
|
400
|
+
const affected = new Set<string>(isrDependents(tags));
|
|
401
|
+
for (const path of affected) invalidate(path);
|
|
402
|
+
for (const path of revalidateHeld(tags)) affected.add(path);
|
|
339
403
|
return [...affected].sort();
|
|
340
404
|
},
|
|
341
405
|
|
|
342
406
|
attach() {
|
|
343
|
-
// The cache fanout owns the trigger; render owns only "what does
|
|
407
|
+
// The cache fanout owns the trigger; render owns only "what does an invalidation mean here".
|
|
344
408
|
const revalidate: Revalidator = (path) => {
|
|
345
|
-
|
|
409
|
+
invalidate(path);
|
|
346
410
|
};
|
|
347
|
-
|
|
411
|
+
const revalidateTags: TagRevalidator = (tags) => revalidateHeld(tags);
|
|
412
|
+
registerRevalidator(revalidate, revalidateTags);
|
|
348
413
|
installedRevalidator = revalidate;
|
|
349
414
|
return () => {
|
|
350
415
|
for (const path of registered) unregisterDependent({ kind: 'isr-route', id: path });
|
|
@@ -375,113 +440,3 @@ export async function invalidateAndRevalidate(
|
|
|
375
440
|
const report = await invalidateTags(tags);
|
|
376
441
|
return report.isr;
|
|
377
442
|
}
|
|
378
|
-
|
|
379
|
-
/** `post` / `post:123` → `{ entity, id? }`. Mirrors `@ultimat3/cache`'s wire form. */
|
|
380
|
-
function parseWireTag(wire: string): CacheTag {
|
|
381
|
-
const split = wire.indexOf(':');
|
|
382
|
-
if (split === -1) return { entity: wire };
|
|
383
|
-
return { entity: wire.slice(0, split), id: wire.slice(split + 1) };
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
interface RouteMatcher {
|
|
387
|
-
readonly route: RouteDescriptor;
|
|
388
|
-
test(storedPath: string): boolean;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/** One compiled set per route TABLE — `describePages()` hands out one array per registry change. */
|
|
392
|
-
const compiledTables = new WeakMap<readonly RouteDescriptor[], readonly RouteMatcher[]>();
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* A stored path belongs to a route when the route's pattern matches it — `route-pattern.ts`'s one
|
|
396
|
-
* pattern compiler, never a second. Ordered MOST SPECIFIC FIRST, as `@ultimat3/http`'s trie
|
|
397
|
-
* resolves a request: segment by segment, a static beats a `:param` beats a `*catch-all`. In table
|
|
398
|
-
* order `/docs/*path` sorts before `/docs/:id`, so the first match gave `/docs/7` the catch-all's
|
|
399
|
-
* TTL and tags — a page rendered by one route and expired by another's clock.
|
|
400
|
-
*/
|
|
401
|
-
function matchersOf(table: readonly RouteDescriptor[]): readonly RouteMatcher[] {
|
|
402
|
-
const cached = compiledTables.get(table);
|
|
403
|
-
if (cached !== undefined) return cached;
|
|
404
|
-
// Static routes too: `/precios-españa` is the route's path and `/precios-espa%C3%B1a` the key's,
|
|
405
|
-
// so only the pattern (raw or encoded, per character) finds it.
|
|
406
|
-
const compiled = table
|
|
407
|
-
.map((route): RouteMatcher & { readonly specificity: number } => {
|
|
408
|
-
const { regex, specificity } = compilePattern(route.path);
|
|
409
|
-
return { route, specificity, test: (stored) => regex.test(stored) };
|
|
410
|
-
})
|
|
411
|
-
.sort((a, b) => b.specificity - a.specificity);
|
|
412
|
-
compiledTables.set(table, compiled);
|
|
413
|
-
return compiled;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/** Tag-only routes have no clock of their own; a tag bust reaches the CDN through the fanout. */
|
|
417
|
-
const TAG_ONLY_S_MAX_AGE_SECONDS = 60;
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* `IsrStore` is a driver seam, so an entry can come back from an app's own store — one backed by
|
|
421
|
-
* Redis round-trips it through JSON, where a `ttlMs` that was never written reads back as
|
|
422
|
-
* `undefined` and `entry.ttlMs === null` is then false. Two failures follow from that one value
|
|
423
|
-
* and neither raises: `now - generatedAt < NaN` is false, so the page is NEVER fresh and every
|
|
424
|
-
* request regenerates it, and the CDN is handed `s-maxage=NaN` — an unparseable directive a
|
|
425
|
-
* conforming cache IGNORES, dropping the page to heuristic caching rather than to the declared
|
|
426
|
-
* age. Read on the request path, so it is TOTAL rather than a throw: a ttl that is not a positive
|
|
427
|
-
* finite number of milliseconds is the tag-only `null` `parseTtlMs` would have answered for it.
|
|
428
|
-
*/
|
|
429
|
-
function entryTtlMs(entry: IsrEntry): number | null {
|
|
430
|
-
const ttlMs = entry.ttlMs;
|
|
431
|
-
if (ttlMs === null || (Number.isFinite(ttlMs) && ttlMs > 0)) return ttlMs;
|
|
432
|
-
logger.warn('isr.entry_ttl_invalid', { path: entry.path, ttlMs: String(ttlMs) });
|
|
433
|
-
return null;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* The declared TTL is the route's own contract with the CDN: a shared cache must not hold the
|
|
438
|
-
* page longer than the app said it stays true. A flat `s-maxage=60` made `revalidate: { ttl:
|
|
439
|
-
* '5m' }` a lie in one direction and `ttl: '30s'` a lie in the other.
|
|
440
|
-
*/
|
|
441
|
-
function cacheControl(ttlMs: number | null): string {
|
|
442
|
-
const sMaxAge = ttlMs === null ? TAG_ONLY_S_MAX_AGE_SECONDS : Math.round(ttlMs / 1_000);
|
|
443
|
-
|
|
444
|
-
return `public, max-age=0, s-maxage=${sMaxAge}, stale-while-revalidate=86400`;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* `entryTtlMs`'s reason, one field over: a store may hand back an entry with no `status`, or one
|
|
449
|
-
* that JSON turned into something else, on the request path. Absent is 200 — the only value any
|
|
450
|
-
* entry carried before the field existed — and anything the range refuses is 200 with a warning,
|
|
451
|
-
* because a stored number must not 500 the page for its whole TTL.
|
|
452
|
-
*/
|
|
453
|
-
function entryStatus(entry: IsrEntry): number {
|
|
454
|
-
const status = entry.status;
|
|
455
|
-
if (status === undefined) return 200;
|
|
456
|
-
if (isRenderStatus(status)) return status;
|
|
457
|
-
logger.warn('isr.entry_status_invalid', { path: entry.path, status: String(status) });
|
|
458
|
-
return 200;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function toResult(
|
|
462
|
-
entry: IsrEntry,
|
|
463
|
-
buildId: string,
|
|
464
|
-
tags: readonly CacheTag[],
|
|
465
|
-
servedStale = false,
|
|
466
|
-
): RenderResult {
|
|
467
|
-
const headers: Record<string, string> = {
|
|
468
|
-
...staticHeaders(entry.hash, buildId),
|
|
469
|
-
'cache-control': cacheControl(entryTtlMs(entry)),
|
|
470
|
-
// The store keys on the locale; a shared cache in front of it has to as well, or the CDN
|
|
471
|
-
// repeats the bug this entry was split to fix. `ssrHeaders`' own line, for the same reason.
|
|
472
|
-
// The rest of the shared key — the cookie, the zone — is added by `@ultimat3/http`'s
|
|
473
|
-
// `cache-headers` stage, which sees the actor this function cannot.
|
|
474
|
-
vary: 'accept-language',
|
|
475
|
-
};
|
|
476
|
-
// The keys an edge purges this document by — `@ultimat3/cache`'s list, never a second one, so
|
|
477
|
-
// what a bust sends and what the page carries cannot drift. Absent, an `invalidates` cleared
|
|
478
|
-
// every tier but the CDN, which held the document for its whole `s-maxage`. The tags were
|
|
479
|
-
// screened when the route registered (`registry.ts`), so this cannot refuse on the request path.
|
|
480
|
-
const keys = surrogateKeys(tags, 'isr');
|
|
481
|
-
if (keys.length > 0) {
|
|
482
|
-
headers['surrogate-key'] = keys.join(' ');
|
|
483
|
-
headers['cache-tag'] = keys.join(',');
|
|
484
|
-
}
|
|
485
|
-
if (servedStale) headers['x-ultimate-isr'] = 'stale';
|
|
486
|
-
return { status: entryStatus(entry), headers, body: entry.html };
|
|
487
|
-
}
|