cacheplank 0.1.2 → 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 +106 -22
- package/dist/cache-handler.cjs +340 -0
- package/dist/cache-handler.cjs.map +7 -0
- package/dist/cache-handler.d.ts +4 -0
- package/dist/cache-handler.mjs +313 -0
- package/dist/cache-handler.mjs.map +7 -0
- package/dist/index.cjs +205 -102
- package/dist/index.cjs.map +2 -2
- package/dist/index.d.ts +60 -4
- package/dist/index.mjs +203 -101
- package/dist/index.mjs.map +2 -2
- package/package.json +7 -1
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
|
|
6
|
-
[
|
|
7
|
-
|
|
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`).
|
|
11
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
95
|
-
regenerates locally instead of paying a
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
+
}
|