what-isr 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/isr.js ADDED
@@ -0,0 +1,125 @@
1
+ // ISR engine — origin-first Incremental Static Regeneration.
2
+ //
3
+ // Stale-while-revalidate at the ORIGIN: a fresh entry is served from cache; a
4
+ // stale entry (past `revalidate`, within `swr`) is served IMMEDIATELY while a
5
+ // single background regeneration refreshes it; a cold/expired entry blocks and
6
+ // renders. Concurrent regenerations of the same key are deduped to ONE render.
7
+ //
8
+ // `render(routeMatch, ctx)` is INJECTED by the adapter (wraps renderPage +
9
+ // serializeState) — the engine never imports what-server, keeping it standalone.
10
+
11
+ import { cacheKey, normalizePath } from './key.js';
12
+ import { makeEntry, isFresh, isServableStale } from './stores/store-interface.js';
13
+ import { buildCacheHeaders } from './headers.js';
14
+
15
+ export function createCacheEngine({ store, render, cdn, now = Date.now, logger = console } = {}) {
16
+ const inFlight = new Map(); // key -> Promise<entry> (dedupe)
17
+
18
+ function keyFor(routeMatch) {
19
+ return cacheKey({ path: routeMatch.path, query: routeMatch.query, vary: routeMatch.vary });
20
+ }
21
+
22
+ // Render + store, deduping concurrent calls for the same key. `renderOverride`
23
+ // lets a caller (e.g. the deploy adapter) supply the render for this route
24
+ // without baking it into the engine — keeps the engine decoupled.
25
+ function regenerate(key, routeMatch, renderOverride) {
26
+ const existing = inFlight.get(key);
27
+ if (existing) return existing;
28
+ const doRender = renderOverride || render;
29
+ const p = (async () => {
30
+ const out = await doRender(routeMatch, {});
31
+ const entry = makeEntry({ ...out, path: routeMatch.path }, routeMatch.config || {}, now());
32
+ await store.set(key, entry);
33
+ return entry;
34
+ })().finally(() => inFlight.delete(key));
35
+ inFlight.set(key, p);
36
+ return p;
37
+ }
38
+
39
+ function serve(entry, cacheStatus, config) {
40
+ return {
41
+ html: entry.html,
42
+ head: entry.head,
43
+ state: entry.state,
44
+ status: entry.status || 200,
45
+ cacheStatus,
46
+ headers: buildCacheHeaders(entry, config || {}, cacheStatus),
47
+ };
48
+ }
49
+
50
+ async function handle(routeMatch, renderOverride) {
51
+ const config = routeMatch.config || {};
52
+
53
+ // Uncacheable (server-rendered) routes: always render, never store.
54
+ if (config.mode === 'server') {
55
+ const out = await (renderOverride || render)(routeMatch, {});
56
+ const entry = makeEntry({ ...out, path: routeMatch.path }, config, now());
57
+ return serve(entry, 'BYPASS', config);
58
+ }
59
+
60
+ const key = keyFor(routeMatch);
61
+ const entry = await store.get(key);
62
+ const t = now();
63
+
64
+ if (entry && isFresh(entry, t)) {
65
+ return serve(entry, 'HIT', config);
66
+ }
67
+
68
+ if (entry && isServableStale(entry, t)) {
69
+ // Serve stale immediately; refresh in the background (deduped, non-blocking).
70
+ regenerate(key, routeMatch, renderOverride).catch((e) => logger.error?.('[what-isr] background regenerate failed:', e));
71
+ return serve(entry, 'STALE', config);
72
+ }
73
+
74
+ // Cold miss or expired beyond the swr window.
75
+ if (entry && config.onMiss === 'stale-if-error') {
76
+ try {
77
+ const fresh = await regenerate(key, routeMatch, renderOverride);
78
+ return serve(fresh, 'MISS', config);
79
+ } catch (e) {
80
+ logger.error?.('[what-isr] regenerate failed, serving stale:', e);
81
+ return serve(entry, 'STALE', config);
82
+ }
83
+ }
84
+
85
+ const fresh = await regenerate(key, routeMatch, renderOverride);
86
+ return serve(fresh, 'MISS', config);
87
+ }
88
+
89
+ // --- On-demand invalidation (origin purge + optional CDN fan-out) ---
90
+
91
+ async function revalidatePath(path, { regenerate: regen = false, routeResolver } = {}) {
92
+ const norm = normalizePath(path);
93
+ const deleted = await store.deleteByPath(norm);
94
+ if (cdn && cdn.purge) await cdn.purge([path]);
95
+ if (regen) {
96
+ const route = routeResolver ? routeResolver(norm) : { path: norm, query: {}, config: {} };
97
+ await regenerate(keyFor(route), route).catch((e) => logger.error?.('[what-isr] regen after revalidatePath failed:', e));
98
+ }
99
+ return deleted;
100
+ }
101
+
102
+ async function revalidateTag(tag, { regenerate: regen = false, routeResolver } = {}) {
103
+ const deleted = await store.deleteByTag(tag);
104
+ if (cdn && cdn.purgeTags) await cdn.purgeTags([tag]);
105
+ if (regen && routeResolver) {
106
+ for (const key of deleted) {
107
+ const route = routeResolver(key);
108
+ if (route) await regenerate(keyFor(route), route).catch(() => {});
109
+ }
110
+ }
111
+ return deleted;
112
+ }
113
+
114
+ return {
115
+ handle,
116
+ regenerate: (routeMatch) => regenerate(keyFor(routeMatch), routeMatch),
117
+ revalidatePath,
118
+ revalidateTag,
119
+ keyFor,
120
+ store,
121
+ _inFlight: inFlight,
122
+ _now: now,
123
+ _cdn: cdn,
124
+ };
125
+ }
package/src/key.js ADDED
Binary file
package/src/paths.js ADDED
@@ -0,0 +1,43 @@
1
+ // getStaticPaths resolution + fallback decisions for dynamic routes.
2
+ //
3
+ // Known paths (in getStaticPaths) are pre-rendered at build. Unknown params at
4
+ // request time follow the route's `fallback`:
5
+ // 'blocking' -> render on first hit, then cache (ISR)
6
+ // true -> serve a skeleton immediately, regenerate in the background
7
+ // false -> 404
8
+
9
+ /** Run a page's getStaticPaths (if any). Returns { paths, fallback }. */
10
+ export async function resolveStaticPaths(getStaticPaths, ctx = {}) {
11
+ if (typeof getStaticPaths !== 'function') return { paths: [], fallback: false };
12
+ const result = await getStaticPaths(ctx);
13
+ return {
14
+ paths: (result && result.paths) || [],
15
+ fallback: result && 'fallback' in result ? result.fallback : false,
16
+ };
17
+ }
18
+
19
+ /** Build a concrete URL from a route pattern + params. Supports :param and *catchall. */
20
+ export function buildPath(pattern, params = {}) {
21
+ return pattern.replace(/[:*]([A-Za-z0-9_]+)/g, (_, name) => {
22
+ const v = params[name];
23
+ return v == null ? '' : String(v);
24
+ });
25
+ }
26
+
27
+ /** Is this param set among the pre-built static paths? */
28
+ export function isKnownParams(staticPaths, params) {
29
+ return staticPaths.some((entry) => {
30
+ const p = entry.params || {};
31
+ const keys = new Set([...Object.keys(p), ...Object.keys(params)]);
32
+ for (const k of keys) if (String(p[k]) !== String(params[k])) return false;
33
+ return true;
34
+ });
35
+ }
36
+
37
+ /** Decide what to do for a requested dynamic path. */
38
+ export function decideFallback(fallback, isKnown) {
39
+ if (isKnown) return 'serve';
40
+ if (fallback === 'blocking') return 'render';
41
+ if (fallback === true) return 'skeleton';
42
+ return 'notfound';
43
+ }
@@ -0,0 +1,73 @@
1
+ // Poll/scheduled regeneration — keeps the cache warm regardless of traffic.
2
+ // Runs in the long-lived Node adapter process. Self-rescheduling setTimeout
3
+ // (no drift/overlap), jitter (anti-thundering-herd after a restart), a global
4
+ // concurrency cap, and it joins the engine's in-flight lock so a scheduled tick
5
+ // during a traffic-triggered regeneration is a no-op.
6
+
7
+ export function createScheduler(engine, options = {}) {
8
+ const {
9
+ maxConcurrent = 4,
10
+ random = Math.random,
11
+ setTimer = setTimeout,
12
+ clearTimer = clearTimeout,
13
+ logger = console,
14
+ } = options;
15
+
16
+ const tasks = []; // { route, intervalMs, timer }
17
+ const queue = [];
18
+ let running = false;
19
+ let active = 0;
20
+
21
+ function jittered(intervalMs) {
22
+ return Math.round(intervalMs * (1 + random() * 0.1));
23
+ }
24
+
25
+ function schedule(task) {
26
+ task.timer = setTimer(() => fire(task), jittered(task.intervalMs));
27
+ if (task.timer && typeof task.timer.unref === 'function') task.timer.unref();
28
+ }
29
+
30
+ function fire(task) {
31
+ if (!running) return;
32
+ if (active < maxConcurrent) runTask(task);
33
+ else queue.push(task);
34
+ }
35
+
36
+ async function runTask(task) {
37
+ active++;
38
+ try {
39
+ await engine.regenerate(task.route);
40
+ } catch (e) {
41
+ logger.error?.('[what-isr] scheduled regenerate failed:', e);
42
+ } finally {
43
+ active--;
44
+ if (running) schedule(task);
45
+ drain();
46
+ }
47
+ }
48
+
49
+ function drain() {
50
+ while (running && active < maxConcurrent && queue.length) {
51
+ runTask(queue.shift());
52
+ }
53
+ }
54
+
55
+ return {
56
+ register(route, { intervalMs }) {
57
+ tasks.push({ route, intervalMs, timer: null });
58
+ return this;
59
+ },
60
+ start() {
61
+ running = true;
62
+ for (const t of tasks) schedule(t);
63
+ return this;
64
+ },
65
+ stop() {
66
+ running = false;
67
+ for (const t of tasks) if (t.timer != null) clearTimer(t.timer);
68
+ queue.length = 0;
69
+ return this;
70
+ },
71
+ _tasks: tasks,
72
+ };
73
+ }
@@ -0,0 +1,127 @@
1
+ // Filesystem cache store — survives restarts and is shareable by multiple
2
+ // worker processes on one box. Entries are sharded JSON files; writes are atomic
3
+ // (write to .tmp then rename). Tag/path reverse indexes are sidecar JSON lists.
4
+
5
+ import { mkdir, writeFile, readFile, rename, rm, readdir, stat } from 'node:fs/promises';
6
+ import { join, dirname } from 'node:path';
7
+ import { createHash } from 'node:crypto';
8
+ import { hashKey } from '../key.js';
9
+
10
+ function safeName(s) {
11
+ return createHash('sha256').update(String(s)).digest('hex');
12
+ }
13
+
14
+ export function createFilesystemStore({ dir }) {
15
+ const entriesDir = join(dir, 'entries');
16
+ const tagsDir = join(dir, 'tags');
17
+ const pathsDir = join(dir, 'paths');
18
+
19
+ const entryFile = (key) => join(entriesDir, hashKey(key) + '.json');
20
+ const indexFile = (base, name) => join(base, safeName(name) + '.json');
21
+
22
+ async function atomicWrite(file, contents) {
23
+ await mkdir(dirname(file), { recursive: true });
24
+ const tmp = `${file}.${process.pid}.${safeName(file).slice(0, 8)}.tmp`;
25
+ await writeFile(tmp, contents);
26
+ await rename(tmp, file);
27
+ }
28
+
29
+ async function readJson(file) {
30
+ try {
31
+ return JSON.parse(await readFile(file, 'utf8'));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ async function addToIndex(base, name, key) {
38
+ const file = indexFile(base, name);
39
+ const list = (await readJson(file)) || [];
40
+ if (!list.includes(key)) {
41
+ list.push(key);
42
+ await atomicWrite(file, JSON.stringify(list));
43
+ }
44
+ }
45
+
46
+ async function removeFromIndex(base, name, key) {
47
+ const file = indexFile(base, name);
48
+ const list = await readJson(file);
49
+ if (!list) return;
50
+ const next = list.filter((k) => k !== key);
51
+ if (next.length) await atomicWrite(file, JSON.stringify(next));
52
+ else await rm(file, { force: true });
53
+ }
54
+
55
+ async function deleteByIndex(base, name) {
56
+ const file = indexFile(base, name);
57
+ const keys = await readJson(file);
58
+ if (!keys) return [];
59
+ for (const k of keys) await removeKey(k);
60
+ await rm(file, { force: true });
61
+ return keys;
62
+ }
63
+
64
+ async function removeKey(key) {
65
+ const record = await readJson(entryFile(key));
66
+ if (!record) return false;
67
+ await rm(entryFile(key), { force: true });
68
+ const entry = record.entry || {};
69
+ for (const t of entry.tags || []) await removeFromIndex(tagsDir, t, key);
70
+ if (entry.path) await removeFromIndex(pathsDir, entry.path, key);
71
+ return true;
72
+ }
73
+
74
+ async function walkKeys() {
75
+ const out = [];
76
+ let shards;
77
+ try { shards = await readdir(entriesDir); } catch { return out; }
78
+ for (const a of shards) {
79
+ const aDir = join(entriesDir, a);
80
+ let st; try { st = await stat(aDir); } catch { continue; }
81
+ if (!st.isDirectory()) continue;
82
+ for (const b of await readdir(aDir)) {
83
+ const bDir = join(aDir, b);
84
+ try { if (!(await stat(bDir)).isDirectory()) continue; } catch { continue; }
85
+ for (const f of await readdir(bDir)) {
86
+ if (!f.endsWith('.json')) continue;
87
+ const rec = await readJson(join(bDir, f));
88
+ if (rec && rec.key != null) out.push(rec.key);
89
+ }
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ return {
96
+ async get(key) {
97
+ const rec = await readJson(entryFile(key));
98
+ return rec ? rec.entry : null;
99
+ },
100
+ async set(key, entry) {
101
+ // de-index any previous version's tags/path first
102
+ const prev = await readJson(entryFile(key));
103
+ if (prev && prev.entry) {
104
+ for (const t of prev.entry.tags || []) await removeFromIndex(tagsDir, t, key);
105
+ if (prev.entry.path) await removeFromIndex(pathsDir, prev.entry.path, key);
106
+ }
107
+ await atomicWrite(entryFile(key), JSON.stringify({ key, entry }));
108
+ for (const t of entry.tags || []) await addToIndex(tagsDir, t, key);
109
+ if (entry.path) await addToIndex(pathsDir, entry.path, key);
110
+ },
111
+ async delete(key) {
112
+ return removeKey(key);
113
+ },
114
+ async deleteByTag(tag) {
115
+ return deleteByIndex(tagsDir, tag);
116
+ },
117
+ async deleteByPath(path) {
118
+ return deleteByIndex(pathsDir, path);
119
+ },
120
+ async clear() {
121
+ await rm(dir, { recursive: true, force: true });
122
+ },
123
+ async keys() {
124
+ return walkKeys();
125
+ },
126
+ };
127
+ }
@@ -0,0 +1,85 @@
1
+ // In-memory cache store — the zero-config default. LRU eviction + reverse
2
+ // indexes (tag -> keys, path -> keys) for O(1) group invalidation.
3
+ //
4
+ // Insertion order of a Map IS the LRU order: get() re-inserts (moves to newest),
5
+ // eviction removes from the front (oldest).
6
+
7
+ export function createMemoryStore({ maxEntries = 1000 } = {}) {
8
+ const map = new Map(); // key -> entry
9
+ const tagIndex = new Map(); // tag -> Set<key>
10
+ const pathIndex = new Map(); // path -> Set<key>
11
+
12
+ function addToIndex(index, name, key) {
13
+ if (name == null) return;
14
+ let set = index.get(name);
15
+ if (!set) index.set(name, (set = new Set()));
16
+ set.add(key);
17
+ }
18
+ function removeFromIndex(index, name, key) {
19
+ if (name == null) return;
20
+ const set = index.get(name);
21
+ if (set) {
22
+ set.delete(key);
23
+ if (set.size === 0) index.delete(name);
24
+ }
25
+ }
26
+ function indexEntry(key, entry) {
27
+ if (entry.tags) for (const t of entry.tags) addToIndex(tagIndex, t, key);
28
+ addToIndex(pathIndex, entry.path, key);
29
+ }
30
+ function deindexEntry(key, entry) {
31
+ if (entry.tags) for (const t of entry.tags) removeFromIndex(tagIndex, t, key);
32
+ removeFromIndex(pathIndex, entry.path, key);
33
+ }
34
+ function removeKey(key) {
35
+ const e = map.get(key);
36
+ if (!e) return false;
37
+ map.delete(key);
38
+ deindexEntry(key, e);
39
+ return true;
40
+ }
41
+ function deleteByIndex(index, name) {
42
+ const set = index.get(name);
43
+ if (!set) return [];
44
+ const deleted = [...set];
45
+ for (const k of deleted) removeKey(k);
46
+ return deleted;
47
+ }
48
+
49
+ return {
50
+ async get(key) {
51
+ const e = map.get(key);
52
+ if (!e) return null;
53
+ // LRU touch: move to newest.
54
+ map.delete(key);
55
+ map.set(key, e);
56
+ return e;
57
+ },
58
+ async set(key, entry) {
59
+ if (map.has(key)) deindexEntry(key, map.get(key));
60
+ map.set(key, entry);
61
+ indexEntry(key, entry);
62
+ while (map.size > maxEntries) {
63
+ const oldest = map.keys().next().value;
64
+ removeKey(oldest);
65
+ }
66
+ },
67
+ async delete(key) {
68
+ return removeKey(key);
69
+ },
70
+ async deleteByTag(tag) {
71
+ return deleteByIndex(tagIndex, tag);
72
+ },
73
+ async deleteByPath(path) {
74
+ return deleteByIndex(pathIndex, path);
75
+ },
76
+ async clear() {
77
+ map.clear();
78
+ tagIndex.clear();
79
+ pathIndex.clear();
80
+ },
81
+ async keys() {
82
+ return [...map.keys()];
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,62 @@
1
+ // Redis/KV cache store — the multi-instance story (N app servers share one
2
+ // cache). Takes an INJECTED client (ioredis / node-redis shaped:
3
+ // get/set/del/sadd/srem/smembers, optional keys) so this package keeps zero deps.
4
+
5
+ export function createRedisStore({ client, namespace = 'what' } = {}) {
6
+ if (!client) throw new Error('[what-isr] createRedisStore requires { client }');
7
+
8
+ const ck = (key) => `${namespace}:cache:${key}`;
9
+ const tk = (tag) => `${namespace}:tag:${tag}`;
10
+ const pk = (path) => `${namespace}:path:${path}`;
11
+
12
+ async function deindex(key, entry) {
13
+ if (!entry) return;
14
+ for (const t of entry.tags || []) await client.srem(tk(t), key);
15
+ if (entry.path) await client.srem(pk(entry.path), key);
16
+ }
17
+
18
+ async function deleteBySet(setKey) {
19
+ const keys = (await client.smembers(setKey)) || [];
20
+ for (const k of keys) await client.del(ck(k));
21
+ await client.del(setKey);
22
+ return keys;
23
+ }
24
+
25
+ return {
26
+ async get(key) {
27
+ const v = await client.get(ck(key));
28
+ return v ? JSON.parse(v) : null;
29
+ },
30
+ async set(key, entry) {
31
+ const prev = await this.get(key);
32
+ if (prev) await deindex(key, prev);
33
+ await client.set(ck(key), JSON.stringify(entry));
34
+ for (const t of entry.tags || []) await client.sadd(tk(t), key);
35
+ if (entry.path) await client.sadd(pk(entry.path), key);
36
+ },
37
+ async delete(key) {
38
+ const entry = await this.get(key);
39
+ await client.del(ck(key));
40
+ await deindex(key, entry);
41
+ return !!entry;
42
+ },
43
+ async deleteByTag(tag) {
44
+ return deleteBySet(tk(tag));
45
+ },
46
+ async deleteByPath(path) {
47
+ return deleteBySet(pk(path));
48
+ },
49
+ async clear() {
50
+ if (typeof client.keys === 'function') {
51
+ const all = await client.keys(`${namespace}:*`);
52
+ for (const k of all) await client.del(k);
53
+ }
54
+ },
55
+ async keys() {
56
+ if (typeof client.keys !== 'function') return [];
57
+ const prefix = `${namespace}:cache:`;
58
+ const all = await client.keys(`${prefix}*`);
59
+ return all.map((k) => k.slice(prefix.length));
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,60 @@
1
+ // CacheStore contract (JSDoc only — JS source, .d.ts ships types) + helpers.
2
+ //
3
+ // A store maps a cache key -> Entry. All methods are async so memory, filesystem
4
+ // and Redis adapters share one interface (memory just resolves synchronously).
5
+ //
6
+ // @typedef {Object} Entry
7
+ // @property {string} html Rendered body HTML.
8
+ // @property {string} [head] Collected <head> HTML.
9
+ // @property {*} [state] Serialized hydration state (loaderData/resources).
10
+ // @property {string[]} [tags] Tags for group invalidation (revalidateTag).
11
+ // @property {string} [path] Normalized path (for revalidatePath).
12
+ // @property {number} renderedAt ms epoch when rendered.
13
+ // @property {number} maxAge Revalidate seconds (0 = never time-stale).
14
+ // @property {number} expiresAt renderedAt + maxAge*1000 (precomputed).
15
+ // @property {number} swrWindow Grace seconds an expired entry is still served.
16
+ // @property {number} [status] HTTP status (for 404 stubs / fallback skeletons).
17
+ // @property {boolean} [partial] True for fallback skeletons (never durable).
18
+ //
19
+ // @typedef {Object} CacheStore
20
+ // @property {(key:string)=>Promise<Entry|null>} get
21
+ // @property {(key:string, entry:Entry)=>Promise<void>} set
22
+ // @property {(key:string)=>Promise<boolean>} delete
23
+ // @property {(tag:string)=>Promise<string[]>} deleteByTag returns deleted keys
24
+ // @property {(path:string)=>Promise<string[]>} deleteByPath returns deleted keys
25
+ // @property {()=>Promise<void>} clear
26
+ // @property {()=>Promise<string[]>} keys
27
+
28
+ /**
29
+ * Fill an Entry's time fields from `now` + a route config. Used by the ISR
30
+ * engine so every store receives consistent expiry metadata.
31
+ */
32
+ export function makeEntry(out, config = {}, now = Date.now()) {
33
+ const maxAge = Number(config.revalidate) || 0;
34
+ const swrWindow = config.swr != null ? Number(config.swr) : maxAge;
35
+ return {
36
+ html: out.html || '',
37
+ head: out.head || '',
38
+ state: out.state ?? null,
39
+ tags: out.tags || config.tags || [],
40
+ path: out.path || config.path,
41
+ status: out.status || 200,
42
+ partial: !!out.partial,
43
+ renderedAt: now,
44
+ maxAge,
45
+ swrWindow,
46
+ expiresAt: maxAge > 0 ? now + maxAge * 1000 : Infinity,
47
+ };
48
+ }
49
+
50
+ /** Freshness check against a clock. */
51
+ export function isFresh(entry, now = Date.now()) {
52
+ return entry.expiresAt === Infinity || now < entry.expiresAt;
53
+ }
54
+
55
+ /** Within the stale-while-revalidate grace window (servable while regenerating). */
56
+ export function isServableStale(entry, now = Date.now()) {
57
+ if (isFresh(entry, now)) return true;
58
+ if (entry.swrWindow == null) return false;
59
+ return now < entry.expiresAt + entry.swrWindow * 1000;
60
+ }
package/src/webhook.js ADDED
@@ -0,0 +1,55 @@
1
+ // On-demand revalidation webhook — the CMS-trigger path. A POST with a shared
2
+ // secret purges paths/tags so a Sanity/Contentful/WP "published" event can warm
3
+ // or drop cache entries. The adapter mounts this at e.g. /__what_revalidate.
4
+
5
+ // Constant-time string compare (timing-attack safe). Self-contained so the cache
6
+ // package stays dependency-free and decoupled from what-server.
7
+ function safeEqual(a, b) {
8
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
9
+ if (a.length !== b.length) return false;
10
+ let result = 0;
11
+ for (let i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i);
12
+ return result === 0;
13
+ }
14
+
15
+ /**
16
+ * @param engine cache engine (revalidatePath / revalidateTag)
17
+ * @param options { secret, header='x-what-revalidate-secret', regenerate=false }
18
+ * @returns async (reqLike:{headers, body:{paths?,tags?,regenerate?}}) -> { status, body }
19
+ */
20
+ export function createRevalidateWebhook(engine, options = {}) {
21
+ const { secret, header = 'x-what-revalidate-secret', regenerate = false } = options;
22
+
23
+ return async function handle(reqLike) {
24
+ const provided = (reqLike.headers || {})[header] || (reqLike.headers || {})[header.toLowerCase()];
25
+ if (!secret || !safeEqual(provided || '', secret)) {
26
+ return { status: 401, body: { message: 'Unauthorized' } };
27
+ }
28
+
29
+ const body = reqLike.body;
30
+ if (!body || typeof body !== 'object') {
31
+ return { status: 400, body: { message: 'Invalid body' } };
32
+ }
33
+
34
+ const { paths, tags, regenerate: regen = regenerate } = body;
35
+ if (!Array.isArray(paths) && !Array.isArray(tags)) {
36
+ return { status: 400, body: { message: 'Provide `paths` and/or `tags` arrays' } };
37
+ }
38
+
39
+ const revalidated = { paths: [], tags: [] };
40
+ if (Array.isArray(paths)) {
41
+ for (const p of paths) {
42
+ await engine.revalidatePath(p, { regenerate: regen });
43
+ revalidated.paths.push(p);
44
+ }
45
+ }
46
+ if (Array.isArray(tags)) {
47
+ for (const t of tags) {
48
+ await engine.revalidateTag(t, { regenerate: regen });
49
+ revalidated.tags.push(t);
50
+ }
51
+ }
52
+
53
+ return { status: 200, body: { revalidated: true, ...revalidated } };
54
+ };
55
+ }