cacheplank 0.1.0 → 0.1.4

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/README.md CHANGED
@@ -2,16 +2,25 @@
2
2
 
3
3
  **The shared plank your pods walk across: one table of tag stamps, everything else stays local.**
4
4
 
5
- `cacheplank` is a single-file, tag-based distributed cache handler for Next.js 16's
6
- [`cacheHandlers`](https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheHandlers)
7
- API, backed by [libSQL](https://github.com/tursodatabase/libsql).
5
+ `cacheplank` is a single-file, tag-based distributed cache handler for Next.js 16,
6
+ backed by [libSQL](https://github.com/tursodatabase/libsql).
7
+
8
+ Next 16 has **two** pluggable cache interfaces, resolved from two different
9
+ config keys. cacheplank implements **both**, so every kind of cached route
10
+ converges across pods:
11
+
12
+ | Config key | Interface | Serves | cacheplank entry |
13
+ | --- | --- | --- | --- |
14
+ | `cacheHandlers` (plural) | five-method `use cache` API | `"use cache"` results, fetch path | `cacheplank` (default export) |
15
+ | `cacheHandler` (singular) | legacy incremental / ISR | prerendered **static** `APP_PAGE` / `APP_ROUTE` / `PAGES`, fetch cache | `cacheplank/cache-handler` (default export) |
8
16
 
9
17
  Cache **entries** stay local to each process (a plain in-memory LRU). Only tag
10
- **invalidation** is shared, through one tiny libSQL table (`tag_stamps`). Any
11
- number of pods or regions converge because every `get` compares the entry's
18
+ **invalidation** is shared, through one tiny libSQL table (`tag_stamps`). Both
19
+ handlers read and write that one table, so a `revalidateTag` from any pod
20
+ invalidates every entry kind everywhere: every `get` compares the entry's
12
21
  timestamp against the shared invalidation stamps. That makes ISR/data-cache cost
13
22
  structurally low for self-hosted deploys: every regeneration is a local memory
14
- write, and the only durable write is a KB-scale tag-stamp upsert.
23
+ write, and the only durable write is a one-row tag-stamp upsert.
15
24
 
16
25
  ## Quickstart
17
26
 
@@ -29,9 +38,12 @@ CACHEPLANK_AUTH_TOKEN=...
29
38
  // next.config.js (CommonJS)
30
39
  module.exports = {
31
40
  cacheComponents: true,
41
+ // "use cache" / fetch path (the five-method API):
32
42
  cacheHandlers: {
33
43
  default: require.resolve('cacheplank'),
34
44
  },
45
+ // Prerendered static routes + ISR (the incremental/ISR API):
46
+ cacheHandler: require.resolve('cacheplank/cache-handler'),
35
47
  };
36
48
  ```
37
49
 
@@ -45,11 +57,22 @@ export default {
45
57
  cacheHandlers: {
46
58
  default: require.resolve('cacheplank'),
47
59
  },
60
+ cacheHandler: require.resolve('cacheplank/cache-handler'),
48
61
  };
49
62
  ```
50
63
 
51
- That's it. The package's `default` export is a ready-to-use handler configured
52
- from the environment.
64
+ That's it. Each entry's `default` export is a ready-to-use handler configured
65
+ from the environment. You can enable just one if you prefer — the two keys are
66
+ independent — but you need **both** for fully-static pages to converge (see
67
+ [Compatibility notes](#compatibility-notes) #6, and `npm run test:e2e`).
68
+
69
+ > **Both handler paths must be absolute.** Next joins each config value against
70
+ > the `.next/` directory (`formatDynamicImportPath(distDir, …)` in
71
+ > `next-server.js`, called for `cacheHandlers` and `cacheHandler` alike), so a
72
+ > relative `./cache-handler.cjs` resolves to `.next/cache-handler.cjs` and
73
+ > throws `ERR_MODULE_NOT_FOUND` at startup. `require.resolve('cacheplank')` and
74
+ > `require.resolve('cacheplank/cache-handler')` yield absolute paths and are the
75
+ > recommended forms; plain absolute paths and `file://` URLs also work.
53
76
 
54
77
  ### Storage backends
55
78
 
@@ -71,9 +94,10 @@ fallbacks: `BUNNY_DATABASE_URL` and `BUNNY_DATABASE_AUTH_TOKEN`.
71
94
  | --- | --- | --- |
72
95
  | `CACHEPLANK_URL` | — | libSQL URL (falls back to `BUNNY_DATABASE_URL`) |
73
96
  | `CACHEPLANK_AUTH_TOKEN` | — | libSQL token (falls back to `BUNNY_DATABASE_AUTH_TOKEN`) |
74
- | `CACHEPLANK_PREFIX` | `''` | String prepended to every key (the only build/deploy escape hatch) |
75
- | `CACHEPLANK_MAX_ENTRIES` | `2000` | Local LRU capacity |
97
+ | `CACHEPLANK_PREFIX` | `''` | String prepended to every cache key. Namespaces **entry keys only** — tag stamps are global, so configs sharing one DB cross-invalidate on identical tag strings |
98
+ | `CACHEPLANK_MAX_ENTRIES` | `2000` | Local LRU capacity, **counted in entries, not bytes** — size it to your pod's memory; `0` disables entry storage |
76
99
  | `CACHEPLANK_STAMPS_TTL_MS` | `3000` | How long the in-process stamp memo is trusted |
100
+ | `CACHEPLANK_STAMPS_RETENTION_MS` | `2592000000` (30d) | Stamp retention window: stamps older than this are ignored by shared-table reads, bounding the table and every sync to tags touched within the window. **Set it ≥ your longest entry lifetime** (longest `cacheLife` expire) — see below |
77
101
 
78
102
  The same options can be passed programmatically:
79
103
 
@@ -91,13 +115,39 @@ const handler = createCacheHandler({ url: process.env.CACHEPLANK_URL, prefix: 'p
91
115
 
92
116
  That is the design, not a shortcut. Two pods will each render and each hold their
93
117
  own copy; what they agree on is *what is stale*, not *what is cached*. In
94
- exchange, the shared state is one KB-scale table, and a cold pod simply
95
- regenerates locally instead of paying a network round-trip for every read.
96
-
97
- The handler is **fail-open**: if the database is unreachable, `get` still serves
98
- valid local entries, and `updateTags` / `refreshTags` resolve without throwing
99
- (after logging exactly one warning). cacheplank never throws into Next's request
100
- path.
118
+ exchange, the shared state is one small table (≈48 bytes per distinct tag ever
119
+ revalidated), and a cold pod simply regenerates locally instead of paying a
120
+ network round-trip for every read.
121
+
122
+ The handlers are **fail-open**: if the database is unreachable, `get` still
123
+ serves valid local entries, and `updateTags` / `refreshTags` / `revalidateTag`
124
+ resolve without throwing (after logging exactly one warning). cacheplank never
125
+ throws into Next's request path.
126
+
127
+ There is a **bounded convergence window**: a pod's in-process view of
128
+ invalidation is refreshed from the shared table at most every
129
+ `CACHEPLANK_STAMPS_TTL_MS` (default 3s). A pod therefore converges on another
130
+ pod's `revalidateTag` within that window, not instantaneously. Set the env var to
131
+ `0` to consult the shared table on every read. Once the memo expires,
132
+ concurrent reads coalesce into a single windowed query (single-flight
133
+ refresh), so a read burst costs one stamp query per handler instance per
134
+ window. That query only reads stamps **inside the retention window**
135
+ (`CACHEPLANK_STAMPS_RETENTION_MS`, default 30 days) via an auto-provisioned
136
+ index on the stamp age — so sync cost and memo size track the tags *touched
137
+ recently*, not every tag ever stamped. Rows are never deleted; they simply age
138
+ out of the window (a 48-byte row that nothing reads costs disk alone).
139
+
140
+ **The retention window is a correctness knob, not a tuning knob.** A stamp
141
+ guards entries written before it, and entries die at `timestamp + expire`;
142
+ so a stamp is provably inert once older than the app's longest entry
143
+ lifetime. Keep `CACHEPLANK_STAMPS_RETENTION_MS` ≥ your longest `cacheLife`
144
+ expire, or entries that outlive the window (including no-expiry static
145
+ pages) can resurrect stale after the window passes.
146
+
147
+ After a **database error** (as opposed to the URL merely being unset), the pod
148
+ backs off 30s before retrying the store — fail-open recovery can therefore lag
149
+ the configured `CACHEPLANK_STAMPS_TTL_MS` by that much. With no URL configured
150
+ at all, the handler warns once and stays process-local permanently.
101
151
 
102
152
  ## Invariants (the compatibility contract)
103
153
 
@@ -115,15 +165,30 @@ the thing npm actually ships:
115
165
  6. Expiry mirrors Next's own bounds: an entry is a miss past `revalidate` **or**
116
166
  past `expire` (the wrapper discards on either), `expire < 0` is the tiered-cache
117
167
  eviction sentinel → miss, and `expire === 0` is dynamic and not stored in
118
- production.
119
-
120
- Plus a cross-process fixture: two real child processes, one on-disk `file:` DB,
121
- a stamp written by one observed by the other. Run everything with:
168
+ production. In dev (when `__NEXT_DEV_SERVER` is set) both bounds widen to
169
+ `MIN_PRERENDERABLE_EXPIRE` (300s), matching next's own dev retention, and
170
+ `CACHEPLANK_MAX_ENTRIES=0` disables entry storage (next's `maxSize: 0`
171
+ semantics).
172
+ 7. Stamp refreshes are single-flight: however many reads observe an expired
173
+ memo together, exactly one shared-table query is issued per handler
174
+ instance, and the rest await its result.
175
+
176
+ The same invariants are enforced for **both** handlers (the plural
177
+ `cacheHandlers` and the singular `cacheHandler`), plus a cross-process fixture:
178
+ two real child processes, one on-disk `file:` DB, a stamp written by one observed
179
+ by the other. Run everything with:
122
180
 
123
181
  ```sh
124
- npm run build && npm test
182
+ npm run build && npm test && npm run test:e2e
125
183
  ```
126
184
 
185
+ `npm run test:e2e` is the end-to-end proof of the issue that motivated the
186
+ singular handler: it builds a real `next` app with a **fully-static** route,
187
+ copies it into two independent pods (separate `.next`, separate memory, sharing
188
+ only the stamp DB), and asserts that pod B converges on pod A's
189
+ `revalidateTag`. Run it with `WITH_SINGULAR=0` for the control — without the
190
+ singular handler, pod B does not converge.
191
+
127
192
  ## Compatibility notes
128
193
 
129
194
  **Verified against [next@16.3.8](https://www.npmjs.com/package/next/v/16.3.8);
@@ -155,6 +220,25 @@ package's source (code is truth, prose isn't):
155
220
  5. **The default handler is the reference.** Next's in-memory handler is
156
221
  `dist/server/lib/cache-handlers/default.js`; cacheplank's expiry logic and its
157
222
  negative-`expire` / `expire === 0` handling are mirrored from it deliberately.
223
+ 6. **There are two cache-handler interfaces, not one.** Next resolves
224
+ `cacheHandlers` (plural, five-method) and `cacheHandler` (singular, legacy
225
+ incremental/ISR) independently — `next-server.js` imports both, and
226
+ `IncrementalCache` constructs the singular one with `new`. A fully-static
227
+ prerendered route never touches the plural handler; it is served through the
228
+ singular incremental cache (or, with no handler configured, a pod-local
229
+ on-disk `route-cache`). **A singular `cacheHandler` makes Next bypass that
230
+ on-disk file and consult the handler for every request**, which is what lets
231
+ static routes converge. cacheplank ships both entries off one shared table.
232
+ The singular path is resolved against `.next/`, so it must be absolute.
233
+ 7. **The singular handler is instantiated per request.** `IncrementalCache` does
234
+ `new CurCacheHandler(ctx)` for each request while the module itself is
235
+ imported once, so cacheplank keeps all singular-handler state (the entry LRU
236
+ and the stamp memo) in a module-level closure — shared across those
237
+ instances. Singular entries are opaque values (Buffers, headers, segment
238
+ `Map`s); cacheplank retains the object by reference, exactly like Next's own
239
+ in-memory `FileSystemCache`. Time-based `revalidate` / `expire` / `isStale`
240
+ semantics are owned by the wrapper, not the handler, so the singular handler
241
+ implements exactly one policy: shared tag invalidation.
158
242
 
159
243
  ## License
160
244
 
@@ -0,0 +1,340 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/cache-handler.ts
21
+ var cache_handler_exports = {};
22
+ __export(cache_handler_exports, {
23
+ createIncrementalCacheHandler: () => createIncrementalCacheHandler,
24
+ default: () => cache_handler_default
25
+ });
26
+ module.exports = __toCommonJS(cache_handler_exports);
27
+
28
+ // src/index.ts
29
+ var import_client = require("@libsql/client");
30
+ var RETRY_BACKOFF_MS = 3e4;
31
+ var DEFAULT_MAX_ENTRIES = 2e3;
32
+ var DEFAULT_STAMPS_TTL_MS = 3e3;
33
+ var DEFAULT_STAMPS_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
34
+ var now = () => Date.now();
35
+ function envNumber(name) {
36
+ const raw = process.env[name]?.trim();
37
+ if (!raw) return void 0;
38
+ const value = Number(raw);
39
+ return Number.isFinite(value) ? value : void 0;
40
+ }
41
+ function resolveOptions(options) {
42
+ const configuredMax = envNumber("CACHEPLANK_MAX_ENTRIES");
43
+ const configuredTtl = envNumber("CACHEPLANK_STAMPS_TTL_MS");
44
+ const configuredRetention = envNumber("CACHEPLANK_STAMPS_RETENTION_MS");
45
+ return {
46
+ url: options.url ?? process.env.CACHEPLANK_URL ?? process.env.BUNNY_DATABASE_URL,
47
+ authToken: options.authToken ?? process.env.CACHEPLANK_AUTH_TOKEN ?? process.env.BUNNY_DATABASE_AUTH_TOKEN,
48
+ prefix: options.prefix ?? process.env.CACHEPLANK_PREFIX ?? "",
49
+ // `>= 0` so `CACHEPLANK_MAX_ENTRIES=0` disables entry storage, mirroring
50
+ // next's own `maxSize === 0` no-op handler; blank/garbage falls back.
51
+ maxEntries: options.maxEntries ?? (configuredMax !== void 0 && configuredMax >= 0 ? configuredMax : DEFAULT_MAX_ENTRIES),
52
+ tagCheckTtlMs: options.stampsTtlMs ?? (configuredTtl !== void 0 && configuredTtl >= 0 ? configuredTtl : DEFAULT_STAMPS_TTL_MS),
53
+ // Window reads need a positive horizon; zero/blank/garbage → the default.
54
+ stampsRetentionMs: options.stampsRetentionMs ?? (configuredRetention !== void 0 && configuredRetention > 0 ? configuredRetention : DEFAULT_STAMPS_RETENTION_MS),
55
+ warn: options.warn ?? ((message) => console.warn(`[cacheplank] ${message}`))
56
+ };
57
+ }
58
+ function createStampStore(opts) {
59
+ const stamps = /* @__PURE__ */ new Map();
60
+ let stampsSyncedAt = 0;
61
+ let client;
62
+ let ready;
63
+ let retryAt = 0;
64
+ let warned = false;
65
+ function warnOnce(message) {
66
+ if (!warned) {
67
+ warned = true;
68
+ opts.warn(message);
69
+ }
70
+ }
71
+ function observe(tag, at) {
72
+ if (at > (stamps.get(tag) ?? 0)) stamps.set(tag, at);
73
+ }
74
+ function ensureStore() {
75
+ if (ready) return ready;
76
+ if (!opts.url) {
77
+ warnOnce("no CACHEPLANK_URL / BUNNY_DATABASE_URL set; tag stamps are process-local only");
78
+ return Promise.resolve(false);
79
+ }
80
+ if (now() < retryAt) return Promise.resolve(false);
81
+ ready = (async () => {
82
+ try {
83
+ const created = (0, import_client.createClient)({ url: opts.url, authToken: opts.authToken });
84
+ await created.execute(
85
+ "CREATE TABLE IF NOT EXISTS tag_stamps (tag TEXT PRIMARY KEY, revalidated_at INTEGER NOT NULL)"
86
+ );
87
+ await created.execute(
88
+ "CREATE INDEX IF NOT EXISTS tag_stamps_revalidated_at ON tag_stamps(revalidated_at)"
89
+ );
90
+ client = created;
91
+ return true;
92
+ } catch (error) {
93
+ retryAt = now() + RETRY_BACKOFF_MS;
94
+ ready = void 0;
95
+ warnOnce(`tag-stamp store unavailable; continuing fail-open (${error.message})`);
96
+ return false;
97
+ }
98
+ })();
99
+ return ready;
100
+ }
101
+ let syncing;
102
+ function sync() {
103
+ if (now() - stampsSyncedAt < opts.tagCheckTtlMs) return Promise.resolve();
104
+ if (syncing) return syncing;
105
+ stampsSyncedAt = now();
106
+ syncing = (async () => {
107
+ try {
108
+ if (!await ensureStore() || !client) return;
109
+ const result = await client.execute({
110
+ sql: "SELECT tag, revalidated_at FROM tag_stamps WHERE revalidated_at > ?",
111
+ args: [now() - opts.stampsRetentionMs]
112
+ });
113
+ for (const row of result.rows) {
114
+ const at = Number(row.revalidated_at);
115
+ if (Number.isFinite(at)) observe(String(row.tag), at);
116
+ }
117
+ } catch (error) {
118
+ retryAt = now() + RETRY_BACKOFF_MS;
119
+ warnOnce(`tag-stamp read failed; serving local state (${error.message})`);
120
+ } finally {
121
+ syncing = void 0;
122
+ }
123
+ })();
124
+ return syncing;
125
+ }
126
+ return {
127
+ stamps,
128
+ sync,
129
+ async expiration(tags) {
130
+ await sync();
131
+ let max = 0;
132
+ for (const tag of tags) {
133
+ const at = stamps.get(tag) ?? 0;
134
+ if (at > max) max = at;
135
+ }
136
+ return max;
137
+ },
138
+ async update(tags) {
139
+ const at = now();
140
+ for (const tag of tags) observe(tag, at);
141
+ if (tags.length === 0) return;
142
+ try {
143
+ if (!await ensureStore() || !client) return;
144
+ await client.batch(
145
+ tags.map((tag) => ({
146
+ sql: "INSERT INTO tag_stamps(tag, revalidated_at) VALUES (?, ?) ON CONFLICT(tag) DO UPDATE SET revalidated_at = MAX(revalidated_at, excluded.revalidated_at)",
147
+ args: [tag, at]
148
+ })),
149
+ "write"
150
+ );
151
+ } catch (error) {
152
+ warnOnce(`tag-stamp write failed; local state kept (${error.message})`);
153
+ }
154
+ }
155
+ };
156
+ }
157
+ function remember(entries, key, value, max) {
158
+ entries.delete(key);
159
+ entries.set(key, value);
160
+ while (entries.size > max) {
161
+ const oldest = entries.keys().next().value;
162
+ if (oldest === void 0) break;
163
+ entries.delete(oldest);
164
+ }
165
+ }
166
+ function valueTags(value) {
167
+ if (!value) return [];
168
+ const tags = value.tags;
169
+ if (Array.isArray(tags)) return tags.filter((t) => typeof t === "string");
170
+ const headers = value.headers;
171
+ const header = headers?.["x-next-cache-tags"];
172
+ if (typeof header === "string") return header.split(",");
173
+ return [];
174
+ }
175
+ function createCacheHandler(options = {}) {
176
+ const opts = resolveOptions(options);
177
+ const store = createStampStore(opts);
178
+ const entries = /* @__PURE__ */ new Map();
179
+ const pendingSets = /* @__PURE__ */ new Map();
180
+ return {
181
+ async get(cacheKey, softTags) {
182
+ const key = opts.prefix + cacheKey;
183
+ const pending = pendingSets.get(key);
184
+ if (pending) await pending.catch(() => {
185
+ });
186
+ const entry = entries.get(key);
187
+ if (!entry) return void 0;
188
+ entries.delete(key);
189
+ entries.set(key, entry);
190
+ if (entry.expire < 0) {
191
+ entries.delete(key);
192
+ return void 0;
193
+ }
194
+ const age = now() - entry.timestamp;
195
+ const dev = Boolean(process.env.__NEXT_DEV_SERVER);
196
+ const maxAgeSeconds = dev ? Math.max(entry.expire, 300) : entry.revalidate;
197
+ const expireBoundSeconds = dev ? Math.max(entry.expire, 300) : entry.expire;
198
+ if (!(age < maxAgeSeconds * 1e3) || entry.expire >= 0 && !(age < expireBoundSeconds * 1e3)) {
199
+ return void 0;
200
+ }
201
+ await store.sync();
202
+ for (const tag of entry.tags) {
203
+ if ((store.stamps.get(tag) ?? 0) >= entry.timestamp) return void 0;
204
+ }
205
+ for (const tag of softTags) {
206
+ if ((store.stamps.get(tag) ?? 0) >= entry.timestamp) return void 0;
207
+ }
208
+ const bytes = entry.bytes;
209
+ return {
210
+ value: new ReadableStream({
211
+ start(controller) {
212
+ controller.enqueue(bytes);
213
+ controller.close();
214
+ }
215
+ }),
216
+ tags: entry.tags,
217
+ stale: entry.stale,
218
+ timestamp: entry.timestamp,
219
+ expire: entry.expire,
220
+ revalidate: entry.revalidate
221
+ };
222
+ },
223
+ async set(cacheKey, pendingEntry) {
224
+ const key = opts.prefix + cacheKey;
225
+ let release = () => {
226
+ };
227
+ const gate = new Promise((resolve) => {
228
+ release = resolve;
229
+ });
230
+ pendingSets.set(key, gate);
231
+ try {
232
+ const entry = await pendingEntry;
233
+ if (entry.expire === 0 && !process.env.__NEXT_DEV_SERVER) return;
234
+ const bytes = await drainStream(entry.value);
235
+ remember(
236
+ entries,
237
+ key,
238
+ {
239
+ bytes,
240
+ tags: entry.tags ?? [],
241
+ timestamp: entry.timestamp,
242
+ expire: entry.expire,
243
+ revalidate: entry.revalidate,
244
+ stale: entry.stale
245
+ },
246
+ opts.maxEntries
247
+ );
248
+ } catch (error) {
249
+ opts.warn(`failed to buffer a cache entry (${error.message})`);
250
+ } finally {
251
+ release();
252
+ pendingSets.delete(key);
253
+ }
254
+ },
255
+ async refreshTags() {
256
+ await store.sync();
257
+ },
258
+ async getExpiration(tags) {
259
+ return store.expiration(tags);
260
+ },
261
+ async updateTags(tags, _durations) {
262
+ await store.update(tags);
263
+ }
264
+ };
265
+ }
266
+ function createIncrementalCacheHandler(options = {}) {
267
+ const opts = resolveOptions(options);
268
+ const store = createStampStore(opts);
269
+ const entries = /* @__PURE__ */ new Map();
270
+ return class CacheplankIncrementalCacheHandler {
271
+ // Next only ever passes its own context; we read none of it here (the
272
+ // meaningful context is supplied per call to `get`/`set`).
273
+ constructor(_ctx) {
274
+ }
275
+ async get(cacheKey, ctx) {
276
+ const key = opts.prefix + cacheKey;
277
+ const entry = entries.get(key);
278
+ if (!entry) return null;
279
+ entries.delete(key);
280
+ entries.set(key, entry);
281
+ await store.sync();
282
+ const tags = valueTags(entry.value);
283
+ if (ctx.tags) tags.push(...ctx.tags);
284
+ if (ctx.softTags) tags.push(...ctx.softTags);
285
+ for (const tag of tags) {
286
+ if ((store.stamps.get(tag) ?? 0) >= entry.lastModified) {
287
+ entries.delete(key);
288
+ return null;
289
+ }
290
+ }
291
+ return { lastModified: entry.lastModified, value: entry.value };
292
+ }
293
+ async set(cacheKey, data, _ctx) {
294
+ const key = opts.prefix + cacheKey;
295
+ if (data == null) {
296
+ entries.delete(key);
297
+ return;
298
+ }
299
+ remember(entries, key, { lastModified: now(), value: data }, opts.maxEntries);
300
+ }
301
+ async revalidateTag(tags, _durations) {
302
+ await store.update(typeof tags === "string" ? [tags] : tags);
303
+ }
304
+ resetRequestCache() {
305
+ }
306
+ };
307
+ }
308
+ async function drainStream(stream) {
309
+ const reader = stream.getReader();
310
+ const chunks = [];
311
+ let size = 0;
312
+ try {
313
+ for (; ; ) {
314
+ const { done, value } = await reader.read();
315
+ if (done) break;
316
+ if (value) {
317
+ chunks.push(value);
318
+ size += value.byteLength;
319
+ }
320
+ }
321
+ } finally {
322
+ reader.releaseLock();
323
+ }
324
+ const bytes = new Uint8Array(size);
325
+ let offset = 0;
326
+ for (const chunk of chunks) {
327
+ bytes.set(chunk, offset);
328
+ offset += chunk.byteLength;
329
+ }
330
+ return bytes;
331
+ }
332
+ var index_default = createCacheHandler();
333
+
334
+ // src/cache-handler.ts
335
+ var cache_handler_default = createIncrementalCacheHandler();
336
+ // Annotate the CommonJS export names for ESM import in node:
337
+ 0 && (module.exports = {
338
+ createIncrementalCacheHandler
339
+ });
340
+ //# sourceMappingURL=cache-handler.cjs.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/cache-handler.ts", "../src/index.ts"],
4
+ "sourcesContent": ["/**\n * cacheplank/cache-handler \u2014 the singular `cacheHandler` entry.\n *\n * Wire this as `cacheHandler` in `next.config` to give Next's legacy\n * incremental/ISR cache (prerendered static `APP_PAGE` routes, `APP_ROUTE`,\n * `PAGES`, fetch cache) the same shared-invalidation behaviour the plural\n * `cacheHandlers` entry already has. Both share one `tag_stamps` table, so a\n * `revalidateTag` from any pod converges every entry kind on every pod.\n *\n * // next.config.js\n * module.exports = {\n * cacheComponents: true,\n * cacheHandlers: { default: require.resolve('cacheplank') },\n * cacheHandler: require.resolve('cacheplank/cache-handler'),\n * };\n *\n * Next resolves this path against `.next/`, so it must be **absolute** or a\n * `file://` URL \u2014 `require.resolve(...)` yields an absolute path (README note\n * #6).\n */\nimport { createIncrementalCacheHandler } from './index';\n\n/** Next does `new CurCacheHandler(ctx)`, and loads `mod.default || mod`. */\nexport default createIncrementalCacheHandler();\n\nexport { createIncrementalCacheHandler } from './index';\n", "/**\n * cacheplank \u2014 \"the shared plank your pods walk across.\"\n *\n * A single-file, tag-based distributed cache handler for Next.js 16, backed by\n * libSQL.\n *\n * Cache *entries* stay local to each process (an in-memory LRU). Only tag\n * *invalidation* is shared, through one tiny libSQL table (`tag_stamps`). Any\n * number of pods/regions converge because every `get` compares the entry's\n * timestamp against the shared invalidation stamps.\n *\n * Two handlers, one invariant. Next 16 exposes two distinct, unrelated cache\n * interfaces and resolves them from two different config keys:\n *\n * 1. `cacheHandlers` (plural) \u2014 a five-method API used for `\"use cache\"` /\n * fetch-path entries. cacheplank's `default` export implements this.\n * 2. `cacheHandler` (singular) \u2014 the legacy incremental/ISR handler\n * (`get`/`set`/`revalidateTag`/`resetRequestCache`) that sits under\n * prerendered `APP_PAGE` / `APP_ROUTE` / `PAGES` routes and the fetch\n * cache. The `cacheplank/cache-handler` entry implements this.\n *\n * Both share the same `tag_stamps` table and the same convergence rule, so a\n * `revalidateTag` from any pod invalidates both kinds of entry everywhere.\n *\n * Ground truth for the contracts below: the installed `next@16.3.8` package \u2014\n * `dist/server/lib/cache-handlers/{types,default}.js`,\n * `dist/server/lib/incremental-cache/{index,file-system-cache}.js`,\n * `dist/server/lib/incremental-cache/tags-manifest.external.js`, and\n * `dist/server/use-cache/{use-cache-wrapper,handlers}.js`. See README\n * \"Compatibility notes\".\n */\nimport { createClient, type Client } from '@libsql/client';\n\n/* ------------------------------------------------------------------------- *\n * Next 16 `cacheHandlers` (plural) contract \u2014 the five-method, `use cache` API.*\n * Hand-written: Next does not export these types publicly (README note #1). *\n * ------------------------------------------------------------------------- */\n\n/** A timestamp in milliseconds elapsed since the epoch. */\nexport type Timestamp = number;\n\n/** Mirrors `CacheEntry` in next@16.3.8. Durations are in seconds. */\nexport interface CacheEntry {\n /** The stored value. May be partially written / error while pending. */\n value: ReadableStream<Uint8Array>;\n /** Tags configured for the entry, excluding soft tags. */\n tags: string[];\n /** Client hint only; not used to compute expiration. [seconds] */\n stale: number;\n /** When the entry was created. [ms epoch] */\n timestamp: Timestamp;\n /** How long the entry may be used. [seconds] */\n expire: number;\n /** How long until the entry should be revalidated. [seconds] */\n revalidate: number;\n}\n\n/** Mirrors `CacheHandler` in next@16.3.8. */\nexport interface CacheHandler {\n get(cacheKey: string, softTags: string[]): Promise<CacheEntry | undefined>;\n set(cacheKey: string, pendingEntry: Promise<CacheEntry>): Promise<void>;\n refreshTags(): Promise<void>;\n getExpiration(tags: string[]): Promise<Timestamp>;\n updateTags(tags: string[], durations?: { expire?: number }): Promise<void>;\n}\n\n/* ------------------------------------------------------------------------- *\n * Next 16 `cacheHandler` (singular) contract \u2014 the legacy incremental/ISR API. *\n * Mirrors `CacheHandler`/`CacheHandlerValue` in *\n * `next/dist/server/lib/incremental-cache/index.d.ts`. Values are opaque *\n * JSON-ish objects (Buffers/headers/segment maps); we never introspect them, *\n * only the tags they carry. *\n * ------------------------------------------------------------------------- */\n\n/** Mirrors `GetIncremental*Context` (union) in next@16.3.8 \u2014 the fields we read. */\nexport interface IncrementalCacheContext {\n kind: string;\n route?: string;\n revalidate?: number;\n /** Fetch-cache entry tags (only for `kind: 'FETCH'`). */\n tags?: string[];\n /** Route-path-derived tags Next passes for staleness checks. */\n softTags?: string[];\n fetchCache?: boolean;\n isFallback?: boolean;\n isRoutePPREnabled?: boolean;\n}\n\n/** A stored incremental value. Opaque to us beyond `kind` and any tags. */\nexport interface IncrementalCacheValue {\n kind: string;\n}\n\n/** Mirrors `CacheHandlerValue` in next@16.3.8. */\nexport interface IncrementalCacheHandlerValue {\n lastModified: number;\n age?: number;\n cacheState?: string;\n value: IncrementalCacheValue | null;\n}\n\n/** Mirrors the singular `CacheHandler` class in next@16.3.8. */\nexport interface IncrementalCacheHandler {\n get(cacheKey: string, ctx: IncrementalCacheContext): Promise<IncrementalCacheHandlerValue | null>;\n set(cacheKey: string, data: IncrementalCacheValue | null, ctx: IncrementalCacheContext): Promise<void>;\n revalidateTag(tags: string | string[], durations?: { expire?: number }): Promise<void>;\n resetRequestCache(): void;\n}\n\n/* ------------------------------------------------------------------------- *\n * Options. *\n * ------------------------------------------------------------------------- */\n\nexport interface CacheplankOptions {\n /** libSQL URL. Defaults to `CACHEPLANK_URL`, then `BUNNY_DATABASE_URL`. */\n url?: string;\n /** libSQL auth token. Defaults to `CACHEPLANK_AUTH_TOKEN`, then `BUNNY_DATABASE_AUTH_TOKEN`. */\n authToken?: string;\n /** Key namespace. Defaults to `CACHEPLANK_PREFIX` (may be empty). */\n prefix?: string;\n /** Max local entries. Defaults to `CACHEPLANK_MAX_ENTRIES`, else 2000. */\n maxEntries?: number;\n /**\n * How long an in-process tag-stamp memo may be reused before the shared table\n * is re-read. This bounds how stale a pod's view of invalidation can get.\n * Defaults to `CACHEPLANK_STAMPS_TTL_MS`, else 3000. [ms]\n */\n stampsTtlMs?: number;\n /**\n * Stamp retention window: stamps older than this are treated as inert and\n * excluded from shared-table reads. Bounds the shared table to tags touched\n * within the window. MUST be \u2265 the app's longest entry lifetime (e.g. the\n * longest `cacheLife` expire), else entries that outlive the window can\n * resurrect stale. Defaults to `CACHEPLANK_STAMPS_RETENTION_MS`, else\n * 30 days. [ms]\n */\n stampsRetentionMs?: number;\n /** Sink for the single fail-open warning. Defaults to `console.warn`. */\n warn?: (message: string) => void;\n}\n\n/** Fail-open backoff after a stamp-store error, so we don't hammer a dead DB. [ms] */\nconst RETRY_BACKOFF_MS = 30_000;\nconst DEFAULT_MAX_ENTRIES = 2000;\n/** Default soft TTL for the in-process tag-stamp memo. [ms] */\nconst DEFAULT_STAMPS_TTL_MS = 3000;\n/**\n * Default stamp retention window. Stamps older than this are ignored by\n * shared-table reads, bounding the table to tags touched within the window.\n * Generous enough to exceed any realistic max entry TTL (next's own `max`\n * cacheLife profile is a year \u2014 apps using it should raise this).\n * [ms]\n */\nconst DEFAULT_STAMPS_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;\n\nconst now = (): number => Date.now();\n\n/** Trims an env value; returns its numeric value, or undefined if blank/invalid. */\nfunction envNumber(name: string): number | undefined {\n const raw = process.env[name]?.trim();\n if (!raw) return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n}\n\ninterface ResolvedOptions {\n url?: string;\n authToken?: string;\n prefix: string;\n maxEntries: number;\n tagCheckTtlMs: number;\n stampsRetentionMs: number;\n warn: (message: string) => void;\n}\n\nfunction resolveOptions(options: CacheplankOptions): ResolvedOptions {\n const configuredMax = envNumber('CACHEPLANK_MAX_ENTRIES');\n const configuredTtl = envNumber('CACHEPLANK_STAMPS_TTL_MS');\n const configuredRetention = envNumber('CACHEPLANK_STAMPS_RETENTION_MS');\n return {\n url: options.url ?? process.env.CACHEPLANK_URL ?? process.env.BUNNY_DATABASE_URL,\n authToken:\n options.authToken ?? process.env.CACHEPLANK_AUTH_TOKEN ?? process.env.BUNNY_DATABASE_AUTH_TOKEN,\n prefix: options.prefix ?? process.env.CACHEPLANK_PREFIX ?? '',\n // `>= 0` so `CACHEPLANK_MAX_ENTRIES=0` disables entry storage, mirroring\n // next's own `maxSize === 0` no-op handler; blank/garbage falls back.\n maxEntries:\n options.maxEntries ??\n (configuredMax !== undefined && configuredMax >= 0 ? configuredMax : DEFAULT_MAX_ENTRIES),\n tagCheckTtlMs:\n options.stampsTtlMs ??\n (configuredTtl !== undefined && configuredTtl >= 0 ? configuredTtl : DEFAULT_STAMPS_TTL_MS),\n // Window reads need a positive horizon; zero/blank/garbage \u2192 the default.\n stampsRetentionMs:\n options.stampsRetentionMs ??\n (configuredRetention !== undefined && configuredRetention > 0\n ? configuredRetention\n : DEFAULT_STAMPS_RETENTION_MS),\n warn: options.warn ?? ((message: string) => console.warn(`[cacheplank] ${message}`)),\n };\n}\n\n/* ------------------------------------------------------------------------- *\n * Shared tag-stamp store \u2014 the only shared state. *\n * ------------------------------------------------------------------------- */\n\ninterface StampStore {\n /** Monotonic in-process memo of tag \u2192 latest invalidation timestamp. */\n readonly stamps: Map<string, number>;\n /** Refresh the memo from the shared table, respecting the soft TTL. */\n sync(): Promise<void>;\n /** Latest stamp across `tags` (0 if none seen). */\n expiration(tags: string[]): Promise<Timestamp>;\n /** Write a monotone stamp for every tag, and make it self-visible at once. */\n update(tags: string[]): Promise<void>;\n}\n\n/**\n * One libSQL table holds invalidation stamps. Reads and writes are fail-open:\n * a dead store degrades to process-local invalidation, never an exception.\n */\nfunction createStampStore(opts: ResolvedOptions): StampStore {\n const stamps = new Map<string, number>();\n let stampsSyncedAt = 0;\n\n let client: Client | undefined;\n let ready: Promise<boolean> | undefined;\n let retryAt = 0;\n let warned = false;\n function warnOnce(message: string): void {\n if (!warned) {\n warned = true;\n opts.warn(message);\n }\n }\n\n function observe(tag: string, at: number): void {\n if (at > (stamps.get(tag) ?? 0)) stamps.set(tag, at);\n }\n\n function ensureStore(): Promise<boolean> {\n if (ready) return ready;\n if (!opts.url) {\n warnOnce('no CACHEPLANK_URL / BUNNY_DATABASE_URL set; tag stamps are process-local only');\n return Promise.resolve(false);\n }\n if (now() < retryAt) return Promise.resolve(false);\n ready = (async () => {\n try {\n const created = createClient({ url: opts.url as string, authToken: opts.authToken });\n await created.execute(\n 'CREATE TABLE IF NOT EXISTS tag_stamps (tag TEXT PRIMARY KEY, revalidated_at INTEGER NOT NULL)',\n );\n // Makes the retention-windowed sync read a range scan over live rows\n // instead of a full-table scan (rows_read stays O(window), not\n // O(every tag ever stamped)). Idempotent; rides the same lazy init.\n await created.execute(\n 'CREATE INDEX IF NOT EXISTS tag_stamps_revalidated_at ON tag_stamps(revalidated_at)',\n );\n client = created;\n return true;\n } catch (error) {\n retryAt = now() + RETRY_BACKOFF_MS;\n ready = undefined;\n warnOnce(`tag-stamp store unavailable; continuing fail-open (${(error as Error).message})`);\n return false;\n }\n })();\n return ready;\n }\n\n // Single-flight: one in-progress refresh; concurrent callers await it instead\n // of each issuing their own full-table SELECT at a TTL boundary.\n let syncing: Promise<void> | undefined;\n\n function sync(): Promise<void> {\n if (now() - stampsSyncedAt < opts.tagCheckTtlMs) return Promise.resolve();\n if (syncing) return syncing;\n // Claim the freshness window synchronously, so a burst of `get`s that all\n // observe the expired memo together joins the one refresh below.\n stampsSyncedAt = now();\n syncing = (async () => {\n try {\n if (!(await ensureStore()) || !client) return;\n // Retention window: stamps older than the window are provably inert\n // (every entry they could invalidate has expired by then), so they are\n // excluded here. That bounds memo size and read cost to tags touched\n // within the window instead of every tag ever stamped, and keeps the\n // range scan index-backed (see ensureStore).\n const result = await client.execute({\n sql: 'SELECT tag, revalidated_at FROM tag_stamps WHERE revalidated_at > ?',\n args: [now() - opts.stampsRetentionMs],\n });\n for (const row of result.rows) {\n const at = Number(row.revalidated_at);\n if (Number.isFinite(at)) observe(String(row.tag), at);\n }\n } catch (error) {\n retryAt = now() + RETRY_BACKOFF_MS;\n warnOnce(`tag-stamp read failed; serving local state (${(error as Error).message})`);\n } finally {\n syncing = undefined;\n }\n })();\n return syncing;\n }\n\n return {\n stamps,\n sync,\n async expiration(tags) {\n await sync();\n let max = 0;\n for (const tag of tags) {\n const at = stamps.get(tag) ?? 0;\n if (at > max) max = at;\n }\n return max;\n },\n async update(tags) {\n const at = now();\n for (const tag of tags) observe(tag, at);\n if (tags.length === 0) return;\n try {\n if (!(await ensureStore()) || !client) return;\n await client.batch(\n tags.map((tag) => ({\n sql:\n 'INSERT INTO tag_stamps(tag, revalidated_at) VALUES (?, ?) ' +\n 'ON CONFLICT(tag) DO UPDATE SET revalidated_at = MAX(revalidated_at, excluded.revalidated_at)',\n args: [tag, at],\n })),\n 'write',\n );\n } catch (error) {\n warnOnce(`tag-stamp write failed; local state kept (${(error as Error).message})`);\n }\n },\n };\n}\n\n/** LRU insert via Map insertion order, capped at `max`. */\nfunction remember<V>(entries: Map<string, V>, key: string, value: V, max: number): void {\n entries.delete(key);\n entries.set(key, value);\n while (entries.size > max) {\n const oldest = entries.keys().next().value;\n if (oldest === undefined) break;\n entries.delete(oldest);\n }\n}\n\n/** Extract the tag list a stored incremental value carries, if any. */\nfunction valueTags(value: IncrementalCacheValue | null): string[] {\n if (!value) return [];\n const tags = (value as { tags?: unknown }).tags;\n if (Array.isArray(tags)) return tags.filter((t): t is string => typeof t === 'string');\n const headers = (value as { headers?: Record<string, string | string[]> }).headers;\n const header = headers?.['x-next-cache-tags'];\n if (typeof header === 'string') return header.split(',');\n return [];\n}\n\n/* ------------------------------------------------------------------------- *\n * Handler 1 \u2014 `cacheHandlers` (plural). Entries are byte streams. *\n * ------------------------------------------------------------------------- */\n\ninterface StoredEntry {\n bytes: Uint8Array;\n tags: string[];\n timestamp: number;\n expire: number;\n revalidate: number;\n stale: number;\n}\n\n/**\n * Create a `cacheHandlers` (plural) handler. The package's `default` export is\n * one of these, configured from the environment.\n */\nexport function createCacheHandler(options: CacheplankOptions = {}): CacheHandler {\n const opts = resolveOptions(options);\n const store = createStampStore(opts);\n\n // Local entry store, LRU via Map insertion order.\n const entries = new Map<string, StoredEntry>();\n // In-flight `set` calls, so a `get` racing a `set` waits instead of missing.\n const pendingSets = new Map<string, Promise<void>>();\n\n return {\n async get(cacheKey, softTags) {\n const key = opts.prefix + cacheKey;\n const pending = pendingSets.get(key);\n if (pending) await pending.catch(() => {});\n\n const entry = entries.get(key);\n if (!entry) return undefined;\n\n // Touch for LRU recency.\n entries.delete(key);\n entries.set(key, entry);\n\n // A negative `expire` is a tombstone: dropped for good here rather than\n // re-checked on every read (and independently of the retention bounds).\n if (entry.expire < 0) {\n entries.delete(key);\n return undefined;\n }\n // Mirror Next's effective expiry. `set` already dropped `expire: 0` in\n // production. The wrapper discards an entry once\n // `currentTime > timestamp + expire*1000` (always) or past `revalidate`\n // during static generation; the default handler additionally drops past\n // `revalidate` in production (SWR background revalidation takes over).\n // Dropping at either bound here matches what Next would reject anyway and\n // never serves a value the wrapper throws away. In dev next widens the\n // expire bound to MIN_PRERENDERABLE_EXPIRE (300s) \u2014 the default\n // handler's single dev max-age and the wrapper's dev expire check use\n // the same formula \u2014 so reloads of short-`expire` entries still hit; we\n // widen the expire-bound check to match instead of only the max-age\n // one. A `revalidate <= 0` (including SWR's `-1`) drops in production.\n const age = now() - entry.timestamp;\n const dev = Boolean(process.env.__NEXT_DEV_SERVER);\n const maxAgeSeconds = dev ? Math.max(entry.expire, 300) : entry.revalidate;\n const expireBoundSeconds = dev ? Math.max(entry.expire, 300) : entry.expire;\n if (\n !(age < maxAgeSeconds * 1000) ||\n (entry.expire >= 0 && !(age < expireBoundSeconds * 1000))\n ) {\n return undefined;\n }\n\n // Shared invalidation: any tag (own or route soft tag) stamped at or\n // after this entry was written means another pod revalidated it \u2192 miss,\n // so Next regenerates. `>=` mirrors Next's own wrapper-side discard.\n await store.sync();\n for (const tag of entry.tags) {\n if ((store.stamps.get(tag) ?? 0) >= entry.timestamp) return undefined;\n }\n for (const tag of softTags) {\n if ((store.stamps.get(tag) ?? 0) >= entry.timestamp) return undefined;\n }\n\n const bytes = entry.bytes;\n return {\n value: new ReadableStream<Uint8Array>({\n start(controller) {\n controller.enqueue(bytes);\n controller.close();\n },\n }),\n tags: entry.tags,\n stale: entry.stale,\n timestamp: entry.timestamp,\n expire: entry.expire,\n revalidate: entry.revalidate,\n };\n },\n\n async set(cacheKey, pendingEntry) {\n const key = opts.prefix + cacheKey;\n let release: () => void = () => {};\n const gate = new Promise<void>((resolve) => {\n release = resolve;\n });\n pendingSets.set(key, gate);\n try {\n const entry = await pendingEntry;\n // In production an `expire: 0` entry is dynamic and never served back.\n if (entry.expire === 0 && !process.env.__NEXT_DEV_SERVER) return;\n\n const bytes = await drainStream(entry.value);\n remember(\n entries,\n key,\n {\n bytes,\n tags: entry.tags ?? [],\n timestamp: entry.timestamp,\n expire: entry.expire,\n revalidate: entry.revalidate,\n stale: entry.stale,\n },\n opts.maxEntries,\n );\n } catch (error) {\n opts.warn(`failed to buffer a cache entry (${(error as Error).message})`);\n } finally {\n release();\n pendingSets.delete(key);\n }\n },\n\n async refreshTags() {\n await store.sync();\n },\n\n async getExpiration(tags) {\n return store.expiration(tags);\n },\n\n async updateTags(tags, _durations) {\n // `durations.expire` is accepted but not persisted: the shared table holds\n // a single monotone stamp per tag. A profile'd `revalidateTag` therefore\n // invalidates immediately rather than stale-while-revalidate \u2014 safe, and\n // faithful to the one-table model. See README compatibility note #4.\n await store.update(tags);\n },\n };\n}\n\n/* ------------------------------------------------------------------------- *\n * Handler 2 \u2014 `cacheHandler` (singular). Entries are opaque JSON-ish values. *\n * ------------------------------------------------------------------------- */\n\ninterface StoredIncrementalEntry {\n lastModified: number;\n value: IncrementalCacheValue;\n}\n\n/**\n * Create a `cacheHandler` (singular, incremental/ISR) handler **class**.\n *\n * Next instantiates this class once per request (`new CurCacheHandler(ctx)` in\n * `IncrementalCache`) and imports the module once, so all state lives in this\n * closure \u2014 shared across the per-request instances by construction.\n *\n * Unlike the plural handler we do *not* re-implement time-based expiry: the\n * incremental wrapper owns `revalidate`/`expire`/`isStale` semantics (it reads\n * them from the prerender manifest's cache controls), so we implement exactly\n * one policy \u2014 shared tag invalidation \u2014 and let Next decide everything else.\n * That is what makes fully-static `APP_PAGE` routes converge across pods.\n */\nexport function createIncrementalCacheHandler(\n options: CacheplankOptions = {},\n): new (ctx: unknown) => IncrementalCacheHandler {\n const opts = resolveOptions(options);\n const store = createStampStore(opts);\n const entries = new Map<string, StoredIncrementalEntry>();\n\n return class CacheplankIncrementalCacheHandler implements IncrementalCacheHandler {\n // Next only ever passes its own context; we read none of it here (the\n // meaningful context is supplied per call to `get`/`set`).\n constructor(_ctx: unknown) {}\n\n async get(cacheKey: string, ctx: IncrementalCacheContext) {\n const key = opts.prefix + cacheKey;\n const entry = entries.get(key);\n if (!entry) return null;\n\n // Touch for LRU recency.\n entries.delete(key);\n entries.set(key, entry);\n\n // One shared rule, identical to the plural handler: a tag stamped at or\n // after the entry was written means some pod revalidated it \u2192 miss, so\n // Next regenerates (through the same response-cache path that wrote it).\n await store.sync();\n const tags = valueTags(entry.value);\n if (ctx.tags) tags.push(...ctx.tags);\n if (ctx.softTags) tags.push(...ctx.softTags);\n for (const tag of tags) {\n if ((store.stamps.get(tag) ?? 0) >= entry.lastModified) {\n entries.delete(key);\n return null;\n }\n }\n\n return { lastModified: entry.lastModified, value: entry.value };\n }\n\n async set(\n cacheKey: string,\n data: IncrementalCacheValue | null,\n _ctx: IncrementalCacheContext,\n ) {\n const key = opts.prefix + cacheKey;\n // A null datum means \"delete this key\" (Next uses it to drop entries).\n if (data == null) {\n entries.delete(key);\n return;\n }\n // The incremental values are plain objects (Buffers, headers, segment\n // Maps) that Next does not mutate after writing, so we retain the\n // reference \u2014 exactly like Next's own in-memory `FileSystemCache`. Only\n // invalidation is shared; the entry itself never leaves this process.\n remember(entries, key, { lastModified: now(), value: data }, opts.maxEntries);\n }\n\n async revalidateTag(tags: string | string[], _durations?: { expire?: number }) {\n // Same table, same monotone upsert as the plural handler, so a\n // `revalidateTag` invalidates both entry kinds across every pod.\n await store.update(typeof tags === 'string' ? [tags] : tags);\n }\n\n resetRequestCache() {\n // No per-request cache state to reset.\n }\n };\n}\n\n/* ------------------------------------------------------------------------- *\n * Small helpers. *\n * ------------------------------------------------------------------------- */\n\nasync function drainStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {\n const reader = stream.getReader();\n const chunks: Uint8Array[] = [];\n let size = 0;\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n if (value) {\n chunks.push(value);\n size += value.byteLength;\n }\n }\n } finally {\n reader.releaseLock();\n }\n const bytes = new Uint8Array(size);\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.byteLength;\n }\n return bytes;\n}\n\n/**\n * Default singleton for the plural `cacheHandlers` API. Next loads the module\n * via `interopDefault`, so this `default` export is the handler Next uses.\n */\nexport default createCacheHandler();\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,4 @@
1
+ /** Next does `new CurCacheHandler(ctx)`, and loads `mod.default || mod`. */
2
+ declare const _default: new (ctx: unknown) => import("./index").IncrementalCacheHandler;
3
+ export default _default;
4
+ export { createIncrementalCacheHandler } from './index';