@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-store.ts
CHANGED
|
@@ -30,7 +30,34 @@ export interface IsrEntry {
|
|
|
30
30
|
readonly status?: number;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A fence the STORE holds, for a store more than one process writes. A process's own fence
|
|
35
|
+
* (`@ultimat3/cache`'s) only knows the busts that process heard: a replica that began a render
|
|
36
|
+
* before a purge, and has not yet been told of it, would write the purged page straight back —
|
|
37
|
+
* and with the bus down it stayed for the page's whole `ttl`. So the purge moves a generation in
|
|
38
|
+
* the store itself, and a write that read an older one is refused there.
|
|
39
|
+
*
|
|
40
|
+
* Keyed by tag ENTITY (`post` for `post` and `post:1` alike), which is coarser than a tag and
|
|
41
|
+
* errs the safe way: an unrelated row's bust refuses a write, and the next request renders again.
|
|
42
|
+
* A custom store implements all three over its own backend; `setIfCurrent` must be ATOMIC there
|
|
43
|
+
* (a transaction, a Lua script, a conditional put) — a read-then-write is the race over again.
|
|
44
|
+
*/
|
|
45
|
+
export interface IsrTagFence {
|
|
46
|
+
/** An opaque reading of these entities' generations, taken before a render reads its rows. */
|
|
47
|
+
sample(entities: readonly string[]): string;
|
|
48
|
+
/** A bust of these entities happened: every reading taken before this call is void. */
|
|
49
|
+
bump(entities: readonly string[]): void;
|
|
50
|
+
/** `set(entry)` only while `sampled` is still the reading. `false`: refused, nothing written. */
|
|
51
|
+
setIfCurrent(entry: IsrEntry, entities: readonly string[], sampled: string): boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
33
54
|
export interface IsrStore {
|
|
55
|
+
/**
|
|
56
|
+
* Present on a store that is safe to SHARE between processes under `onInvalidate: 'purge'`.
|
|
57
|
+
* `memoryIsrStore` has it. A store handed to the boot without one is refused there when any
|
|
58
|
+
* route purges (`X_ROUTE_MODE_INVALID`), rather than left to re-fill a purged page in silence.
|
|
59
|
+
*/
|
|
60
|
+
readonly tagFence?: IsrTagFence;
|
|
34
61
|
get(path: string): IsrEntry | undefined;
|
|
35
62
|
set(entry: IsrEntry): void;
|
|
36
63
|
/**
|
|
@@ -66,19 +93,36 @@ export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
|
|
|
66
93
|
options.maxEntries ?? DEFAULT_ISR_MAX_ENTRIES,
|
|
67
94
|
);
|
|
68
95
|
const map = new Map<string, IsrEntry>();
|
|
96
|
+
// One counter per tag entity ever busted — bounded by the entities an app declares.
|
|
97
|
+
const generations = new Map<string, number>();
|
|
98
|
+
const reading = (entities: readonly string[]): string =>
|
|
99
|
+
entities.map((entity) => String(generations.get(entity) ?? 0)).join(',');
|
|
100
|
+
const set = (entry: IsrEntry): void => {
|
|
101
|
+
// Re-inserted rather than overwritten, so the Map's iteration order IS generation order and
|
|
102
|
+
// the first key is the least recently generated page.
|
|
103
|
+
map.delete(entry.path);
|
|
104
|
+
map.set(entry.path, entry);
|
|
105
|
+
while (map.size > maxEntries) {
|
|
106
|
+
const oldest = map.keys().next();
|
|
107
|
+
if (oldest.done === true) break;
|
|
108
|
+
map.delete(oldest.value);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
69
111
|
return {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
112
|
+
tagFence: {
|
|
113
|
+
sample: reading,
|
|
114
|
+
bump: (entities) => {
|
|
115
|
+
for (const entity of entities) generations.set(entity, (generations.get(entity) ?? 0) + 1);
|
|
116
|
+
},
|
|
117
|
+
// One synchronous step in one process, so the compare and the write cannot be interleaved.
|
|
118
|
+
setIfCurrent: (entry, entities, sampled) => {
|
|
119
|
+
if (reading(entities) !== sampled) return false;
|
|
120
|
+
set(entry);
|
|
121
|
+
return true;
|
|
122
|
+
},
|
|
81
123
|
},
|
|
124
|
+
get: (path) => map.get(path),
|
|
125
|
+
set,
|
|
82
126
|
// In place: `map.set` on a key the Map already holds keeps its position, and that position is
|
|
83
127
|
// the eviction order. Never `delete` + `set` here — that is the bug this method exists to fix.
|
|
84
128
|
markStale: (path) => {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ISR controller's contract: what a render hands it, what a request gets back, what it can be
|
|
3
|
+
* built with and what it offers. Declarations only — `render-isr.ts` is the controller.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { CacheFence, CacheTag, FenceScope } from '@ultimat3/cache';
|
|
7
|
+
import type { Scheduler } from '@ultimat3/core';
|
|
8
|
+
import type { RouteDescriptor } from './registry';
|
|
9
|
+
import type { IsrEntry, IsrState, IsrStore } from './render-isr-store';
|
|
10
|
+
import type { RenderResult } from './route';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A render that also answers a status — what a loader's `withStatus(404, …)` becomes once the
|
|
14
|
+
* document is built. A bare string is the 200 every render before this one was: the union is
|
|
15
|
+
* additive, and a render function that never learned the object shape keeps compiling.
|
|
16
|
+
*/
|
|
17
|
+
export interface IsrRendered {
|
|
18
|
+
readonly html: string;
|
|
19
|
+
readonly status: number;
|
|
20
|
+
/**
|
|
21
|
+
* The render's own "do not keep this answer" — what a loader's `noStore(data)` becomes. Served to
|
|
22
|
+
* the request that asked, `private, no-store`, and never written to the store. A 5xx needs no
|
|
23
|
+
* flag: it is never stored whatever this says.
|
|
24
|
+
*/
|
|
25
|
+
readonly noStore?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One regeneration's outcome: the entry it rendered, and whether that entry was kept. */
|
|
29
|
+
export interface Generation {
|
|
30
|
+
readonly entry: IsrEntry;
|
|
31
|
+
readonly cacheable: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* A page that WOULD have been kept, refused because something invalidated it while it rendered.
|
|
34
|
+
* The request that led the render predates that and is answered it, uncached; a request that
|
|
35
|
+
* merely joined may have arrived after, so it renders for itself (`serve`).
|
|
36
|
+
*/
|
|
37
|
+
readonly fenced: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The process fence a controller samples and marks — `@ultimat3/cache`'s, by default. A seam so
|
|
42
|
+
* two controllers in one test can stand for two PROCESSES: sharing the real one made every
|
|
43
|
+
* shared-store test pass on a fence two replicas never share.
|
|
44
|
+
*/
|
|
45
|
+
export interface IsrFence {
|
|
46
|
+
sample(scope: FenceScope): CacheFence;
|
|
47
|
+
mark(scope: FenceScope): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type IsrRenderFn = (path: string) => string | IsrRendered | Promise<string | IsrRendered>;
|
|
51
|
+
|
|
52
|
+
export interface IsrServeResult {
|
|
53
|
+
readonly state: IsrState;
|
|
54
|
+
readonly entry: IsrEntry;
|
|
55
|
+
readonly result: RenderResult;
|
|
56
|
+
/** True when this request started a background regeneration. */
|
|
57
|
+
readonly regenerating: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface IsrControllerOptions {
|
|
61
|
+
readonly store?: IsrStore;
|
|
62
|
+
readonly buildId?: string;
|
|
63
|
+
readonly now?: () => number;
|
|
64
|
+
/** Route table provider — defaults to the real registry. */
|
|
65
|
+
readonly routes?: () => readonly RouteDescriptor[];
|
|
66
|
+
/** ISR-route dependents for a tag set; defaults to `@ultimat3/cache`'s graph. */
|
|
67
|
+
readonly isrDependents?: (tags: readonly CacheTag[]) => readonly string[];
|
|
68
|
+
/**
|
|
69
|
+
* How long one regeneration may hold its path, in whole ms (≥ 1). Defaults to
|
|
70
|
+
* `DEFAULT_ISR_REGENERATE_DEADLINE_MS`. There is no "forever": that is the bug it bounds.
|
|
71
|
+
*/
|
|
72
|
+
readonly regenerateDeadlineMs?: number | undefined;
|
|
73
|
+
/** Injected so the deadline is provable without waiting one out. */
|
|
74
|
+
readonly schedule?: Scheduler | undefined;
|
|
75
|
+
/** See `IsrFence`. Defaults to the process's own. */
|
|
76
|
+
readonly fence?: IsrFence | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* How long a 5xx render answers the requests behind it instead of each rendering again, in
|
|
79
|
+
* whole ms. Defaults to `DEFAULT_ISR_FAILURE_COOLDOWN_MS`; `0` renders on every request.
|
|
80
|
+
*/
|
|
81
|
+
readonly failureCooldownMs?: number | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface IsrController {
|
|
85
|
+
serve(path: string, render: IsrRenderFn): Promise<IsrServeResult>;
|
|
86
|
+
/** Single-flight: concurrent callers for the same path share one render. */
|
|
87
|
+
regenerate(path: string, render: IsrRenderFn): Promise<IsrEntry>;
|
|
88
|
+
markStale(path: string): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Invalidate every ISR page carrying one of these tags — the ones the cache graph knows AND the
|
|
91
|
+
* ones only the store holds — as each page's route declared: marked stale, or purged.
|
|
92
|
+
*/
|
|
93
|
+
revalidateByTags(tags: readonly CacheTag[]): readonly string[];
|
|
94
|
+
inflight(): number;
|
|
95
|
+
store(): IsrStore;
|
|
96
|
+
/**
|
|
97
|
+
* Register this controller as the framework's revalidator, so
|
|
98
|
+
* `action({ cache: { invalidates: [tag.post] } })` reaches ISR in the same hop as
|
|
99
|
+
* memo, LRU, Redis and the CDN. Returns a detach function for tests and reloads.
|
|
100
|
+
*/
|
|
101
|
+
attach(): () => void;
|
|
102
|
+
}
|