cacheplank 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +160 -2
- package/dist/index.cjs +234 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.mjs +209 -0
- package/dist/index.mjs.map +7 -0
- package/package.json +66 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cacheplank contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,161 @@
|
|
|
1
|
-
#
|
|
1
|
+
# cacheplank
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**The shared plank your pods walk across: one table of tag stamps, everything else stays local.**
|
|
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).
|
|
8
|
+
|
|
9
|
+
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
|
|
12
|
+
timestamp against the shared invalidation stamps. That makes ISR/data-cache cost
|
|
13
|
+
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.
|
|
15
|
+
|
|
16
|
+
## Quickstart
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm i cacheplank
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
# One env pair is the entire migration surface.
|
|
24
|
+
CACHEPLANK_URL=libsql://your-db.turso.io
|
|
25
|
+
CACHEPLANK_AUTH_TOKEN=...
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
// next.config.js (CommonJS)
|
|
30
|
+
module.exports = {
|
|
31
|
+
cacheComponents: true,
|
|
32
|
+
cacheHandlers: {
|
|
33
|
+
default: require.resolve('cacheplank'),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
// next.config.mjs (ESM) — `require` doesn't exist here, so create it:
|
|
40
|
+
import { createRequire } from 'node:module';
|
|
41
|
+
const require = createRequire(import.meta.url);
|
|
42
|
+
|
|
43
|
+
export default {
|
|
44
|
+
cacheComponents: true,
|
|
45
|
+
cacheHandlers: {
|
|
46
|
+
default: require.resolve('cacheplank'),
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
That's it. The package's `default` export is a ready-to-use handler configured
|
|
52
|
+
from the environment.
|
|
53
|
+
|
|
54
|
+
### Storage backends
|
|
55
|
+
|
|
56
|
+
Any libSQL URL scheme works — the table is created lazily on first use.
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
CACHEPLANK_URL=:memory: # tests / single process
|
|
60
|
+
CACHEPLANK_URL=file:./.cache/tag_stamps.db # one box, several processes
|
|
61
|
+
CACHEPLANK_URL=libsql://db.turso.io # Turso (add CACHEPLANK_AUTH_TOKEN)
|
|
62
|
+
CACHEPLANK_URL=libsql://db.bunny.net # Bunny Database (libSQL)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The target deployment injects Bunny's native names, so these are honored as
|
|
66
|
+
fallbacks: `BUNNY_DATABASE_URL` and `BUNNY_DATABASE_AUTH_TOKEN`.
|
|
67
|
+
|
|
68
|
+
### Configuration
|
|
69
|
+
|
|
70
|
+
| Env var | Default | Purpose |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| `CACHEPLANK_URL` | — | libSQL URL (falls back to `BUNNY_DATABASE_URL`) |
|
|
73
|
+
| `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 |
|
|
76
|
+
| `CACHEPLANK_STAMPS_TTL_MS` | `3000` | How long the in-process stamp memo is trusted |
|
|
77
|
+
|
|
78
|
+
The same options can be passed programmatically:
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
import { createCacheHandler } from 'cacheplank';
|
|
82
|
+
|
|
83
|
+
const handler = createCacheHandler({ url: process.env.CACHEPLANK_URL, prefix: 'prod' });
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## What it does *not* do
|
|
87
|
+
|
|
88
|
+
> **cacheplank does not share cache entries between pods. Entries are local and
|
|
89
|
+
> derived; only invalidation is shared. If you want shared entry storage, this is
|
|
90
|
+
> not your package.**
|
|
91
|
+
|
|
92
|
+
That is the design, not a shortcut. Two pods will each render and each hold their
|
|
93
|
+
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.
|
|
101
|
+
|
|
102
|
+
## Invariants (the compatibility contract)
|
|
103
|
+
|
|
104
|
+
These are enforced by the test suite, which runs against the built artifacts —
|
|
105
|
+
the thing npm actually ships:
|
|
106
|
+
|
|
107
|
+
1. `set` → `get` roundtrip returns identical bytes, tags, and timestamps.
|
|
108
|
+
2. `updateTags` is idempotent and monotone across processes sharing one DB
|
|
109
|
+
(`MAX` wins, interleaved upserts all land).
|
|
110
|
+
3. A tag mutation after a write makes the entry miss — entry tags and route
|
|
111
|
+
soft tags alike.
|
|
112
|
+
4. Fail-open: an unreachable store never throws, and local hits still serve.
|
|
113
|
+
5. `get` after `set` works with tags alone (never relies on revalidate times
|
|
114
|
+
round-tripping through Next).
|
|
115
|
+
6. Expiry mirrors Next's own bounds: an entry is a miss past `revalidate` **or**
|
|
116
|
+
past `expire` (the wrapper discards on either), `expire < 0` is the tiered-cache
|
|
117
|
+
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:
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
npm run build && npm test
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Compatibility notes
|
|
128
|
+
|
|
129
|
+
**Verified against [next@16.3.8](https://www.npmjs.com/package/next/v/16.3.8);
|
|
130
|
+
the handler API is platform-unofficial — pin your Next version and treat the
|
|
131
|
+
tests as the contract.** The following were resolved by reading the installed
|
|
132
|
+
package's source (code is truth, prose isn't):
|
|
133
|
+
|
|
134
|
+
1. **Types are hand-written.** Next does not export `CacheHandler` / `CacheEntry`
|
|
135
|
+
from its public surface; the canonical definitions live in
|
|
136
|
+
`dist/server/lib/cache-handlers/types.d.ts`. cacheplank mirrors them exactly
|
|
137
|
+
(including the `[seconds]` durations and the ms-epoch `timestamp`), so it
|
|
138
|
+
compiles without depending on Next's internal paths.
|
|
139
|
+
2. **`revalidatePath` is soft-tag based.** Next derives implicit tags from the
|
|
140
|
+
route path (`_N_T_/…/layout`, `_N_T_/<path>`, with `/` ↔ `/index` aliases) in
|
|
141
|
+
`dist/server/lib/implicit-tags.js` and passes them to `get` as `softTags`.
|
|
142
|
+
`revalidatePath` ultimately calls `updateTags([_N_T_<path>])`, so it flows
|
|
143
|
+
through the same shared-stamp path as any other tag. cacheplank treats
|
|
144
|
+
`softTags` identically to entry tags.
|
|
145
|
+
3. **The cache key embeds a build ID.** `use-cache-wrapper.js` seeds the key with
|
|
146
|
+
`workStore.deploymentId || workStore.buildId`, so a new deploy does not reuse
|
|
147
|
+
old entries. No extra coupling is needed; `CACHEPLANK_PREFIX` remains as an
|
|
148
|
+
optional manual namespace.
|
|
149
|
+
4. **`updateTags` `durations` is accepted but not persisted.** Next passes
|
|
150
|
+
`{ expire }` in seconds when `revalidateTag` uses a cacheLife profile. The
|
|
151
|
+
one-table model stores a single monotone stamp per tag, so a profile-driven
|
|
152
|
+
`revalidateTag` invalidates immediately rather than serving stale-while-
|
|
153
|
+
revalidate. This is safe (never serves stale data); it only forgoes a
|
|
154
|
+
background-refresh window.
|
|
155
|
+
5. **The default handler is the reference.** Next's in-memory handler is
|
|
156
|
+
`dist/server/lib/cache-handlers/default.js`; cacheplank's expiry logic and its
|
|
157
|
+
negative-`expire` / `expire === 0` handling are mirrored from it deliberately.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
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/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createCacheHandler: () => createCacheHandler,
|
|
24
|
+
default: () => index_default
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
var import_client = require("@libsql/client");
|
|
28
|
+
var TAG_CHECK_TTL_MS = (() => {
|
|
29
|
+
const configured = Number(process.env.CACHEPLANK_STAMPS_TTL_MS);
|
|
30
|
+
return Number.isFinite(configured) && configured >= 0 ? configured : 3e3;
|
|
31
|
+
})();
|
|
32
|
+
var RETRY_BACKOFF_MS = 3e4;
|
|
33
|
+
var DEFAULT_MAX_ENTRIES = 2e3;
|
|
34
|
+
var now = () => Date.now();
|
|
35
|
+
function createCacheHandler(options = {}) {
|
|
36
|
+
const url = options.url ?? process.env.CACHEPLANK_URL ?? process.env.BUNNY_DATABASE_URL;
|
|
37
|
+
const authToken = options.authToken ?? process.env.CACHEPLANK_AUTH_TOKEN ?? process.env.BUNNY_DATABASE_AUTH_TOKEN;
|
|
38
|
+
const prefix = options.prefix ?? process.env.CACHEPLANK_PREFIX ?? "";
|
|
39
|
+
const configuredMax = Number(process.env.CACHEPLANK_MAX_ENTRIES);
|
|
40
|
+
const maxEntries = options.maxEntries ?? (Number.isFinite(configuredMax) && configuredMax > 0 ? configuredMax : DEFAULT_MAX_ENTRIES);
|
|
41
|
+
const configuredTtl = Number(process.env.CACHEPLANK_STAMPS_TTL_MS);
|
|
42
|
+
const tagCheckTtlMs = options.stampsTtlMs ?? (Number.isFinite(configuredTtl) && configuredTtl >= 0 ? configuredTtl : TAG_CHECK_TTL_MS);
|
|
43
|
+
const warn = options.warn ?? ((message) => console.warn(`[cacheplank] ${message}`));
|
|
44
|
+
const entries = /* @__PURE__ */ new Map();
|
|
45
|
+
function remember(key, entry) {
|
|
46
|
+
entries.delete(key);
|
|
47
|
+
entries.set(key, entry);
|
|
48
|
+
while (entries.size > maxEntries) {
|
|
49
|
+
const oldest = entries.keys().next().value;
|
|
50
|
+
if (oldest === void 0) break;
|
|
51
|
+
entries.delete(oldest);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const stamps = /* @__PURE__ */ new Map();
|
|
55
|
+
let stampsSyncedAt = 0;
|
|
56
|
+
function observeStamp(tag, at) {
|
|
57
|
+
if (at > (stamps.get(tag) ?? 0)) stamps.set(tag, at);
|
|
58
|
+
}
|
|
59
|
+
let client;
|
|
60
|
+
let ready;
|
|
61
|
+
let retryAt = 0;
|
|
62
|
+
let warned = false;
|
|
63
|
+
function warnOnce(message) {
|
|
64
|
+
if (!warned) {
|
|
65
|
+
warned = true;
|
|
66
|
+
warn(message);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function ensureStore() {
|
|
70
|
+
if (ready) return ready;
|
|
71
|
+
if (!url) {
|
|
72
|
+
warnOnce("no CACHEPLANK_URL / BUNNY_DATABASE_URL set; tag stamps are process-local only");
|
|
73
|
+
return Promise.resolve(false);
|
|
74
|
+
}
|
|
75
|
+
if (now() < retryAt) return Promise.resolve(false);
|
|
76
|
+
ready = (async () => {
|
|
77
|
+
try {
|
|
78
|
+
const created = (0, import_client.createClient)({ url, authToken });
|
|
79
|
+
await created.execute(
|
|
80
|
+
"CREATE TABLE IF NOT EXISTS tag_stamps (tag TEXT PRIMARY KEY, revalidated_at INTEGER NOT NULL)"
|
|
81
|
+
);
|
|
82
|
+
client = created;
|
|
83
|
+
return true;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
retryAt = now() + RETRY_BACKOFF_MS;
|
|
86
|
+
ready = void 0;
|
|
87
|
+
warnOnce(`tag-stamp store unavailable; continuing fail-open (${error.message})`);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
})();
|
|
91
|
+
return ready;
|
|
92
|
+
}
|
|
93
|
+
async function syncStamps() {
|
|
94
|
+
if (now() - stampsSyncedAt < tagCheckTtlMs) return;
|
|
95
|
+
try {
|
|
96
|
+
if (!await ensureStore() || !client) return;
|
|
97
|
+
const result = await client.execute("SELECT tag, revalidated_at FROM tag_stamps");
|
|
98
|
+
for (const row of result.rows) {
|
|
99
|
+
const at = Number(row.revalidated_at);
|
|
100
|
+
if (Number.isFinite(at)) observeStamp(String(row.tag), at);
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
retryAt = now() + RETRY_BACKOFF_MS;
|
|
104
|
+
warnOnce(`tag-stamp read failed; serving local state (${error.message})`);
|
|
105
|
+
} finally {
|
|
106
|
+
stampsSyncedAt = now();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const pendingSets = /* @__PURE__ */ new Map();
|
|
110
|
+
return {
|
|
111
|
+
async get(cacheKey, softTags) {
|
|
112
|
+
const key = prefix + cacheKey;
|
|
113
|
+
const pending = pendingSets.get(key);
|
|
114
|
+
if (pending) await pending.catch(() => {
|
|
115
|
+
});
|
|
116
|
+
const entry = entries.get(key);
|
|
117
|
+
if (!entry) return void 0;
|
|
118
|
+
entries.delete(key);
|
|
119
|
+
entries.set(key, entry);
|
|
120
|
+
if (entry.expire < 0) {
|
|
121
|
+
entries.delete(key);
|
|
122
|
+
return void 0;
|
|
123
|
+
}
|
|
124
|
+
const age = now() - entry.timestamp;
|
|
125
|
+
const maxAgeSeconds = process.env.__NEXT_DEV_SERVER ? Math.max(entry.expire, 300) : entry.revalidate;
|
|
126
|
+
if (!(age < maxAgeSeconds * 1e3) || entry.expire >= 0 && !(age < entry.expire * 1e3)) {
|
|
127
|
+
return void 0;
|
|
128
|
+
}
|
|
129
|
+
await syncStamps();
|
|
130
|
+
for (const tag of entry.tags) {
|
|
131
|
+
if ((stamps.get(tag) ?? 0) >= entry.timestamp) return void 0;
|
|
132
|
+
}
|
|
133
|
+
for (const tag of softTags) {
|
|
134
|
+
if ((stamps.get(tag) ?? 0) >= entry.timestamp) return void 0;
|
|
135
|
+
}
|
|
136
|
+
const bytes = entry.bytes;
|
|
137
|
+
return {
|
|
138
|
+
value: new ReadableStream({
|
|
139
|
+
start(controller) {
|
|
140
|
+
controller.enqueue(bytes);
|
|
141
|
+
controller.close();
|
|
142
|
+
}
|
|
143
|
+
}),
|
|
144
|
+
tags: entry.tags,
|
|
145
|
+
stale: entry.stale,
|
|
146
|
+
timestamp: entry.timestamp,
|
|
147
|
+
expire: entry.expire,
|
|
148
|
+
revalidate: entry.revalidate
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
async set(cacheKey, pendingEntry) {
|
|
152
|
+
const key = prefix + cacheKey;
|
|
153
|
+
let release = () => {
|
|
154
|
+
};
|
|
155
|
+
const gate = new Promise((resolve) => {
|
|
156
|
+
release = resolve;
|
|
157
|
+
});
|
|
158
|
+
pendingSets.set(key, gate);
|
|
159
|
+
try {
|
|
160
|
+
const entry = await pendingEntry;
|
|
161
|
+
if (entry.expire === 0 && !process.env.__NEXT_DEV_SERVER) return;
|
|
162
|
+
const reader = entry.value.getReader();
|
|
163
|
+
const chunks = [];
|
|
164
|
+
let size = 0;
|
|
165
|
+
try {
|
|
166
|
+
for (; ; ) {
|
|
167
|
+
const { done, value } = await reader.read();
|
|
168
|
+
if (done) break;
|
|
169
|
+
if (value) {
|
|
170
|
+
chunks.push(value);
|
|
171
|
+
size += value.byteLength;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
reader.releaseLock();
|
|
176
|
+
}
|
|
177
|
+
const bytes = new Uint8Array(size);
|
|
178
|
+
let offset = 0;
|
|
179
|
+
for (const chunk of chunks) {
|
|
180
|
+
bytes.set(chunk, offset);
|
|
181
|
+
offset += chunk.byteLength;
|
|
182
|
+
}
|
|
183
|
+
remember(key, {
|
|
184
|
+
bytes,
|
|
185
|
+
tags: entry.tags ?? [],
|
|
186
|
+
timestamp: entry.timestamp,
|
|
187
|
+
expire: entry.expire,
|
|
188
|
+
revalidate: entry.revalidate,
|
|
189
|
+
stale: entry.stale
|
|
190
|
+
});
|
|
191
|
+
} catch (error) {
|
|
192
|
+
warnOnce(`failed to buffer a cache entry (${error.message})`);
|
|
193
|
+
} finally {
|
|
194
|
+
release();
|
|
195
|
+
pendingSets.delete(key);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
async refreshTags() {
|
|
199
|
+
await syncStamps();
|
|
200
|
+
},
|
|
201
|
+
async getExpiration(tags) {
|
|
202
|
+
await syncStamps();
|
|
203
|
+
let max = 0;
|
|
204
|
+
for (const tag of tags) {
|
|
205
|
+
const at = stamps.get(tag) ?? 0;
|
|
206
|
+
if (at > max) max = at;
|
|
207
|
+
}
|
|
208
|
+
return max;
|
|
209
|
+
},
|
|
210
|
+
async updateTags(tags, _durations) {
|
|
211
|
+
const at = now();
|
|
212
|
+
for (const tag of tags) observeStamp(tag, at);
|
|
213
|
+
if (tags.length === 0) return;
|
|
214
|
+
try {
|
|
215
|
+
if (!await ensureStore() || !client) return;
|
|
216
|
+
await client.batch(
|
|
217
|
+
tags.map((tag) => ({
|
|
218
|
+
sql: "INSERT INTO tag_stamps(tag, revalidated_at) VALUES (?, ?) ON CONFLICT(tag) DO UPDATE SET revalidated_at = MAX(revalidated_at, excluded.revalidated_at)",
|
|
219
|
+
args: [tag, at]
|
|
220
|
+
})),
|
|
221
|
+
"write"
|
|
222
|
+
);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
warnOnce(`tag-stamp write failed; local state kept (${error.message})`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
var index_default = createCacheHandler();
|
|
230
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
231
|
+
0 && (module.exports = {
|
|
232
|
+
createCacheHandler
|
|
233
|
+
});
|
|
234
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/index.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * cacheplank \u2014 \"the shared plank your pods walk across.\"\n *\n * A single-file, tag-based distributed cache handler for Next.js 16's\n * `cacheHandlers` API, backed by 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 * Ground truth for the contract below: the installed `next@16.3.8` package \u2014\n * `dist/server/lib/cache-handlers/{types,default}.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 cache-handler contract. *\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\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 /** Sink for the single fail-open warning. Defaults to `console.warn`. */\n warn?: (message: string) => void;\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 * Soft TTL for the in-process tag-stamp memo. [ms]. Defaults to 3000; override\n * with `CACHEPLANK_STAMPS_TTL_MS` (e.g. `0` to read the shared table on every\n * `get`, which the cross-process test relies on).\n */\nconst TAG_CHECK_TTL_MS = (() => {\n const configured = Number(process.env.CACHEPLANK_STAMPS_TTL_MS);\n return Number.isFinite(configured) && configured >= 0 ? configured : 3000;\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\nconst now = (): number => Date.now();\n\n/**\n * Create a cache handler. The package's `default` export is one of these,\n * configured from the environment.\n */\nexport function createCacheHandler(options: CacheplankOptions = {}): CacheHandler {\n const url = options.url ?? process.env.CACHEPLANK_URL ?? process.env.BUNNY_DATABASE_URL;\n const authToken =\n options.authToken ?? process.env.CACHEPLANK_AUTH_TOKEN ?? process.env.BUNNY_DATABASE_AUTH_TOKEN;\n const prefix = options.prefix ?? process.env.CACHEPLANK_PREFIX ?? '';\n const configuredMax = Number(process.env.CACHEPLANK_MAX_ENTRIES);\n const maxEntries =\n options.maxEntries ??\n (Number.isFinite(configuredMax) && configuredMax > 0 ? configuredMax : DEFAULT_MAX_ENTRIES);\n const configuredTtl = Number(process.env.CACHEPLANK_STAMPS_TTL_MS);\n const tagCheckTtlMs =\n options.stampsTtlMs ??\n (Number.isFinite(configuredTtl) && configuredTtl >= 0 ? configuredTtl : TAG_CHECK_TTL_MS);\n const warn = options.warn ?? ((message: string) => console.warn(`[cacheplank] ${message}`));\n\n // ---- local entry store, LRU via Map insertion order ----\n const entries = new Map<string, StoredEntry>();\n function remember(key: string, entry: StoredEntry): void {\n entries.delete(key);\n entries.set(key, entry);\n while (entries.size > maxEntries) {\n const oldest = entries.keys().next().value;\n if (oldest === undefined) break;\n entries.delete(oldest);\n }\n }\n\n // ---- shared tag stamps (in-process memo; monotonically increasing) ----\n const stamps = new Map<string, number>();\n let stampsSyncedAt = 0;\n function observeStamp(tag: string, at: number): void {\n if (at > (stamps.get(tag) ?? 0)) stamps.set(tag, at);\n }\n\n // ---- libSQL client: lazy, memoized, and never allowed to throw ----\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 warn(message);\n }\n }\n\n function ensureStore(): Promise<boolean> {\n if (ready) return ready;\n if (!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, authToken });\n await created.execute(\n 'CREATE TABLE IF NOT EXISTS tag_stamps (tag TEXT PRIMARY KEY, revalidated_at INTEGER NOT NULL)',\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 async function syncStamps(): Promise<void> {\n if (now() - stampsSyncedAt < tagCheckTtlMs) return;\n try {\n if (!(await ensureStore()) || !client) return;\n const result = await client.execute('SELECT tag, revalidated_at FROM tag_stamps');\n for (const row of result.rows) {\n const at = Number(row.revalidated_at);\n if (Number.isFinite(at)) observeStamp(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 stampsSyncedAt = now();\n }\n }\n\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 = 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 the tiered-cache eviction sentinel Next's tiered\n // handler uses in dev (the interface has no per-key delete); treat it as\n // missing, independently of the retention bounds below.\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 then takes\n // over). Dropping at either bound here matches what Next would reject\n // anyway and never serves a value the wrapper throws away. In dev the\n // default handler retains short-`expire` entries for at least\n // MIN_PRERENDERABLE_EXPIRE (300s) so reloads hit; mirror that. A\n // `revalidate <= 0` (including the stale-while-revalidate `-1`) drops.\n const age = now() - entry.timestamp;\n const maxAgeSeconds = process.env.__NEXT_DEV_SERVER\n ? Math.max(entry.expire, 300)\n : entry.revalidate;\n if (!(age < maxAgeSeconds * 1000) || (entry.expire >= 0 && !(age < entry.expire * 1000))) {\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 \u2014 miss,\n // so Next regenerates. `>=` mirrors Next's wrapper-side discard\n // (`entry.timestamp <= implicitTagsExpiration`): for an entry created in\n // the same millisecond as a stamp the ordering is unknowable, so we\n // conservatively regenerate.\n await syncStamps();\n for (const tag of entry.tags) {\n if ((stamps.get(tag) ?? 0) >= entry.timestamp) return undefined;\n }\n for (const tag of softTags) {\n if ((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 = 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 reader = entry.value.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\n remember(key, {\n bytes,\n tags: entry.tags ?? [],\n timestamp: entry.timestamp,\n expire: entry.expire,\n revalidate: entry.revalidate,\n stale: entry.stale,\n });\n } catch (error) {\n warnOnce(`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 syncStamps();\n },\n\n async getExpiration(tags) {\n await syncStamps();\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\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 const at = now();\n for (const tag of tags) observeStamp(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/**\n * Default singleton. Next loads the module via `interopDefault`, so this\n * `default` export is the handler Next uses.\n */\nexport default createCacheHandler();\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** A timestamp in milliseconds elapsed since the epoch. */
|
|
2
|
+
export type Timestamp = number;
|
|
3
|
+
/** Mirrors `CacheEntry` in next@16.3.8. Durations are in seconds. */
|
|
4
|
+
export interface CacheEntry {
|
|
5
|
+
/** The stored value. May be partially written / error while pending. */
|
|
6
|
+
value: ReadableStream<Uint8Array>;
|
|
7
|
+
/** Tags configured for the entry, excluding soft tags. */
|
|
8
|
+
tags: string[];
|
|
9
|
+
/** Client hint only; not used to compute expiration. [seconds] */
|
|
10
|
+
stale: number;
|
|
11
|
+
/** When the entry was created. [ms epoch] */
|
|
12
|
+
timestamp: Timestamp;
|
|
13
|
+
/** How long the entry may be used. [seconds] */
|
|
14
|
+
expire: number;
|
|
15
|
+
/** How long until the entry should be revalidated. [seconds] */
|
|
16
|
+
revalidate: number;
|
|
17
|
+
}
|
|
18
|
+
/** Mirrors `CacheHandler` in next@16.3.8. */
|
|
19
|
+
export interface CacheHandler {
|
|
20
|
+
get(cacheKey: string, softTags: string[]): Promise<CacheEntry | undefined>;
|
|
21
|
+
set(cacheKey: string, pendingEntry: Promise<CacheEntry>): Promise<void>;
|
|
22
|
+
refreshTags(): Promise<void>;
|
|
23
|
+
getExpiration(tags: string[]): Promise<Timestamp>;
|
|
24
|
+
updateTags(tags: string[], durations?: {
|
|
25
|
+
expire?: number;
|
|
26
|
+
}): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
export interface CacheplankOptions {
|
|
29
|
+
/** libSQL URL. Defaults to `CACHEPLANK_URL`, then `BUNNY_DATABASE_URL`. */
|
|
30
|
+
url?: string;
|
|
31
|
+
/** libSQL auth token. Defaults to `CACHEPLANK_AUTH_TOKEN`, then `BUNNY_DATABASE_AUTH_TOKEN`. */
|
|
32
|
+
authToken?: string;
|
|
33
|
+
/** Key namespace. Defaults to `CACHEPLANK_PREFIX` (may be empty). */
|
|
34
|
+
prefix?: string;
|
|
35
|
+
/** Max local entries. Defaults to `CACHEPLANK_MAX_ENTRIES`, else 2000. */
|
|
36
|
+
maxEntries?: number;
|
|
37
|
+
/**
|
|
38
|
+
* How long an in-process tag-stamp memo may be reused before the shared table
|
|
39
|
+
* is re-read. This bounds how stale a pod's view of invalidation can get.
|
|
40
|
+
* Defaults to `CACHEPLANK_STAMPS_TTL_MS`, else 3000. [ms]
|
|
41
|
+
*/
|
|
42
|
+
stampsTtlMs?: number;
|
|
43
|
+
/** Sink for the single fail-open warning. Defaults to `console.warn`. */
|
|
44
|
+
warn?: (message: string) => void;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Create a cache handler. The package's `default` export is one of these,
|
|
48
|
+
* configured from the environment.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createCacheHandler(options?: CacheplankOptions): CacheHandler;
|
|
51
|
+
/**
|
|
52
|
+
* Default singleton. Next loads the module via `interopDefault`, so this
|
|
53
|
+
* `default` export is the handler Next uses.
|
|
54
|
+
*/
|
|
55
|
+
declare const _default: CacheHandler;
|
|
56
|
+
export default _default;
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createClient } from "@libsql/client";
|
|
3
|
+
var TAG_CHECK_TTL_MS = (() => {
|
|
4
|
+
const configured = Number(process.env.CACHEPLANK_STAMPS_TTL_MS);
|
|
5
|
+
return Number.isFinite(configured) && configured >= 0 ? configured : 3e3;
|
|
6
|
+
})();
|
|
7
|
+
var RETRY_BACKOFF_MS = 3e4;
|
|
8
|
+
var DEFAULT_MAX_ENTRIES = 2e3;
|
|
9
|
+
var now = () => Date.now();
|
|
10
|
+
function createCacheHandler(options = {}) {
|
|
11
|
+
const url = options.url ?? process.env.CACHEPLANK_URL ?? process.env.BUNNY_DATABASE_URL;
|
|
12
|
+
const authToken = options.authToken ?? process.env.CACHEPLANK_AUTH_TOKEN ?? process.env.BUNNY_DATABASE_AUTH_TOKEN;
|
|
13
|
+
const prefix = options.prefix ?? process.env.CACHEPLANK_PREFIX ?? "";
|
|
14
|
+
const configuredMax = Number(process.env.CACHEPLANK_MAX_ENTRIES);
|
|
15
|
+
const maxEntries = options.maxEntries ?? (Number.isFinite(configuredMax) && configuredMax > 0 ? configuredMax : DEFAULT_MAX_ENTRIES);
|
|
16
|
+
const configuredTtl = Number(process.env.CACHEPLANK_STAMPS_TTL_MS);
|
|
17
|
+
const tagCheckTtlMs = options.stampsTtlMs ?? (Number.isFinite(configuredTtl) && configuredTtl >= 0 ? configuredTtl : TAG_CHECK_TTL_MS);
|
|
18
|
+
const warn = options.warn ?? ((message) => console.warn(`[cacheplank] ${message}`));
|
|
19
|
+
const entries = /* @__PURE__ */ new Map();
|
|
20
|
+
function remember(key, entry) {
|
|
21
|
+
entries.delete(key);
|
|
22
|
+
entries.set(key, entry);
|
|
23
|
+
while (entries.size > maxEntries) {
|
|
24
|
+
const oldest = entries.keys().next().value;
|
|
25
|
+
if (oldest === void 0) break;
|
|
26
|
+
entries.delete(oldest);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const stamps = /* @__PURE__ */ new Map();
|
|
30
|
+
let stampsSyncedAt = 0;
|
|
31
|
+
function observeStamp(tag, at) {
|
|
32
|
+
if (at > (stamps.get(tag) ?? 0)) stamps.set(tag, at);
|
|
33
|
+
}
|
|
34
|
+
let client;
|
|
35
|
+
let ready;
|
|
36
|
+
let retryAt = 0;
|
|
37
|
+
let warned = false;
|
|
38
|
+
function warnOnce(message) {
|
|
39
|
+
if (!warned) {
|
|
40
|
+
warned = true;
|
|
41
|
+
warn(message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function ensureStore() {
|
|
45
|
+
if (ready) return ready;
|
|
46
|
+
if (!url) {
|
|
47
|
+
warnOnce("no CACHEPLANK_URL / BUNNY_DATABASE_URL set; tag stamps are process-local only");
|
|
48
|
+
return Promise.resolve(false);
|
|
49
|
+
}
|
|
50
|
+
if (now() < retryAt) return Promise.resolve(false);
|
|
51
|
+
ready = (async () => {
|
|
52
|
+
try {
|
|
53
|
+
const created = createClient({ url, authToken });
|
|
54
|
+
await created.execute(
|
|
55
|
+
"CREATE TABLE IF NOT EXISTS tag_stamps (tag TEXT PRIMARY KEY, revalidated_at INTEGER NOT NULL)"
|
|
56
|
+
);
|
|
57
|
+
client = created;
|
|
58
|
+
return true;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
retryAt = now() + RETRY_BACKOFF_MS;
|
|
61
|
+
ready = void 0;
|
|
62
|
+
warnOnce(`tag-stamp store unavailable; continuing fail-open (${error.message})`);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
66
|
+
return ready;
|
|
67
|
+
}
|
|
68
|
+
async function syncStamps() {
|
|
69
|
+
if (now() - stampsSyncedAt < tagCheckTtlMs) return;
|
|
70
|
+
try {
|
|
71
|
+
if (!await ensureStore() || !client) return;
|
|
72
|
+
const result = await client.execute("SELECT tag, revalidated_at FROM tag_stamps");
|
|
73
|
+
for (const row of result.rows) {
|
|
74
|
+
const at = Number(row.revalidated_at);
|
|
75
|
+
if (Number.isFinite(at)) observeStamp(String(row.tag), at);
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
retryAt = now() + RETRY_BACKOFF_MS;
|
|
79
|
+
warnOnce(`tag-stamp read failed; serving local state (${error.message})`);
|
|
80
|
+
} finally {
|
|
81
|
+
stampsSyncedAt = now();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const pendingSets = /* @__PURE__ */ new Map();
|
|
85
|
+
return {
|
|
86
|
+
async get(cacheKey, softTags) {
|
|
87
|
+
const key = prefix + cacheKey;
|
|
88
|
+
const pending = pendingSets.get(key);
|
|
89
|
+
if (pending) await pending.catch(() => {
|
|
90
|
+
});
|
|
91
|
+
const entry = entries.get(key);
|
|
92
|
+
if (!entry) return void 0;
|
|
93
|
+
entries.delete(key);
|
|
94
|
+
entries.set(key, entry);
|
|
95
|
+
if (entry.expire < 0) {
|
|
96
|
+
entries.delete(key);
|
|
97
|
+
return void 0;
|
|
98
|
+
}
|
|
99
|
+
const age = now() - entry.timestamp;
|
|
100
|
+
const maxAgeSeconds = process.env.__NEXT_DEV_SERVER ? Math.max(entry.expire, 300) : entry.revalidate;
|
|
101
|
+
if (!(age < maxAgeSeconds * 1e3) || entry.expire >= 0 && !(age < entry.expire * 1e3)) {
|
|
102
|
+
return void 0;
|
|
103
|
+
}
|
|
104
|
+
await syncStamps();
|
|
105
|
+
for (const tag of entry.tags) {
|
|
106
|
+
if ((stamps.get(tag) ?? 0) >= entry.timestamp) return void 0;
|
|
107
|
+
}
|
|
108
|
+
for (const tag of softTags) {
|
|
109
|
+
if ((stamps.get(tag) ?? 0) >= entry.timestamp) return void 0;
|
|
110
|
+
}
|
|
111
|
+
const bytes = entry.bytes;
|
|
112
|
+
return {
|
|
113
|
+
value: new ReadableStream({
|
|
114
|
+
start(controller) {
|
|
115
|
+
controller.enqueue(bytes);
|
|
116
|
+
controller.close();
|
|
117
|
+
}
|
|
118
|
+
}),
|
|
119
|
+
tags: entry.tags,
|
|
120
|
+
stale: entry.stale,
|
|
121
|
+
timestamp: entry.timestamp,
|
|
122
|
+
expire: entry.expire,
|
|
123
|
+
revalidate: entry.revalidate
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
async set(cacheKey, pendingEntry) {
|
|
127
|
+
const key = prefix + cacheKey;
|
|
128
|
+
let release = () => {
|
|
129
|
+
};
|
|
130
|
+
const gate = new Promise((resolve) => {
|
|
131
|
+
release = resolve;
|
|
132
|
+
});
|
|
133
|
+
pendingSets.set(key, gate);
|
|
134
|
+
try {
|
|
135
|
+
const entry = await pendingEntry;
|
|
136
|
+
if (entry.expire === 0 && !process.env.__NEXT_DEV_SERVER) return;
|
|
137
|
+
const reader = entry.value.getReader();
|
|
138
|
+
const chunks = [];
|
|
139
|
+
let size = 0;
|
|
140
|
+
try {
|
|
141
|
+
for (; ; ) {
|
|
142
|
+
const { done, value } = await reader.read();
|
|
143
|
+
if (done) break;
|
|
144
|
+
if (value) {
|
|
145
|
+
chunks.push(value);
|
|
146
|
+
size += value.byteLength;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
reader.releaseLock();
|
|
151
|
+
}
|
|
152
|
+
const bytes = new Uint8Array(size);
|
|
153
|
+
let offset = 0;
|
|
154
|
+
for (const chunk of chunks) {
|
|
155
|
+
bytes.set(chunk, offset);
|
|
156
|
+
offset += chunk.byteLength;
|
|
157
|
+
}
|
|
158
|
+
remember(key, {
|
|
159
|
+
bytes,
|
|
160
|
+
tags: entry.tags ?? [],
|
|
161
|
+
timestamp: entry.timestamp,
|
|
162
|
+
expire: entry.expire,
|
|
163
|
+
revalidate: entry.revalidate,
|
|
164
|
+
stale: entry.stale
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
warnOnce(`failed to buffer a cache entry (${error.message})`);
|
|
168
|
+
} finally {
|
|
169
|
+
release();
|
|
170
|
+
pendingSets.delete(key);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
async refreshTags() {
|
|
174
|
+
await syncStamps();
|
|
175
|
+
},
|
|
176
|
+
async getExpiration(tags) {
|
|
177
|
+
await syncStamps();
|
|
178
|
+
let max = 0;
|
|
179
|
+
for (const tag of tags) {
|
|
180
|
+
const at = stamps.get(tag) ?? 0;
|
|
181
|
+
if (at > max) max = at;
|
|
182
|
+
}
|
|
183
|
+
return max;
|
|
184
|
+
},
|
|
185
|
+
async updateTags(tags, _durations) {
|
|
186
|
+
const at = now();
|
|
187
|
+
for (const tag of tags) observeStamp(tag, at);
|
|
188
|
+
if (tags.length === 0) return;
|
|
189
|
+
try {
|
|
190
|
+
if (!await ensureStore() || !client) return;
|
|
191
|
+
await client.batch(
|
|
192
|
+
tags.map((tag) => ({
|
|
193
|
+
sql: "INSERT INTO tag_stamps(tag, revalidated_at) VALUES (?, ?) ON CONFLICT(tag) DO UPDATE SET revalidated_at = MAX(revalidated_at, excluded.revalidated_at)",
|
|
194
|
+
args: [tag, at]
|
|
195
|
+
})),
|
|
196
|
+
"write"
|
|
197
|
+
);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
warnOnce(`tag-stamp write failed; local state kept (${error.message})`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
var index_default = createCacheHandler();
|
|
205
|
+
export {
|
|
206
|
+
createCacheHandler,
|
|
207
|
+
index_default as default
|
|
208
|
+
};
|
|
209
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/index.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * cacheplank \u2014 \"the shared plank your pods walk across.\"\n *\n * A single-file, tag-based distributed cache handler for Next.js 16's\n * `cacheHandlers` API, backed by 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 * Ground truth for the contract below: the installed `next@16.3.8` package \u2014\n * `dist/server/lib/cache-handlers/{types,default}.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 cache-handler contract. *\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\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 /** Sink for the single fail-open warning. Defaults to `console.warn`. */\n warn?: (message: string) => void;\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 * Soft TTL for the in-process tag-stamp memo. [ms]. Defaults to 3000; override\n * with `CACHEPLANK_STAMPS_TTL_MS` (e.g. `0` to read the shared table on every\n * `get`, which the cross-process test relies on).\n */\nconst TAG_CHECK_TTL_MS = (() => {\n const configured = Number(process.env.CACHEPLANK_STAMPS_TTL_MS);\n return Number.isFinite(configured) && configured >= 0 ? configured : 3000;\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\nconst now = (): number => Date.now();\n\n/**\n * Create a cache handler. The package's `default` export is one of these,\n * configured from the environment.\n */\nexport function createCacheHandler(options: CacheplankOptions = {}): CacheHandler {\n const url = options.url ?? process.env.CACHEPLANK_URL ?? process.env.BUNNY_DATABASE_URL;\n const authToken =\n options.authToken ?? process.env.CACHEPLANK_AUTH_TOKEN ?? process.env.BUNNY_DATABASE_AUTH_TOKEN;\n const prefix = options.prefix ?? process.env.CACHEPLANK_PREFIX ?? '';\n const configuredMax = Number(process.env.CACHEPLANK_MAX_ENTRIES);\n const maxEntries =\n options.maxEntries ??\n (Number.isFinite(configuredMax) && configuredMax > 0 ? configuredMax : DEFAULT_MAX_ENTRIES);\n const configuredTtl = Number(process.env.CACHEPLANK_STAMPS_TTL_MS);\n const tagCheckTtlMs =\n options.stampsTtlMs ??\n (Number.isFinite(configuredTtl) && configuredTtl >= 0 ? configuredTtl : TAG_CHECK_TTL_MS);\n const warn = options.warn ?? ((message: string) => console.warn(`[cacheplank] ${message}`));\n\n // ---- local entry store, LRU via Map insertion order ----\n const entries = new Map<string, StoredEntry>();\n function remember(key: string, entry: StoredEntry): void {\n entries.delete(key);\n entries.set(key, entry);\n while (entries.size > maxEntries) {\n const oldest = entries.keys().next().value;\n if (oldest === undefined) break;\n entries.delete(oldest);\n }\n }\n\n // ---- shared tag stamps (in-process memo; monotonically increasing) ----\n const stamps = new Map<string, number>();\n let stampsSyncedAt = 0;\n function observeStamp(tag: string, at: number): void {\n if (at > (stamps.get(tag) ?? 0)) stamps.set(tag, at);\n }\n\n // ---- libSQL client: lazy, memoized, and never allowed to throw ----\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 warn(message);\n }\n }\n\n function ensureStore(): Promise<boolean> {\n if (ready) return ready;\n if (!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, authToken });\n await created.execute(\n 'CREATE TABLE IF NOT EXISTS tag_stamps (tag TEXT PRIMARY KEY, revalidated_at INTEGER NOT NULL)',\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 async function syncStamps(): Promise<void> {\n if (now() - stampsSyncedAt < tagCheckTtlMs) return;\n try {\n if (!(await ensureStore()) || !client) return;\n const result = await client.execute('SELECT tag, revalidated_at FROM tag_stamps');\n for (const row of result.rows) {\n const at = Number(row.revalidated_at);\n if (Number.isFinite(at)) observeStamp(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 stampsSyncedAt = now();\n }\n }\n\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 = 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 the tiered-cache eviction sentinel Next's tiered\n // handler uses in dev (the interface has no per-key delete); treat it as\n // missing, independently of the retention bounds below.\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 then takes\n // over). Dropping at either bound here matches what Next would reject\n // anyway and never serves a value the wrapper throws away. In dev the\n // default handler retains short-`expire` entries for at least\n // MIN_PRERENDERABLE_EXPIRE (300s) so reloads hit; mirror that. A\n // `revalidate <= 0` (including the stale-while-revalidate `-1`) drops.\n const age = now() - entry.timestamp;\n const maxAgeSeconds = process.env.__NEXT_DEV_SERVER\n ? Math.max(entry.expire, 300)\n : entry.revalidate;\n if (!(age < maxAgeSeconds * 1000) || (entry.expire >= 0 && !(age < entry.expire * 1000))) {\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 \u2014 miss,\n // so Next regenerates. `>=` mirrors Next's wrapper-side discard\n // (`entry.timestamp <= implicitTagsExpiration`): for an entry created in\n // the same millisecond as a stamp the ordering is unknowable, so we\n // conservatively regenerate.\n await syncStamps();\n for (const tag of entry.tags) {\n if ((stamps.get(tag) ?? 0) >= entry.timestamp) return undefined;\n }\n for (const tag of softTags) {\n if ((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 = 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 reader = entry.value.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\n remember(key, {\n bytes,\n tags: entry.tags ?? [],\n timestamp: entry.timestamp,\n expire: entry.expire,\n revalidate: entry.revalidate,\n stale: entry.stale,\n });\n } catch (error) {\n warnOnce(`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 syncStamps();\n },\n\n async getExpiration(tags) {\n await syncStamps();\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\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 const at = now();\n for (const tag of tags) observeStamp(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/**\n * Default singleton. Next loads the module via `interopDefault`, so this\n * `default` export is the handler Next uses.\n */\nexport default createCacheHandler();\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cacheplank",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "The shared plank your pods walk across: a single-file Next.js 16 cache handler where entries stay local and only tag invalidation is shared, via one libSQL table.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.mjs",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.mjs",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "node build.mjs && tsc -p tsconfig.json",
|
|
27
|
+
"test": "node --test test/*.test.mjs",
|
|
28
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
29
|
+
"prepublishOnly": "npm run build && npm test && npm run typecheck"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@libsql/client": "^0.18.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"next": ">=16.0.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"next": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^22.0.0",
|
|
44
|
+
"esbuild": "^0.25.0",
|
|
45
|
+
"next": "16.3.8",
|
|
46
|
+
"typescript": "^5.6.0"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"next",
|
|
50
|
+
"nextjs",
|
|
51
|
+
"next.js",
|
|
52
|
+
"cache-handler",
|
|
53
|
+
"cacheComponents",
|
|
54
|
+
"use-cache",
|
|
55
|
+
"isr",
|
|
56
|
+
"revalidateTag",
|
|
57
|
+
"libsql",
|
|
58
|
+
"turso",
|
|
59
|
+
"bunny"
|
|
60
|
+
],
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
},
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "git+https://github.com/dmytri/cacheplank.git"
|
|
67
|
+
}
|
|
68
|
+
}
|