@trieb.work/nextjs-turbo-redis-cache 1.17.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +8 -157
- package/ARCHITECTURE.md +86 -47
- package/README.md +87 -24
- package/dist/index.d.mts +40 -1
- package/dist/index.d.ts +40 -1
- package/dist/index.js +264 -96
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +261 -95
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -6
- package/release.config.cjs +6 -25
- package/src/CacheComponentsHandler.ts +141 -111
- package/src/RedisStringsHandler.ts +37 -25
- package/src/SyncedMap.ts +16 -2
- package/src/index.ts +7 -0
- package/src/utils/cacheTtl.ts +71 -0
- package/src/utils/compareAndUnlink.ts +29 -0
- package/src/utils/redisConnection.ts +17 -0
- package/src/utils/tagRevalidation.ts +162 -0
- package/test/README.md +10 -10
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/next.config.ts +2 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/route.ts +23 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/page.tsx +22 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/next.config.ts +2 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/route.ts +23 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/page.tsx +22 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/next.config.ts +2 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/route.ts +23 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/page.tsx +22 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
- package/test/playwright/cache-lab.spec.ts +75 -0
- package/test/vitest/integration/cache-components/cache-components.integration.test.ts +194 -36
- package/test/vitest/integration/nextjs-cache-handler.integration.test.ts +16 -2
- package/test/vitest/integration/pages-router.integration.test.ts +16 -6
- package/test/vitest/unit/SyncedMap-orphan-cleanup.test.ts +111 -0
- package/test/vitest/unit/cache-components-update-tags.test.ts +350 -0
- package/test/vitest/unit/cache-ttl.test.ts +67 -0
- package/test/vitest/unit/compare-and-unlink.test.ts +22 -0
- package/test/vitest/unit/index.test.ts +17 -0
- package/test/vitest/unit/pages-router-kinds.test.ts +11 -0
- package/test/vitest/unit/redis-connection.test.ts +39 -0
- package/test/vitest/unit/tag-revalidation.test.ts +178 -0
- package/CHANGELOG.md +0 -411
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type ResolveCacheEntryTtlContext = {
|
|
2
|
+
revalidate?: number | false;
|
|
3
|
+
cacheControl?: { revalidate?: number | false; expire?: number | undefined };
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type ResolveCacheEntryTtlData = {
|
|
7
|
+
kind?: string;
|
|
8
|
+
revalidate?: number | false;
|
|
9
|
+
} | null;
|
|
10
|
+
|
|
11
|
+
export type ResolveCacheEntryTtlOptions = {
|
|
12
|
+
estimateExpireAge: (staleAge: number) => number;
|
|
13
|
+
defaultStaleAge: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolves the Redis TTL (seconds) for an ISR / incremental-cache entry.
|
|
18
|
+
*
|
|
19
|
+
* Official Next.js semantics (cache-handler-redis example + self-hosting docs):
|
|
20
|
+
* - Key TTL on `cacheControl.expire`, never on `revalidate` alone.
|
|
21
|
+
* - Past `revalidate` an entry is only stale (SWR); evicting at that boundary
|
|
22
|
+
* would defeat background refresh.
|
|
23
|
+
* - When no finite `expire` is provided, fall back to `estimateExpireAge(revalidate)`
|
|
24
|
+
* for legacy Pages Router / pre-cacheLife callers that only pass `revalidate`.
|
|
25
|
+
* - When neither `expire` nor `revalidate` is available, return `undefined` (no TTL;
|
|
26
|
+
* rely on tag-based invalidation). This is intentional — not a missing
|
|
27
|
+
* `defaultStaleAge` fallback. `revalidate: false` is a separate branch above
|
|
28
|
+
* and is covered by unit + Pages Router integration tests (`/static-forever`).
|
|
29
|
+
*/
|
|
30
|
+
export function resolveCacheEntryTtlSeconds(
|
|
31
|
+
ctx: ResolveCacheEntryTtlContext,
|
|
32
|
+
data: ResolveCacheEntryTtlData,
|
|
33
|
+
options: ResolveCacheEntryTtlOptions,
|
|
34
|
+
): number | undefined {
|
|
35
|
+
const expire = ctx.cacheControl?.expire;
|
|
36
|
+
|
|
37
|
+
if (Number.isFinite(expire) && expire! > 0) {
|
|
38
|
+
return Math.max(1, Math.ceil(expire!));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const revalidate = resolveRevalidateValue(ctx, data);
|
|
42
|
+
|
|
43
|
+
if (revalidate === false) {
|
|
44
|
+
return options.estimateExpireAge(options.defaultStaleAge);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (revalidate && Number.isSafeInteger(revalidate) && revalidate > 0) {
|
|
48
|
+
return options.estimateExpireAge(revalidate);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveRevalidateValue(
|
|
55
|
+
ctx: ResolveCacheEntryTtlContext,
|
|
56
|
+
data: ResolveCacheEntryTtlData,
|
|
57
|
+
): number | false | undefined {
|
|
58
|
+
if (data?.kind === 'FETCH' && data.revalidate !== undefined) {
|
|
59
|
+
return data.revalidate;
|
|
60
|
+
}
|
|
61
|
+
if (ctx.revalidate !== undefined) {
|
|
62
|
+
return ctx.revalidate;
|
|
63
|
+
}
|
|
64
|
+
if (ctx.cacheControl?.revalidate !== undefined) {
|
|
65
|
+
return ctx.cacheControl.revalidate;
|
|
66
|
+
}
|
|
67
|
+
if (data?.revalidate !== undefined) {
|
|
68
|
+
return data.revalidate;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomically delete `key` only if its current value still equals `expected`.
|
|
3
|
+
* GET then UNLINK would race: another instance can SET a replacement in between.
|
|
4
|
+
*/
|
|
5
|
+
const COMPARE_AND_UNLINK = `
|
|
6
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
7
|
+
return redis.call('UNLINK', KEYS[1])
|
|
8
|
+
end
|
|
9
|
+
return 0
|
|
10
|
+
`;
|
|
11
|
+
|
|
12
|
+
type EvalClient = {
|
|
13
|
+
eval(
|
|
14
|
+
script: string,
|
|
15
|
+
options: { keys: string[]; arguments: string[] },
|
|
16
|
+
): Promise<unknown>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export async function compareAndUnlink(
|
|
20
|
+
client: EvalClient,
|
|
21
|
+
key: string,
|
|
22
|
+
expected: string,
|
|
23
|
+
): Promise<boolean> {
|
|
24
|
+
const deleted = await client.eval(COMPARE_AND_UNLINK, {
|
|
25
|
+
keys: [key],
|
|
26
|
+
arguments: [expected],
|
|
27
|
+
});
|
|
28
|
+
return deleted === 1 || deleted === 1n;
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matches `PHASE_PRODUCTION_BUILD` from `next/constants`.
|
|
3
|
+
*
|
|
4
|
+
* Hardcoded so ESM builds (tsup `shims: false`) never call `require()`.
|
|
5
|
+
* A dynamic `require('next/constants')` throws in ESM, and catching that used
|
|
6
|
+
* to cache `undefined` for the process lifetime — disabling the build-phase
|
|
7
|
+
* Redis skip entirely.
|
|
8
|
+
*/
|
|
9
|
+
export const PHASE_PRODUCTION_BUILD = 'phase-production-build';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns true during `next build` so handlers can skip opening Redis
|
|
13
|
+
* connections. Matches the official Next.js cache-handler-redis example.
|
|
14
|
+
*/
|
|
15
|
+
export function shouldDeferRedisConnection(): boolean {
|
|
16
|
+
return process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD;
|
|
17
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tag manifest matching Next.js 16.0–16.3
|
|
3
|
+
* `next/dist/server/lib/cache-handlers/default.js` `updateTags`
|
|
4
|
+
* and `next/dist/server/lib/incremental-cache/tags-manifest.external.js`.
|
|
5
|
+
*
|
|
6
|
+
* `revalidateTag(tag, profile)` resolves `cacheLife[profile].expire` (seconds)
|
|
7
|
+
* and calls `updateTags(tags, { expire })`. `updateTag` / no profile calls
|
|
8
|
+
* `updateTags(tags)` with no durations (immediate hard expire).
|
|
9
|
+
*/
|
|
10
|
+
export type TagManifestEntry = {
|
|
11
|
+
stale?: number;
|
|
12
|
+
expired?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read a tag-manifest field from Redis.
|
|
17
|
+
*
|
|
18
|
+
* Next.js 16.0–16.3 never persists plain numbers here — `updateTags` always
|
|
19
|
+
* writes `{ stale, expired }` via `applyTagUpdate()`. A plain number can only
|
|
20
|
+
* appear from older releases of this package that stored `Date.now()` directly
|
|
21
|
+
* (before the `{ stale, expired }` manifest). ISR uses a separate Redis hash
|
|
22
|
+
* (`__revalidated_tags__`) and is unaffected.
|
|
23
|
+
*
|
|
24
|
+
* A future-looking plain number is not produced by Next.js and is not the same
|
|
25
|
+
* as intentional SWR (`{ stale: now, expired: now + N }`). It can only happen
|
|
26
|
+
* when another instance wrote the timestamp with a faster clock (NTP skew).
|
|
27
|
+
* Clamp to `now` on read so the revalidation is not delayed on slower instances.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeTagManifest(
|
|
30
|
+
stored: unknown,
|
|
31
|
+
now: number = Date.now(),
|
|
32
|
+
): TagManifestEntry {
|
|
33
|
+
if (typeof stored === 'number' && Number.isFinite(stored)) {
|
|
34
|
+
return { expired: Math.min(stored, now) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (stored && typeof stored === 'object') {
|
|
38
|
+
const rec = stored as {
|
|
39
|
+
stale?: unknown;
|
|
40
|
+
expired?: unknown;
|
|
41
|
+
last?: unknown;
|
|
42
|
+
pending?: unknown;
|
|
43
|
+
};
|
|
44
|
+
const stale =
|
|
45
|
+
typeof rec.stale === 'number' && Number.isFinite(rec.stale)
|
|
46
|
+
? rec.stale
|
|
47
|
+
: typeof rec.last === 'number' && Number.isFinite(rec.last)
|
|
48
|
+
? rec.last
|
|
49
|
+
: undefined;
|
|
50
|
+
const expired =
|
|
51
|
+
typeof rec.expired === 'number' && Number.isFinite(rec.expired)
|
|
52
|
+
? rec.expired
|
|
53
|
+
: typeof rec.pending === 'number' && Number.isFinite(rec.pending)
|
|
54
|
+
? rec.pending
|
|
55
|
+
: undefined;
|
|
56
|
+
return {
|
|
57
|
+
...(stale !== undefined ? { stale } : {}),
|
|
58
|
+
...(expired !== undefined ? { expired } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Mirrors Next.js DefaultCacheHandler.updateTags().
|
|
67
|
+
*/
|
|
68
|
+
export function applyTagUpdate(
|
|
69
|
+
existing: TagManifestEntry,
|
|
70
|
+
durations: { expire?: number } | undefined,
|
|
71
|
+
now: number,
|
|
72
|
+
): TagManifestEntry {
|
|
73
|
+
if (durations) {
|
|
74
|
+
const updates = { ...existing };
|
|
75
|
+
updates.stale = now;
|
|
76
|
+
if (durations.expire !== undefined) {
|
|
77
|
+
updates.expired = now + durations.expire * 1000;
|
|
78
|
+
}
|
|
79
|
+
return updates;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
...existing,
|
|
84
|
+
expired: now,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Immediate hard-expires (`expired <= now`) are stored as a plain number so
|
|
90
|
+
* older package versions that JSON.parse + numeric-compare the hash field still
|
|
91
|
+
* see the invalidation during a rolling deploy. SWR windows stay objects.
|
|
92
|
+
*/
|
|
93
|
+
export function persistableTagManifest(
|
|
94
|
+
entry: TagManifestEntry,
|
|
95
|
+
now: number,
|
|
96
|
+
): TagManifestEntry | number {
|
|
97
|
+
if (
|
|
98
|
+
typeof entry.expired === 'number' &&
|
|
99
|
+
entry.expired <= now &&
|
|
100
|
+
(entry.stale === undefined || entry.stale <= entry.expired)
|
|
101
|
+
) {
|
|
102
|
+
return entry.expired;
|
|
103
|
+
}
|
|
104
|
+
return entry;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Mirrors Next.js `areTagsExpired`: hard miss once `expired` has elapsed
|
|
109
|
+
* and is newer than the entry timestamp.
|
|
110
|
+
*/
|
|
111
|
+
export function areTagsExpired(
|
|
112
|
+
tags: string[],
|
|
113
|
+
entryTimestamp: number,
|
|
114
|
+
now: number,
|
|
115
|
+
getEntry: (tag: string) => TagManifestEntry | undefined,
|
|
116
|
+
): boolean {
|
|
117
|
+
for (const tag of tags) {
|
|
118
|
+
const expiredAt = getEntry(tag)?.expired;
|
|
119
|
+
if (
|
|
120
|
+
typeof expiredAt === 'number' &&
|
|
121
|
+
expiredAt <= now &&
|
|
122
|
+
expiredAt > entryTimestamp
|
|
123
|
+
) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Mirrors Next.js `areTagsStale`: SWR when `stale` is newer than the entry.
|
|
132
|
+
*/
|
|
133
|
+
export function areTagsStale(
|
|
134
|
+
tags: string[],
|
|
135
|
+
entryTimestamp: number,
|
|
136
|
+
getEntry: (tag: string) => TagManifestEntry | undefined,
|
|
137
|
+
): boolean {
|
|
138
|
+
for (const tag of tags) {
|
|
139
|
+
const staleAt = getEntry(tag)?.stale ?? 0;
|
|
140
|
+
if (typeof staleAt === 'number' && staleAt > entryTimestamp) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Mirrors Next.js DefaultCacheHandler.getExpiration(): max `expired` or 0.
|
|
149
|
+
*/
|
|
150
|
+
export function maxExpiredTimestamp(
|
|
151
|
+
tags: string[],
|
|
152
|
+
getEntry: (tag: string) => TagManifestEntry | undefined,
|
|
153
|
+
): number {
|
|
154
|
+
let max = 0;
|
|
155
|
+
for (const tag of tags) {
|
|
156
|
+
const expired = getEntry(tag)?.expired || 0;
|
|
157
|
+
if (expired > max) {
|
|
158
|
+
max = expired;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return max;
|
|
162
|
+
}
|
package/test/README.md
CHANGED
|
@@ -83,12 +83,12 @@ pnpm test:integration:build-id-prefix
|
|
|
83
83
|
|
|
84
84
|
Integration tests specific to Next.js 16 Cache Components (`use cache`). CI runs them against the 16.0, 16.2, and 16.3 Cache Components test apps.
|
|
85
85
|
|
|
86
|
-
| File | What it tests
|
|
87
|
-
| -------------------------------------- |
|
|
88
|
-
| `cache-components.integration.test.ts` | `use cache` lifecycle: store, retrieve, tag invalidation, `cacheLife` expiry
|
|
89
|
-
| `redis-kill-reconnect.test.ts` | Graceful recovery when Redis drops and reconnects (main client ping only)
|
|
90
|
-
| `redis-subscriber-outage.test.ts` | Issue #86: subscriber PubSub does not recover after Redis outage (expected to fail until bug is fixed)
|
|
91
|
-
| `redis-quit-vs-disconnect.test.ts` | `quit()` does not reliably close a subscriber during an outage; `disconnect()` does
|
|
86
|
+
| File | What it tests |
|
|
87
|
+
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
88
|
+
| `cache-components.integration.test.ts` | `use cache` lifecycle: store, retrieve, tag invalidation, `cacheLife` expiry, and every `revalidateTag` expire setting (`max`/`default`/`seconds`/`minutes`/`hours`/`days`/`weeks`, `{ expire: N }`, `{ expire: 0 }`, omitted profile) against `next start` + Redis |
|
|
89
|
+
| `redis-kill-reconnect.test.ts` | Graceful recovery when Redis drops and reconnects (main client ping only) |
|
|
90
|
+
| `redis-subscriber-outage.test.ts` | Issue #86: subscriber PubSub does not recover after Redis outage (expected to fail until bug is fixed) |
|
|
91
|
+
| `redis-quit-vs-disconnect.test.ts` | `quit()` does not reliably close a subscriber during an outage; `disconnect()` does |
|
|
92
92
|
|
|
93
93
|
`redis-kill-reconnect.test.ts` uses a container runtime and auto-detects `podman` first, then `docker`. You can force runtime selection with `CONTAINER_RUNTIME=podman` or `CONTAINER_RUNTIME=docker`.
|
|
94
94
|
|
|
@@ -121,10 +121,10 @@ Browser-based tests that validate Cache Components behavior from the user's pers
|
|
|
121
121
|
|
|
122
122
|
The Vitest cache-components integration tests verify the server-side plumbing (Redis state, HTTP responses). Playwright closes the gap by testing the full user-facing flow.
|
|
123
123
|
|
|
124
|
-
| File | What it tests
|
|
125
|
-
| -------------------- |
|
|
126
|
-
| `cache-lab.spec.ts` | `use cache` stability, tag invalidation (`updateTag`/`revalidateTag`), runtime cookie-based cache keys, SWR behavior |
|
|
127
|
-
| `update-tag.spec.ts` | `updateTag` via Server Actions (button click → action → UI update)
|
|
124
|
+
| File | What it tests |
|
|
125
|
+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
126
|
+
| `cache-lab.spec.ts` | `use cache` / `use cache: remote` stability, tag invalidation (`updateTag`/`revalidateTag`), `revalidateTag` durations (SWR stale window), runtime cookie-based cache keys, SWR behavior |
|
|
127
|
+
| `update-tag.spec.ts` | `updateTag` via Server Actions (button click → action → UI update) |
|
|
128
128
|
|
|
129
129
|
```bash
|
|
130
130
|
pnpm test:e2e
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { revalidateTag } from 'next/cache';
|
|
3
|
+
|
|
4
|
+
type Profile =
|
|
5
|
+
| 'max'
|
|
6
|
+
| 'default'
|
|
7
|
+
| 'seconds'
|
|
8
|
+
| 'minutes'
|
|
9
|
+
| 'hours'
|
|
10
|
+
| 'days'
|
|
11
|
+
| 'weeks'
|
|
12
|
+
| { expire: number };
|
|
13
|
+
|
|
14
|
+
export async function POST(request: Request) {
|
|
15
|
+
const body = (await request.json()) as {
|
|
16
|
+
tag?: string;
|
|
17
|
+
profile?: Profile | null;
|
|
18
|
+
};
|
|
19
|
+
const tag = body.tag || 'expire-matrix-default';
|
|
20
|
+
|
|
21
|
+
if (body.profile === undefined || body.profile === null) {
|
|
22
|
+
// Deprecated single-arg form: Next.js calls updateTags(tags) with no durations.
|
|
23
|
+
(revalidateTag as (tag: string) => void)(tag);
|
|
24
|
+
} else {
|
|
25
|
+
revalidateTag(tag, body.profile);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return NextResponse.json({
|
|
29
|
+
revalidated: true,
|
|
30
|
+
tag,
|
|
31
|
+
profile: body.profile ?? null,
|
|
32
|
+
timestamp: Date.now(),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { cacheLife, cacheTag } from 'next/cache';
|
|
3
|
+
|
|
4
|
+
let counter = 0;
|
|
5
|
+
|
|
6
|
+
export async function GET(request: Request) {
|
|
7
|
+
const id = new URL(request.url).searchParams.get('id') || 'default';
|
|
8
|
+
const data = await getExpireMatrixData(id);
|
|
9
|
+
return NextResponse.json({ ...data, id });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function getExpireMatrixData(id: string) {
|
|
13
|
+
'use cache';
|
|
14
|
+
// Long time-based lifetime so only tag invalidation can change the value.
|
|
15
|
+
cacheLife({ stale: 300, revalidate: 3600, expire: 86_400 });
|
|
16
|
+
cacheTag(`expire-matrix-${id}`);
|
|
17
|
+
|
|
18
|
+
counter += 1;
|
|
19
|
+
return {
|
|
20
|
+
counter,
|
|
21
|
+
timestamp: Date.now(),
|
|
22
|
+
};
|
|
23
|
+
}
|
package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/page.tsx
CHANGED
|
@@ -70,6 +70,28 @@ export default function CacheLabIndexPage() {
|
|
|
70
70
|
it into a cached component to show correct boundaries.
|
|
71
71
|
</p>
|
|
72
72
|
</Link>
|
|
73
|
+
|
|
74
|
+
<Link
|
|
75
|
+
className="rounded-lg border p-5 hover:bg-slate-50"
|
|
76
|
+
href="/cache-lab/use-cache-remote"
|
|
77
|
+
>
|
|
78
|
+
<h2 className="text-lg font-medium">use cache: remote</h2>
|
|
79
|
+
<p className="mt-1 text-sm text-slate-600">
|
|
80
|
+
Exercises <code>cacheHandlers.remote</code> with Redis-backed
|
|
81
|
+
streams and <code>updateTag</code>.
|
|
82
|
+
</p>
|
|
83
|
+
</Link>
|
|
84
|
+
|
|
85
|
+
<Link
|
|
86
|
+
className="rounded-lg border p-5 hover:bg-slate-50"
|
|
87
|
+
href="/cache-lab/revalidate-durations"
|
|
88
|
+
>
|
|
89
|
+
<h2 className="text-lg font-medium">revalidateTag durations</h2>
|
|
90
|
+
<p className="mt-1 text-sm text-slate-600">
|
|
91
|
+
Deferred tag invalidation via{' '}
|
|
92
|
+
<code>revalidateTag(tag, {'{ expire }'})</code> and SWR behavior.
|
|
93
|
+
</p>
|
|
94
|
+
</Link>
|
|
73
95
|
</section>
|
|
74
96
|
|
|
75
97
|
<section className="mt-12 rounded-lg border bg-slate-50 p-5">
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { Suspense } from 'react';
|
|
3
|
+
import { cacheLife, cacheTag, revalidateTag } from 'next/cache';
|
|
4
|
+
|
|
5
|
+
function sleep(ms: number): Promise<void> {
|
|
6
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function getSlowTaggedValue() {
|
|
10
|
+
'use cache';
|
|
11
|
+
|
|
12
|
+
cacheLife({ stale: 60, revalidate: 120, expire: 300 });
|
|
13
|
+
cacheTag('cache-lab:durations');
|
|
14
|
+
|
|
15
|
+
await sleep(2500);
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
computedAt: Date.now(),
|
|
19
|
+
value: Math.random(),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function triggerDeferredRevalidateTag() {
|
|
24
|
+
'use server';
|
|
25
|
+
revalidateTag('cache-lab:durations', { expire: 2 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function CachedPanel() {
|
|
29
|
+
const data = await getSlowTaggedValue();
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="rounded-lg border p-5">
|
|
33
|
+
<div className="text-sm text-slate-700">
|
|
34
|
+
<div>
|
|
35
|
+
<span className="font-medium">computedAt:</span>{' '}
|
|
36
|
+
<span data-testid="computedAt" className="font-mono">
|
|
37
|
+
{data.computedAt}
|
|
38
|
+
</span>
|
|
39
|
+
</div>
|
|
40
|
+
<div className="mt-1">
|
|
41
|
+
<span className="font-medium">value:</span>{' '}
|
|
42
|
+
<span data-testid="value" className="font-mono">
|
|
43
|
+
{data.value}
|
|
44
|
+
</span>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default function RevalidateDurationsPage() {
|
|
52
|
+
return (
|
|
53
|
+
<main className="mx-auto max-w-3xl p-10">
|
|
54
|
+
<div className="mb-6">
|
|
55
|
+
<Link
|
|
56
|
+
className="text-sm text-blue-600 hover:underline"
|
|
57
|
+
href="/cache-lab"
|
|
58
|
+
>
|
|
59
|
+
← Back to Cache Lab
|
|
60
|
+
</Link>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<header className="mb-6">
|
|
64
|
+
<h1 className="text-2xl font-semibold">
|
|
65
|
+
revalidateTag durations (SWR stale window)
|
|
66
|
+
</h1>
|
|
67
|
+
<p className="mt-2 text-sm text-slate-600">
|
|
68
|
+
Calls <code>revalidateTag(tag, {'{ expire: 2 }'})</code>. The handler
|
|
69
|
+
should mark the tag stale immediately while keeping the Redis entry so
|
|
70
|
+
the next request can serve stale content (SWR) during refresh.
|
|
71
|
+
</p>
|
|
72
|
+
</header>
|
|
73
|
+
|
|
74
|
+
<Suspense
|
|
75
|
+
fallback={
|
|
76
|
+
<div className="rounded-lg border bg-slate-50 p-5 text-sm text-slate-700">
|
|
77
|
+
Loading cached content… (first load can take ~2.5s)
|
|
78
|
+
</div>
|
|
79
|
+
}
|
|
80
|
+
>
|
|
81
|
+
<CachedPanel />
|
|
82
|
+
</Suspense>
|
|
83
|
+
|
|
84
|
+
<form action={triggerDeferredRevalidateTag} className="mt-6">
|
|
85
|
+
<button
|
|
86
|
+
className="rounded-md border border-blue-600 bg-white px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50"
|
|
87
|
+
type="submit"
|
|
88
|
+
>
|
|
89
|
+
revalidateTag('cache-lab:durations', {'{ expire: 2 }'})
|
|
90
|
+
</button>
|
|
91
|
+
</form>
|
|
92
|
+
|
|
93
|
+
<div className="mt-6 rounded-lg border bg-slate-50 p-5 text-sm text-slate-700">
|
|
94
|
+
<p className="font-medium">How to observe SWR invalidation</p>
|
|
95
|
+
<ol className="mt-2 list-decimal space-y-1 pl-5">
|
|
96
|
+
<li>Load once (expect ~2.5s).</li>
|
|
97
|
+
<li>Click the button, then reload immediately.</li>
|
|
98
|
+
<li>
|
|
99
|
+
The reload should still show the old values (fast SWR). After
|
|
100
|
+
background refresh (~2.5s), values should change.
|
|
101
|
+
</li>
|
|
102
|
+
</ol>
|
|
103
|
+
</div>
|
|
104
|
+
</main>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { Suspense } from 'react';
|
|
3
|
+
import { cacheTag, updateTag } from 'next/cache';
|
|
4
|
+
|
|
5
|
+
async function getRemoteCachedValue() {
|
|
6
|
+
'use cache: remote';
|
|
7
|
+
|
|
8
|
+
cacheTag('cache-lab:remote');
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
value: Math.random(),
|
|
12
|
+
computedAt: Date.now(),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function triggerUpdateTag() {
|
|
17
|
+
'use server';
|
|
18
|
+
updateTag('cache-lab:remote');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function RemotePanel() {
|
|
22
|
+
const data = await getRemoteCachedValue();
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="rounded-lg border p-5">
|
|
26
|
+
<div className="text-sm text-slate-700">
|
|
27
|
+
<div>
|
|
28
|
+
<span className="font-medium">computedAt:</span>{' '}
|
|
29
|
+
<span data-testid="computedAt" className="font-mono">
|
|
30
|
+
{data.computedAt}
|
|
31
|
+
</span>
|
|
32
|
+
</div>
|
|
33
|
+
<div className="mt-1">
|
|
34
|
+
<span className="font-medium">value:</span>{' '}
|
|
35
|
+
<span data-testid="value" className="font-mono">
|
|
36
|
+
{data.value}
|
|
37
|
+
</span>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default function UseCacheRemotePage() {
|
|
45
|
+
return (
|
|
46
|
+
<main className="mx-auto max-w-3xl p-10">
|
|
47
|
+
<div className="mb-6">
|
|
48
|
+
<Link
|
|
49
|
+
className="text-sm text-blue-600 hover:underline"
|
|
50
|
+
href="/cache-lab"
|
|
51
|
+
>
|
|
52
|
+
← Back to Cache Lab
|
|
53
|
+
</Link>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<header className="mb-6">
|
|
57
|
+
<h1 className="text-2xl font-semibold">use cache: remote</h1>
|
|
58
|
+
<p className="mt-2 text-sm text-slate-600">
|
|
59
|
+
Exercises the <code>cacheHandlers.remote</code> handler backed by
|
|
60
|
+
Redis. Values should stay stable across reloads until{' '}
|
|
61
|
+
<code>updateTag</code> runs.
|
|
62
|
+
</p>
|
|
63
|
+
</header>
|
|
64
|
+
|
|
65
|
+
<Suspense
|
|
66
|
+
fallback={
|
|
67
|
+
<div className="rounded-lg border bg-slate-50 p-5 text-sm text-slate-700">
|
|
68
|
+
Loading remote cached content…
|
|
69
|
+
</div>
|
|
70
|
+
}
|
|
71
|
+
>
|
|
72
|
+
<RemotePanel />
|
|
73
|
+
</Suspense>
|
|
74
|
+
|
|
75
|
+
<form action={triggerUpdateTag} className="mt-6">
|
|
76
|
+
<button
|
|
77
|
+
className="rounded-md border border-blue-600 bg-white px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50"
|
|
78
|
+
type="submit"
|
|
79
|
+
>
|
|
80
|
+
updateTag('cache-lab:remote')
|
|
81
|
+
</button>
|
|
82
|
+
</form>
|
|
83
|
+
</main>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { revalidateTag } from 'next/cache';
|
|
3
|
+
|
|
4
|
+
type Profile =
|
|
5
|
+
| 'max'
|
|
6
|
+
| 'default'
|
|
7
|
+
| 'seconds'
|
|
8
|
+
| 'minutes'
|
|
9
|
+
| 'hours'
|
|
10
|
+
| 'days'
|
|
11
|
+
| 'weeks'
|
|
12
|
+
| { expire: number };
|
|
13
|
+
|
|
14
|
+
export async function POST(request: Request) {
|
|
15
|
+
const body = (await request.json()) as {
|
|
16
|
+
tag?: string;
|
|
17
|
+
profile?: Profile | null;
|
|
18
|
+
};
|
|
19
|
+
const tag = body.tag || 'expire-matrix-default';
|
|
20
|
+
|
|
21
|
+
if (body.profile === undefined || body.profile === null) {
|
|
22
|
+
// Deprecated single-arg form: Next.js calls updateTags(tags) with no durations.
|
|
23
|
+
(revalidateTag as (tag: string) => void)(tag);
|
|
24
|
+
} else {
|
|
25
|
+
revalidateTag(tag, body.profile);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return NextResponse.json({
|
|
29
|
+
revalidated: true,
|
|
30
|
+
tag,
|
|
31
|
+
profile: body.profile ?? null,
|
|
32
|
+
timestamp: Date.now(),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { cacheLife, cacheTag } from 'next/cache';
|
|
3
|
+
|
|
4
|
+
let counter = 0;
|
|
5
|
+
|
|
6
|
+
export async function GET(request: Request) {
|
|
7
|
+
const id = new URL(request.url).searchParams.get('id') || 'default';
|
|
8
|
+
const data = await getExpireMatrixData(id);
|
|
9
|
+
return NextResponse.json({ ...data, id });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function getExpireMatrixData(id: string) {
|
|
13
|
+
'use cache';
|
|
14
|
+
// Long time-based lifetime so only tag invalidation can change the value.
|
|
15
|
+
cacheLife({ stale: 300, revalidate: 3600, expire: 86_400 });
|
|
16
|
+
cacheTag(`expire-matrix-${id}`);
|
|
17
|
+
|
|
18
|
+
counter += 1;
|
|
19
|
+
return {
|
|
20
|
+
counter,
|
|
21
|
+
timestamp: Date.now(),
|
|
22
|
+
};
|
|
23
|
+
}
|