From 1f0dbab4d027ae392f7f15f5a78225eb635d8a83 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 8 Oct 2026 16:06:28 -0600 Subject: [PATCH 1/6] feat: isolate persistent filesystem caches by build Pin runtime cache writes outside the replaceable component tree and retain read-only build seed fallback. Capture each app/build binding before production serving, preserve Next cache semantics, and document migration and memory-cache boundaries. Co-Authored-By: OpenAI Codex GPT-6.1 --- DESIGN.md | 3 + README.md | 39 + .../app/api/revalidate/route.js | 6 + .../next-16-versioned-cache/app/layout.js | 3 + fixtures/next-16-versioned-cache/app/page.js | 16 + fixtures/next-16-versioned-cache/config.yaml | 3 + .../next-16-versioned-cache/next.config.mjs | 9 + fixtures/next-16-versioned-cache/package.json | 11 + fixtures/next-16-versioned-cache/release.mjs | 1 + .../next-16-versioned-cache.pw.ts | 100 ++ package-lock.json | 1143 +++++++++++++++-- package.json | 1 + src/DESIGN.md | 11 + src/VersionedCacheHandler.cts | 44 + src/VersionedCacheHandler.test.ts | 312 +++++ src/plugin.ts | 28 + src/versionedCache.cts | 166 +++ src/withHarper.cts | 4 + src/withHarper.test.ts | 8 +- 19 files changed, 1770 insertions(+), 138 deletions(-) create mode 100644 DESIGN.md create mode 100644 fixtures/next-16-versioned-cache/app/api/revalidate/route.js create mode 100644 fixtures/next-16-versioned-cache/app/layout.js create mode 100644 fixtures/next-16-versioned-cache/app/page.js create mode 100644 fixtures/next-16-versioned-cache/config.yaml create mode 100644 fixtures/next-16-versioned-cache/next.config.mjs create mode 100644 fixtures/next-16-versioned-cache/package.json create mode 100644 fixtures/next-16-versioned-cache/release.mjs create mode 100644 integrationTests/next-16-versioned-cache.pw.ts create mode 100644 src/DESIGN.md create mode 100644 src/VersionedCacheHandler.cts create mode 100644 src/VersionedCacheHandler.test.ts create mode 100644 src/versionedCache.cts diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..bafc601 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,3 @@ +# Design notes + +- [src/DESIGN.md](src/DESIGN.md) — immutable production filesystem-cache binding and read-only build seeds. diff --git a/README.md b/README.md index 07d2c01..fed221a 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,45 @@ The `files` option is now optional with plugins. This make configuration simpler Glob pattern specifying which files Harper should watch for changes. Example: `'/app/*'`. --> +## Versioned filesystem cache + +To keep Next.js's incremental render and data caches on disk while isolating deployments, use `versionedCacheHandlerPath()` in `next.config.mjs` or `next.config.ts`: + +```js +import { withHarper, versionedCacheHandlerPath } from '@harperfast/nextjs'; + +export default withHarper({ + cacheHandler: versionedCacheHandlerPath(import.meta.dirname), + cacheMaxMemorySize: 0, +}); +``` + +For CommonJS configs, use `require('@harperfast/nextjs')` and pass `__dirname` instead. This is opt-in and replaces the incremental `cacheHandler`; choose it or the Harper-backed handler below. It does not register the separate `cacheHandlers` interface used by `'use cache'`. + +Each production worker captures its build identity before serving. Runtime HTML, RSC, route-handler bodies, Pages Router JSON, and fetch-cache writes go to a directory outside the replaceable component tree. For `~/harper/components/my-app`, the default is: + +```text +~/harper/.nextjs-cache/// +``` + +The identity includes `BUILD_ID` and a digest of the build artifacts, so changing an artifact still separates its cache when `generateBuildId` returns the same ID. Cache contents survive worker restarts and are shared by workers of that app/build on the same filesystem. This is local disk storage; it does not replicate entries or tag invalidations across cluster nodes. Next.js's installed filesystem-cache implementation supplies the cache formats and invalidation behavior. + +Set `cacheMaxMemorySize: 0` in every app sharing a physical Next.js installation, then restart the workers. Next.js's process-local LRU uses unqualified keys and, once another app initializes it, can also be used by an app configured with zero memory. This handler isolates disk caches, not that shared LRU. Disk caching remains enabled. Development and builds outside the Harper plugin retain Next.js's normal filesystem behavior. + +For a custom component layout or a separate cache volume, set the plugin's `cacheDirectory` in `config.yaml` to an absolute path outside the component directory: + +```yaml +'@harperfast/nextjs': + package: '@harperfast/nextjs' + cacheDirectory: /var/cache/harper-nextjs +``` + +Build seeds remain read-only in `.next`; whole-entry misses can read them without combining their files with partial runtime entries. Legacy `.next/server/route-cache` entries are ignored. Enable the handler on outgoing workers before subsequent rolling deployments. On Next.js 14/15, the first migration from stock caching needs those old workers stopped before replacing the app, because their runtime writes share paths with build seeds. Existing contaminated build seeds require a clean deployment. + +This isolates incremental-cache writes. It does not isolate arbitrary app-code reads after a directory swap, move image-optimization caches, or change the Harper-backed and `'use cache'` handlers. Builds must contain regular files and directories under `.next/server`. + +Namespaces are retained by default, including after dropping an app. Plan disk capacity or remove unused namespaces while their workers are stopped. `HARPER_NEXTJS_SWEEP_OLD_BUILDS=true` also enables a filesystem sweep after the existing five-minute delay: a worker still on the live build removes older namespaces of its app. Cache-only removal can cost extra renders in older workers; their writes recreate their own namespace and cannot enter the new build's cache. + ## Caching (Work In Progress) `@harperfast/nextjs` includes a Harper-backed cache handler for Next.js [Incremental Static Regeneration (ISR)](https://nextjs.org/docs/app/guides/incremental-static-regeneration), the [Data Cache (`fetch()`)](https://nextjs.org/docs/app/deep-dive/caching#data-cache), and [`unstable_cache`](https://nextjs.org/docs/app/api-reference/functions/unstable_cache). Cached entries live in Harper instead of the worker's local filesystem, so a cache write on one node is visible to every node in the cluster. diff --git a/fixtures/next-16-versioned-cache/app/api/revalidate/route.js b/fixtures/next-16-versioned-cache/app/api/revalidate/route.js new file mode 100644 index 0000000..2b68ab8 --- /dev/null +++ b/fixtures/next-16-versioned-cache/app/api/revalidate/route.js @@ -0,0 +1,6 @@ +import { revalidatePath } from 'next/cache'; + +export async function POST() { + revalidatePath('/', 'page'); + return Response.json({ revalidated: true }); +} diff --git a/fixtures/next-16-versioned-cache/app/layout.js b/fixtures/next-16-versioned-cache/app/layout.js new file mode 100644 index 0000000..8a6f981 --- /dev/null +++ b/fixtures/next-16-versioned-cache/app/layout.js @@ -0,0 +1,3 @@ +export default function Layout({ children }) { + return {children}; +} diff --git a/fixtures/next-16-versioned-cache/app/page.js b/fixtures/next-16-versioned-cache/app/page.js new file mode 100644 index 0000000..6b1a435 --- /dev/null +++ b/fixtures/next-16-versioned-cache/app/page.js @@ -0,0 +1,16 @@ +import { existsSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { release } from '../release.mjs'; + +export const revalidate = 3600; + +export default async function Page() { + const gate = process.env.HARPER_NEXTJS_CACHE_GATE; + if (gate && existsSync(join(gate, 'hold'))) { + await writeFile(join(gate, 'entered'), release); + while (!existsSync(join(gate, 'release'))) await new Promise((resolve) => setTimeout(resolve, 25)); + } + return

{release}

{randomUUID()}

; +} diff --git a/fixtures/next-16-versioned-cache/config.yaml b/fixtures/next-16-versioned-cache/config.yaml new file mode 100644 index 0000000..e7c727f --- /dev/null +++ b/fixtures/next-16-versioned-cache/config.yaml @@ -0,0 +1,3 @@ +'@harperfast/nextjs': + package: '@harperfast/nextjs' + bundler: webpack diff --git a/fixtures/next-16-versioned-cache/next.config.mjs b/fixtures/next-16-versioned-cache/next.config.mjs new file mode 100644 index 0000000..6830341 --- /dev/null +++ b/fixtures/next-16-versioned-cache/next.config.mjs @@ -0,0 +1,9 @@ +import * as plugin from '@harperfast/nextjs'; + +export default plugin.withHarper({ + ...(process.env.HARPER_NEXTJS_CACHE_BASELINE !== '1' && { + cacheHandler: plugin.versionedCacheHandlerPath(import.meta.dirname), + }), + cacheMaxMemorySize: 0, + generateBuildId: async () => 'deliberately-reused-build-id', +}); diff --git a/fixtures/next-16-versioned-cache/package.json b/fixtures/next-16-versioned-cache/package.json new file mode 100644 index 0000000..796af4b --- /dev/null +++ b/fixtures/next-16-versioned-cache/package.json @@ -0,0 +1,11 @@ +{ + "name": "next-16-versioned-cache", + "private": true, + "type": "module", + "dependencies": { + "@harperfast/nextjs": "file:../../", + "next": "16.3.8", + "react": "^19", + "react-dom": "^19" + } +} diff --git a/fixtures/next-16-versioned-cache/release.mjs b/fixtures/next-16-versioned-cache/release.mjs new file mode 100644 index 0000000..e55f3b2 --- /dev/null +++ b/fixtures/next-16-versioned-cache/release.mjs @@ -0,0 +1 @@ +export const release = 'v1'; diff --git a/integrationTests/next-16-versioned-cache.pw.ts b/integrationTests/next-16-versioned-cache.pw.ts new file mode 100644 index 0000000..59f7f72 --- /dev/null +++ b/integrationTests/next-16-versioned-cache.pw.ts @@ -0,0 +1,100 @@ +import { test, expect } from '@playwright/test'; +import { createHarperContext, setupHarperWithFixture, startHarper, killHarper, teardownHarper, type StartedHarperTestContext } from '@harperfast/integration-testing'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { mkdtemp, cp, mkdir, readFile, writeFile, readdir, rename, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const fixtureName = 'next-16-versioned-cache'; +const require = createRequire(import.meta.url); +const exec = promisify(execFile); + +async function htmlFiles(directory: string): Promise { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + const contents: string[] = []; + for (const entry of entries) { + const file = join(directory, entry.name); + if (entry.isDirectory()) contents.push(...await htmlFiles(file)); + else if (entry.name.endsWith('.html')) contents.push(await readFile(file, 'utf8')); + } + return contents; +} + +function nonce(html: string): string { + const match = html.match(/

([^<]+)<\/p>/); + expect(match).not.toBeNull(); + return match![1]; +} + +test('an old regeneration cannot pollute the next release, and regenerated pages survive restarts', async ({ request }) => { + test.setTimeout(300_000); + const gate = await mkdtemp(join(tmpdir(), 'next-cache-render-gate-')); + const context = createHarperContext(fixtureName); + const options = { + harperBinPath: join(dirname(require.resolve('harper')), 'bin', 'harper.js'), + startupTimeoutMs: 120_000, + env: { + HARPER_NEXTJS_CACHE_GATE: gate, + ...(process.env.HARPER_NEXTJS_CACHE_BASELINE && { HARPER_NEXTJS_CACHE_BASELINE: process.env.HARPER_NEXTJS_CACHE_BASELINE }), + }, + config: { applications: { lockdown: 'none', moduleLoader: 'none', dependencyLoader: 'native', allowedDirectory: 'any' } }, + }; + let started: StartedHarperTestContext | undefined; + try { + started = await setupHarperWithFixture(context, join(import.meta.dirname, '..', 'fixtures', fixtureName), options); + const { dataRootDir, httpURL } = started.harper; + const app = join(dataRootDir, 'components', fixtureName); + const candidate = join(dataRootDir, 'candidate'); + await cp(app, candidate, { recursive: true, dereference: true, filter: (file) => file !== join(app, '.next') }); + await writeFile(join(candidate, 'release.mjs'), "export const release = 'v2';\n"); + await writeFile(join(candidate, 'config.yaml'), await readFile(join(candidate, 'config.yaml'), 'utf8') + ' prebuilt: true\n'); + await exec(process.execPath, [join(candidate, 'node_modules', 'next', 'dist', 'bin', 'next'), 'build', '--webpack'], { + cwd: candidate, env: { ...process.env, ...options.env }, timeout: 120_000, maxBuffer: 10 * 1024 * 1024, + }); + + expect(await (await request.get(httpURL)).text()).toContain('

v1

'); + await writeFile(join(gate, 'hold'), ''); + expect((await request.post(`${httpURL}/api/revalidate`)).status()).toBe(200); + const held = request.get(httpURL); + await expect.poll(async () => readFile(join(gate, 'entered'), 'utf8').catch(() => '')).toBe('v1'); + await mkdir(join(dataRootDir, 'aside')); + await rename(app, join(dataRootDir, 'aside', fixtureName)); + await rename(candidate, app); + await writeFile(join(gate, 'release'), ''); + expect(await (await held).text()).toContain('

v1

'); + const baseline = process.env.HARPER_NEXTJS_CACHE_BASELINE === '1'; + const writeRoot = baseline ? join(app, '.next', 'server', 'route-cache') : join(dataRootDir, '.nextjs-cache'); + await expect.poll(async () => (await htmlFiles(writeRoot)).some((html) => html.includes('

v1

'))).toBe(true); + + await killHarper(started); + started = await startHarper(context, options); + const first = await (await request.get(httpURL)).text(); + expect(first).toContain('

v2

'); + expect(await htmlFiles(join(app, '.next', 'server', 'route-cache'))).toEqual([]); + const seedNonce = nonce(first); + expect((await request.post(`${httpURL}/api/revalidate`)).status()).toBe(200); + let regenerated = ''; + await expect.poll(async () => { + regenerated = nonce(await (await request.get(httpURL)).text()); + return regenerated; + }).not.toBe(seedNonce); + await expect.poll(async () => (await htmlFiles(join(dataRootDir, '.nextjs-cache'))).some((html) => html.includes(regenerated))).toBe(true); + await killHarper(started); + started = await startHarper(context, options); + const afterRestart = await request.get(httpURL); + expect(nonce(await afterRestart.text())).toBe(regenerated); + expect(afterRestart.headers()['x-nextjs-cache']).toBe('HIT'); + } finally { + await writeFile(join(gate, 'release'), '').catch(() => {}); + if (started) await teardownHarper(started); + await rm(gate, { recursive: true, force: true }); + } +}); diff --git a/package-lock.json b/package-lock.json index 942c5f4..8904d7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "next-14": "npm:next@14.2.35", "next-15": "npm:next@15.5.15", "next-16": "npm:next@16.2.4", + "next-16-route-cache": "npm:next@16.3.8", "typescript": "6.0.3" }, "engines": { @@ -31,10 +32,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -83,7 +83,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -106,7 +105,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -122,6 +120,43 @@ "@img/sharp-libvips-darwin-x64": "1.2.4" } }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-freebsd-wasm32/node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-darwin-arm64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", @@ -129,7 +164,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -146,7 +180,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -163,7 +196,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -180,7 +212,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -197,7 +228,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -214,7 +244,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -231,7 +260,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -248,7 +276,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -265,7 +292,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -282,7 +308,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -299,7 +324,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -322,7 +346,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -345,7 +368,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -368,7 +390,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -391,7 +412,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -414,7 +434,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -437,7 +456,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -460,7 +478,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -483,7 +500,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -496,6 +512,43 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32/node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-win32-arm64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", @@ -503,7 +556,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -523,7 +575,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -543,7 +594,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -937,7 +987,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2750,6 +2799,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2767,6 +2819,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2784,6 +2839,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2801,6 +2859,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -11119,9 +11180,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -11672,134 +11733,942 @@ } } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "node_modules/next-16-route-cache": { + "name": "next", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", + "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/playwright": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", - "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", - "devOptional": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "playwright-core": "1.62.0" + "@next/env": "16.3.8", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" }, "bin": { - "playwright": "cli.js" + "next": "dist/bin/next" }, "engines": { - "node": ">=20" + "node": ">=20.9.0" }, "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", - "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" + "@next/swc-darwin-arm64": "16.3.8", + "@next/swc-darwin-x64": "16.3.8", + "@next/swc-linux-arm64-gnu": "16.3.8", + "@next/swc-linux-arm64-musl": "16.3.8", + "@next/swc-linux-x64-gnu": "16.3.8", + "@next/swc-linux-x64-musl": "16.3.8", + "@next/swc-win32-arm64-msvc": "16.3.8", + "@next/swc-win32-x64-msvc": "16.3.8", + "sharp": "^0.35.4" }, - "engines": { - "node": ">=20" - } - }, - "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" + "@playwright/test": { + "optional": true }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "node_modules/next-16-route-cache/node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=0.10.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "node_modules/next-16-route-cache/node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" }, - "peerDependencies": { - "react": "^18.3.1" + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" + "node_modules/next-16-route-cache/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "node_modules/next-16-route-cache/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-16-route-cache/node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-16-route-cache/node_modules/@next/env": { + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", + "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next-16-route-cache/node_modules/@next/swc-darwin-arm64": { + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", + "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-16-route-cache/node_modules/@next/swc-darwin-x64": { + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", + "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-16-route-cache/node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", + "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-16-route-cache/node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", + "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-16-route-cache/node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", + "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-16-route-cache/node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", + "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-16-route-cache/node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", + "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-16-route-cache/node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", + "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-16-route-cache/node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/next-16-route-cache/node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/next-16-route-cache/node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, "bin": { diff --git a/package.json b/package.json index 61b2c88..a936187 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "next-14": "npm:next@14.2.35", "next-15": "npm:next@15.5.15", "next-16": "npm:next@16.2.4", + "next-16-route-cache": "npm:next@16.3.8", "typescript": "6.0.3" }, "peerDependencies": { diff --git a/src/DESIGN.md b/src/DESIGN.md new file mode 100644 index 0000000..b858d30 --- /dev/null +++ b/src/DESIGN.md @@ -0,0 +1,11 @@ +# Versioned filesystem cache + +`versionedCache.cts` binds the logical `serverDistDir`, installed Next filesystem-cache constructor and immutable artifact namespace before `plugin.ts` prepares the production server. Rebinding a different artifact in one worker and changing a build during prepare both fail startup. No request re-resolves a build identity or constructor from the replaceable app path. The native CJS registry bridges Harper's VM plugin and Next's native CJS handler, including separate package copies. + +`VersionedCacheHandler.cts` writes through the captured runtime delegate. A whole-entry miss can use a second delegate whose filesystem cannot write or read legacy `server/route-cache`. Per-file fallback could mix runtime HTML with seed RSC. Seed reads still use the component pathname; this is cache-write isolation, not arbitrary application-file read isolation. + +The artifact digest includes build seeds, manifests, compiled server files and Next package metadata; it excludes the legacy route-cache directory. These build files stay immutable when this handler owns runtime writes, so restart identity is stable. This also handles a reused BUILD_ID; Next15/16 preview IDs alone would not, because they can be cached across builds. On 14/15, outgoing stock workers can still mutate seeds during the initial migration. + +The optional sweep deletes disposable cache namespaces, never app artifacts. Outgoing workers skip the sweep after their build leaves the live path. Deletion may cost an older worker a render; its captured runtime root is recreated on writes. The default retains namespaces for operator-managed cleanup. + +`VersionedCacheHandler.test.ts` exercises installed Next14/15/16 caches against real files and fresh processes. `integrationTests/next-16-versioned-cache.pw.ts` holds a real regeneration across the swap and verifies the new build and persistence after restarts. diff --git a/src/VersionedCacheHandler.cts b/src/VersionedCacheHandler.cts new file mode 100644 index 0000000..456e8f4 --- /dev/null +++ b/src/VersionedCacheHandler.cts @@ -0,0 +1,44 @@ +import { join } from 'node:path'; +import { getCacheBinding, isProductionCache, stockCacheConstructor, type FileSystemCacheContext } from './versionedCache.cjs'; +import type FileSystemCache from 'next-16/dist/server/lib/incremental-cache/file-system-cache.js'; + +export default class VersionedCacheHandler { + private runtime: FileSystemCache; + private seed?: FileSystemCache; + private context: FileSystemCacheContext; + private binding: ReturnType; + + constructor(context: FileSystemCacheContext) { + this.context = context; + this.binding = context.dev ? undefined : getCacheBinding(context.serverDistDir); + if (!this.binding && !context.dev && isProductionCache(context.serverDistDir)) { + throw new Error('VersionedCacheHandler has no production build binding; check .next/required-server-files.json and restart the application'); + } + const constructor = this.binding?.FileSystemCache ?? stockCacheConstructor(context.serverDistDir); + this.runtime = new constructor({ + ...context, + serverDistDir: this.binding ? join(this.binding.cacheDirectory, 'server') : context.serverDistDir, + }); + } + + async get(...args: Parameters): ReturnType { + const entry = await this.runtime.get(...args); + if (entry !== null || !this.binding) return entry; + // Falling back per file could combine a runtime HTML file with a different build seed's RSC. + this.seed ??= new this.binding.FileSystemCache({ ...this.context, fs: this.binding.seedFs, flushToDisk: true }); + return this.seed.get(...args); + } + + set(...args: Parameters): ReturnType { + return this.runtime.set(...args); + } + + revalidateTag(...args: Parameters): ReturnType { + return this.runtime.revalidateTag(...args); + } + + resetRequestCache(): void { + this.runtime.resetRequestCache(); + this.seed?.resetRequestCache(); + } +} diff --git a/src/VersionedCacheHandler.test.ts b/src/VersionedCacheHandler.test.ts new file mode 100644 index 0000000..5a3ebf4 --- /dev/null +++ b/src/VersionedCacheHandler.test.ts @@ -0,0 +1,312 @@ +import { describe, it, type TestContext } from 'node:test'; +import assert from 'node:assert'; +import { createRequire } from 'node:module'; +import { mkdtemp, mkdir, writeFile, readFile, rename, rm, symlink } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { FileSystemCacheContext } from './versionedCache.cjs'; + +const require = createRequire(import.meta.url); +const { bindVersionedCache, assertVersionedCacheBuild, sweepVersionedCache, markProductionCache } = require('./versionedCache.cjs') as typeof import('./versionedCache.cjs'); +const Handler = require('./VersionedCacheHandler.cjs').default as typeof import('./VersionedCacheHandler.cjs').default; +const pluginDirectory = dirname(import.meta.dirname); +const exec = promisify(execFile); +const versions = ['next-14', 'next-15', 'next-16', 'next-16-route-cache']; + +async function createApp(app: string, version: string, release: string, handler = true) { + await mkdir(join(app, '.next', 'server', 'app'), { recursive: true }); + await mkdir(join(app, 'node_modules', '@harperfast'), { recursive: true }); + await symlink(dirname(require.resolve(`${version}/package.json`)), join(app, 'node_modules', 'next'), 'junction'); + await symlink(pluginDirectory, join(app, 'node_modules', '@harperfast', 'nextjs'), 'junction'); + await writeFile(join(app, 'package.json'), '{}'); + await writeFile(join(app, '.next', 'BUILD_ID'), 'reused-build-id'); + await writeFile(join(app, '.next', 'server', 'app', 'page.js'), release); + await writeFile(join(app, '.next', 'required-server-files.json'), JSON.stringify({ config: { + distDir: '.next', + ...(handler && { cacheHandler: join(app, 'node_modules', '@harperfast', 'nextjs', 'dist', 'VersionedCacheHandler.cjs') }), + } })); +} + +function context(app: string): FileSystemCacheContext { + return { + fs: createRequire(join(app, 'package.json'))('next/dist/server/lib/node-fs-methods.js').nodeFs, + serverDistDir: join(app, '.next', 'server'), + flushToDisk: true, + maxMemoryCacheSize: 0, + revalidatedTags: [], + _requestHeaders: {}, + experimental: { ppr: false }, + appDir: true, + pagesDir: true, + } as FileSystemCacheContext; +} + +function pageValue(version: string, html: string) { + return version === 'next-14' + ? { kind: 'PAGE', html, pageData: html, headers: { 'x-test': html } } + : { kind: 'APP_PAGE', html, rscData: Buffer.from(html), headers: { 'x-test': html } }; +} +const pageContext = { kind: 'APP_PAGE', kindHint: 'app', isFallback: false, isRoutePPREnabled: false } as Parameters['get']>[1]; + +async function pageHTML(cache: InstanceType, key: string): Promise { + const entry = await cache.get(key, pageContext); + assert.ok(entry?.value && 'html' in entry.value); + return entry.value.html; +} + +function pageKey(version: string): string { + return version === 'next-16-route-cache' + ? require(`${version}/dist/server/lib/route-cache-key.js`).getRouteCacheKey('/isr', { kind: 'APP_PAGE', sourceRoute: '/isr/page' }) + : 'isr'; +} + +async function temporaryApp(t: TestContext, version: string) { + const root = await mkdtemp(join(tmpdir(), 'next-versioned-cache-')); + t.after(() => rm(root, { recursive: true, force: true })); + const app = join(root, 'components', 'app'); + await createApp(app, version, 'v1'); + return { root, app }; +} + +async function seed(app: string, version: string, html: string) { + const Native = require(`${version}/dist/server/lib/incremental-cache/file-system-cache.js`).default; + await new Native(context(app)).set('isr', pageValue(version, html), pageContext); + if (version === 'next-16-route-cache') { + const metaPath = join(app, '.next', 'server', 'app', 'isr.meta'); + const meta = JSON.parse(await readFile(metaPath, 'utf8')); + meta.routeCache = { key: pageKey(version), owner: { kind: 'APP_PAGE', sourceRoute: '/isr/page' }, isFallback: false }; + await writeFile(metaPath, JSON.stringify(meta)); + } +} + +async function inFreshWorker(app: string, version: string, set?: string) { + const program = ` + const { createRequire } = require('node:module'); + const { join } = require('node:path'); + const [app, plugin, key, version, html] = process.argv.slice(1); + const appRequire = createRequire(join(app, 'package.json')); + const { bindVersionedCache } = require(join(plugin, 'dist/versionedCache.cjs')); + const Handler = require(join(plugin, 'dist/VersionedCacheHandler.cjs')).default; + const binding = bindVersionedCache(app); + const ctx = { fs: appRequire('next/dist/server/lib/node-fs-methods.js').nodeFs, serverDistDir: join(app,'.next/server'), flushToDisk:true, maxMemoryCacheSize:0, revalidatedTags:[], experimental:{ppr:false}, appDir:true, pagesDir:true }; + const getCtx = { kind:'APP_PAGE', kindHint:'app', isFallback:false, isRoutePPREnabled:false }; + (async () => { + const cache = new Handler(ctx); + if (html) await cache.set(key, version === 'next-14' ? {kind:'PAGE',html,pageData:html,headers:{'x-test':html}} : {kind:'APP_PAGE',html,rscData:Buffer.from(html),headers:{'x-test':html}}, getCtx); + const entry = await cache.get(key,getCtx); + console.log(JSON.stringify({html:entry?.value?.html, directory:binding.cacheDirectory})); + })().catch(error => { console.error(error);process.exitCode=1; }); + `; + const { stdout } = await exec(process.execPath, ['-e', program, app, pluginDirectory, pageKey(version), version, set ?? ''], { timeout: 20_000 }); + return JSON.parse(stdout) as { html: string; directory: string }; +} + +for (const version of versions) describe(`versioned filesystem cache (${version})`, () => { + it('pins late writes to the old build and persists the new cache across worker restarts', async (t) => { + const { root, app } = await temporaryApp(t, version); + const oldBinding = bindVersionedCache(app)!; + const old = new Handler(context(app)); + await old.set(pageKey(version), pageValue(version, 'v1 early') as never, pageContext as never); + await rename(app, join(root, 'aside')); + await createApp(app, version, 'v2'); + const fresh = await inFreshWorker(app, version, 'v2'); + await old.set(pageKey(version), pageValue(version, 'v1 late') as never, pageContext as never); + const anotherOldRequest = new Handler(context(app)); + assert.equal(await pageHTML(anotherOldRequest, pageKey(version)), 'v1 late'); + const restarted = await inFreshWorker(app, version); + assert.equal(restarted.html, 'v2'); + assert.equal(restarted.directory, fresh.directory); + assert.notEqual(fresh.directory, oldBinding.cacheDirectory, 'reusing BUILD_ID must not merge different artifacts'); + assert.equal(existsSync(join(app, '.next', 'server', 'route-cache')), false); + assert.equal(existsSync(join(app, '.next', 'server', 'app', 'isr.html')), false); + assert.throws(() => assertVersionedCacheBuild(oldBinding), /build changed/); + assert.throws(() => bindVersionedCache(app), /restart the worker/); + assert.equal(await sweepVersionedCache(oldBinding), 0, 'an outgoing worker must not sweep the new build'); + await rm(oldBinding.cacheDirectory, { recursive: true }); + await old.set(pageKey(version), pageValue(version, 'v1 recreated') as never, pageContext as never); + assert.equal(await pageHTML(anotherOldRequest, pageKey(version)), 'v1 recreated'); + assert.equal((await inFreshWorker(app, version)).html, 'v2'); + }); + + it('isolates identical cache keys for apps sharing one installed Next module with memory disabled', async (t) => { + const { root, app } = await temporaryApp(t, version); + const secondApp = join(root, 'components', 'second'); + await createApp(secondApp, version, 'v1'); + const firstBinding = bindVersionedCache(app)!; + const secondBinding = bindVersionedCache(secondApp)!; + assert.strictEqual(firstBinding.FileSystemCache, secondBinding.FileSystemCache); + const first = new Handler(context(app)); + const second = new Handler(context(secondApp)); + await first.set(pageKey(version), pageValue(version, 'first app') as never, pageContext as never); + await second.set(pageKey(version), pageValue(version, 'second app') as never, pageContext as never); + assert.equal(await pageHTML(first, pageKey(version)), 'first app'); + assert.equal(await pageHTML(second, pageKey(version)), 'second app'); + assert.notEqual(firstBinding.cacheDirectory, secondBinding.cacheDirectory); + }); + + it('uses immutable build seeds on cold misses without combining partial runtime files', async (t) => { + const { app } = await temporaryApp(t, version); + await seed(app, version, 'seed'); + const binding = bindVersionedCache(app)!; + const cache = new Handler(context(app)); + assert.equal(await pageHTML(cache, pageKey(version)), 'seed'); + const runtimePrimary = version === 'next-16-route-cache' + ? join(binding.cacheDirectory, 'server', pageKey(version) + '.html') + : join(binding.cacheDirectory, 'server', 'app', 'isr.html'); + await mkdir(dirname(runtimePrimary), { recursive: true }); + await writeFile(runtimePrimary, 'incomplete runtime'); + assert.equal(await pageHTML(cache, pageKey(version)), 'seed'); + assert.equal(await readFile(join(app, '.next', 'server', 'app', 'isr.html'), 'utf8'), 'seed'); + assertVersionedCacheBuild(binding); + }); + + it('keeps fetch seeds read-only and persists runtime fetches outside the component', async (t) => { + const { app } = await temporaryApp(t, version); + const Native = require(`${version}/dist/server/lib/incremental-cache/file-system-cache.js`).default; + const data = { kind: 'FETCH', data: { headers: {}, body: 'seed', status: 200, url: 'https://example.com/data' }, revalidate: 3600 }; + const fetchContext = { kind: 'FETCH', kindHint: 'fetch', fetchCache: true, tags: ['seed-tag'], softTags: [] }; + await new Native(context(app)).set('fetch-key', data, fetchContext); + const seedFile = join(app, '.next', 'cache', 'fetch-cache', 'fetch-key'); + const original = await readFile(seedFile, 'utf8'); + const binding = bindVersionedCache(app)!; + const cache = new Handler(context(app)); + const result = await cache.get('fetch-key', { ...fetchContext, tags: ['another-tag'] } as never); + assert.equal(result?.value?.kind, 'FETCH'); + assert.equal(await readFile(seedFile, 'utf8'), original); + await cache.set('fetch-key', { ...data, data: { ...data.data, body: 'runtime' } } as never, fetchContext as never); + assert.match(await readFile(join(binding.cacheDirectory, 'cache', 'fetch-cache', 'fetch-key'), 'utf8'), /runtime/); + assertVersionedCacheBuild(binding); + }); + + it('leaves external builds and development on the stock filesystem when unbound', async (t) => { + const { app } = await temporaryApp(t, version); + const cache = new Handler(context(app)); + await cache.set(pageKey(version), pageValue(version, 'stock') as never, pageContext as never); + assert.equal(await pageHTML(cache, pageKey(version)), 'stock'); + }); + + it('persists Pages Router data and route-handler bodies in the namespace', async (t) => { + const { app } = await temporaryApp(t, version); + const binding = bindVersionedCache(app)!; + const cache = new Handler(context(app)); + const pages = { kind: version === 'next-14' ? 'PAGE' : 'PAGES', html: 'pages', pageData: { release: 'v1' }, headers: { 'x-test': 'pages' } }; + const pagesContext = { kind: 'PAGES', kindHint: 'pages', isFallback: false }; + const pagesKey = version === 'next-16-route-cache' + ? require(`${version}/dist/server/lib/route-cache-key.js`).getRouteCacheKey('/pages', { kind: 'PAGES', sourceRoute: '/pages' }) + : 'pages'; + await cache.set(pagesKey, pages as never, pagesContext as never); + const result = await cache.get(pagesKey, pagesContext as never); + assert.ok(result?.value && 'pageData' in result.value); + assert.deepEqual(result.value.pageData, { release: 'v1' }); + const route = { kind: version === 'next-14' ? 'ROUTE' : 'APP_ROUTE', body: Buffer.from('route body'), status: 201, headers: { 'x-test': 'route' } }; + const routeContext = { kind: 'APP_ROUTE', kindHint: 'app' }; + const routeKey = version === 'next-16-route-cache' + ? require(`${version}/dist/server/lib/route-cache-key.js`).getRouteCacheKey('/route', { kind: 'APP_ROUTE', sourceRoute: '/route/route' }) + : 'route'; + await cache.set(routeKey, route as never, routeContext as never); + const body = await cache.get(routeKey, routeContext as never); + assert.ok(body?.value && 'body' in body.value); + assert.equal(body.value.body.toString(), 'route body'); + assert.equal(existsSync(join(app, '.next', 'server', 'pages')), false); + assert.equal(existsSync(join(app, '.next', 'server', 'app', 'route.body')), false); + assertVersionedCacheBuild(binding); + }); + + it('preserves the installed Next version\'s tag expiration', async (t) => { + const { app } = await temporaryApp(t, version); + bindVersionedCache(app); + const cache = new Handler(context(app)); + const tag = app; + const value = { ...pageValue(version, 'tagged'), headers: { 'x-next-cache-tags': tag } }; + await cache.set(pageKey(version), value as never, pageContext as never); + assert.equal(await pageHTML(cache, pageKey(version)), 'tagged'); + await cache.revalidateTag(tag); + assert.equal(await cache.get(pageKey(version), pageContext), null); + cache.resetRequestCache(); + }); +}); + +describe('versioned cache startup and retention', () => { + it('refuses unsafe production fallback while leaving development and external builds available', async (t) => { + const { app } = await temporaryApp(t, 'next-16'); + const ctx = context(app); + markProductionCache(ctx.serverDistDir); + assert.throws(() => new Handler(ctx), /no production build binding/); + assert.doesNotThrow(() => new Handler({ ...ctx, dev: true })); + const binding = bindVersionedCache(app)!; + assert.ok(Object.isFrozen(binding)); + assert.doesNotThrow(() => new Handler(ctx)); + const dev = new Handler({ ...ctx, dev: true }); + await dev.set('dev-page', pageValue('next-16', 'development') as never, pageContext as never); + assert.ok(existsSync(join(app, '.next', 'server', 'app', 'dev-page.html'))); + assert.equal(existsSync(join(binding.cacheDirectory, 'server', 'app', 'dev-page.html')), false); + }); + it('leaves default and unrelated custom handlers untouched', async (t) => { + const { app } = await temporaryApp(t, 'next-16'); + const file = join(app, '.next', 'required-server-files.json'); + await writeFile(file, '{"config":{}}'); + assert.equal(bindVersionedCache(app), undefined); + await writeFile(file, '{"config":{"cacheHandler":"/custom/handler.cjs"}}'); + assert.equal(bindVersionedCache(app), undefined); + }); + + it('rejects a cache root inside the component and reports storage failures at startup', async (t) => { + const { root, app } = await temporaryApp(t, 'next-16'); + assert.throws(() => bindVersionedCache(app, 'runtime-cache'), /outside the component/); + const blockedRoot = join(root, 'file'); + await writeFile(blockedRoot, 'not a directory'); + assert.throws(() => bindVersionedCache(app, blockedRoot), /ENOTDIR/); + }); + + it('accepts a moved prebuilt artifact whose stored handler path names its build machine', async (t) => { + const { app, root } = await temporaryApp(t, 'next-16'); + const moved = join(root, 'moved-app'); + await rename(app, moved); + assert.ok(bindVersionedCache(moved)); + }); + + it('separates builds whose only difference is prerendered seed data', async (t) => { + const version = 'next-16-route-cache'; + const { app, root } = await temporaryApp(t, version); + await seed(app, version, 'old seed'); + const binding = bindVersionedCache(app)!; + await rename(app, join(root, 'aside')); + await createApp(app, version, 'v1'); + await seed(app, version, 'new seed'); + const fresh = await inFreshWorker(app, version); + assert.equal(fresh.html, 'new seed'); + assert.notEqual(fresh.directory, binding.cacheDirectory); + }); + + it('sweeps only recognized older namespaces of this app', async (t) => { + const { app, root } = await temporaryApp(t, 'next-16'); + const binding = bindVersionedCache(app)!; + const otherBuild = join(dirname(binding.cacheDirectory), 'a'.repeat(64)); + const otherApp = join(root, '.nextjs-cache', 'unrelated-app', 'b'.repeat(64)); + const unrelated = join(dirname(binding.cacheDirectory), 'keep-me'); + await mkdir(otherBuild, { recursive: true }); + await mkdir(otherApp, { recursive: true }); + await mkdir(unrelated); + assert.equal(await sweepVersionedCache(binding), 1); + assert.equal(existsSync(otherBuild), false); + assert.ok(existsSync(binding.cacheDirectory)); + assert.ok(existsSync(otherApp)); + assert.ok(existsSync(unrelated)); + }); +}); + +it('does not import a legacy route-cache entry or promote a build seed into the component', async (t) => { + const version = 'next-16-route-cache'; + const { app } = await temporaryApp(t, version); + await seed(app, version, 'new seed'); + const Native = require(`${version}/dist/server/lib/incremental-cache/file-system-cache.js`).default; + await new Native(context(app)).set(pageKey(version), pageValue(version, 'legacy old render'), pageContext); + const binding = bindVersionedCache(app)!; + const cache = new Handler(context(app)); + assert.equal(await pageHTML(cache, pageKey(version)), 'new seed'); + assert.equal(binding.seedFs.existsSync(join(app, '.next', 'server', pageKey(version) + '.html')), false); + assertVersionedCacheBuild(binding); +}); diff --git a/src/plugin.ts b/src/plugin.ts index d4667e0..508dc2f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -31,6 +31,7 @@ type Bundler = 'webpack' | 'turbopack'; interface NextPluginConfig extends Config { buildOnly: boolean; + cacheDirectory?: string; bundler: Bundler; dev: boolean; // @ts-expect-error @@ -79,6 +80,7 @@ function resolveConfig(scope: Scope): NextPluginConfig { } assertType('buildOnly', options.buildOnly, 'boolean'); + assertType('cacheDirectory', options.cacheDirectory, 'string'); assertType('bundler', options.bundler, 'string'); assertType('dev', options.dev, 'boolean'); assertType('port', options.port, 'number'); @@ -401,6 +403,11 @@ async function runNextBuild(scope: Scope, config: NextPluginConfig, next: NextPa async function serve(scope: Scope, config: NextPluginConfig, next: NextPackage) { scope.logger.debug?.(`Serving Next.js application at ${scope.directory}`); + const cacheBindings = !config.dev + ? createRequire(import.meta.url)('./versionedCache.cjs') as typeof import('./versionedCache.cjs') + : undefined; + cacheBindings?.markProductionCache(join(scope.directory, '.next', 'server')); + const cacheBinding = cacheBindings?.bindVersionedCache(scope.directory, config.cacheDirectory); let app; switch (next.version) { @@ -416,6 +423,27 @@ async function serve(scope: Scope, config: NextPluginConfig, next: NextPackage) } await app.prepare(); + if (cacheBindings && cacheBinding) { + try { + cacheBindings.assertVersionedCacheBuild(cacheBinding); + } catch (error) { + await app.close(); + throw error; + } + const workerIndex = + (harper as unknown as { server?: WorkerServer }).server?.workerIndex ?? + (globalThis as { server?: WorkerServer }).server?.workerIndex; + if (oldBuildSweepEnabled() && (workerIndex === undefined || workerIndex === 0)) { + const timer = setTimeout(async () => { + try { + await cacheBindings.sweepVersionedCache(cacheBinding); + } catch (error) { + scope.logger.error?.('Error sweeping versioned Next.js cache: ', error); + } + }, OLD_BUILD_SWEEP_DELAY_MS); + timer.unref(); + } + } const requestHandler = app.getRequestHandler(); diff --git a/src/versionedCache.cts b/src/versionedCache.cts new file mode 100644 index 0000000..93cf516 --- /dev/null +++ b/src/versionedCache.cts @@ -0,0 +1,166 @@ +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync } from 'node:fs'; +import { readdir, rm } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import type FileSystemCache from 'next-16/dist/server/lib/incremental-cache/file-system-cache.js'; +import type { CacheFs } from 'next-16/dist/shared/lib/utils.js'; + +export type FileSystemCacheContext = ConstructorParameters[0]; + +export interface CacheBinding { + appDirectory: string; + distDirectory: string; + serverDistDir: string; + cacheDirectory: string; + identity: string; + FileSystemCache: typeof FileSystemCache; + seedFs: CacheFs; +} + +const registryKey = Symbol.for('@harperfast/nextjs.versioned-cache.v1'); +const nativeGlobal = globalThis as unknown as Record>; +const bindings = nativeGlobal[registryKey] ??= new Map(); +const fallbackConstructors = new Map(); +const productionKey = Symbol.for('@harperfast/nextjs.production-cache-contexts'); +const productionGlobal = globalThis as unknown as Record>; +const productionContexts = productionGlobal[productionKey] ??= new Set(); + +function hash(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function buildIdentity(appDirectory: string, distDirectory: string): string { + const digest = createHash('sha256'); + const buffer = Buffer.allocUnsafe(256 * 1024); + const addFile = (file: string) => { + const fd = openSync(file, 'r'); + try { + digest.update(JSON.stringify([relative(appDirectory, file), fstatSync(fd).size])); + let length: number; + while ((length = readSync(fd, buffer, 0, buffer.length, null)) > 0) digest.update(buffer.subarray(0, length)); + } finally { + closeSync(fd); + } + }; + const addDirectory = (directory: string, skip?: string) => { + const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry of entries) { + if (entry.name === skip) continue; + const file = join(directory, entry.name); + if (entry.isDirectory()) addDirectory(file); + else if (entry.isFile()) addFile(file); + else throw new Error(`Versioned Next.js cache requires regular build files: ${file}`); + } + }; + + const buildIdFile = join(distDirectory, 'BUILD_ID'); + if (!readFileSync(buildIdFile, 'utf8').trim()) throw new Error(`Missing Next.js build identity: ${buildIdFile}`); + addFile(buildIdFile); + addFile(join(appDirectory, 'node_modules', 'next', 'package.json')); + for (const manifest of ['required-server-files.json', 'prerender-manifest.json', 'routes-manifest.json', 'build-manifest.json', 'app-build-manifest.json']) { + const file = join(distDirectory, manifest); + if (existsSync(file)) addFile(file); + } + addDirectory(join(distDirectory, 'server'), 'route-cache'); + const fetchSeeds = join(distDirectory, 'cache', 'fetch-cache'); + if (existsSync(fetchSeeds)) addDirectory(fetchSeeds); + return digest.digest('hex'); +} + +function seedFileSystem(fs: CacheFs, serverDistDir: string): CacheFs { + const legacyCache = join(serverDistDir, 'route-cache'); + const isLegacyCache = (file: unknown) => typeof file === 'string' && (file === legacyCache || file.startsWith(legacyCache + sep)); + const missing = (file: unknown) => Object.assign(new Error(`ENOENT: no build seed at '${file}'`), { code: 'ENOENT', path: file }); + const readFile = ((...args: Parameters) => + isLegacyCache(args[0]) ? Promise.reject(missing(args[0])) : fs.readFile(...args)) as CacheFs['readFile']; + const readFileSync = ((...args: Parameters) => { + if (isLegacyCache(args[0])) throw missing(args[0]); + return fs.readFileSync(...args); + }) as CacheFs['readFileSync']; + return { + existsSync: (file) => !isLegacyCache(file) && fs.existsSync(file), + readFile, + readFileSync, + stat: (file) => isLegacyCache(file) ? Promise.reject(missing(file)) : fs.stat(file), + writeFile: async () => {}, + mkdir: async () => {}, + }; +} + +export function bindVersionedCache(appDirectory: string, cacheDirectory?: string): CacheBinding | undefined { + appDirectory = resolve(appDirectory); + const manifestFile = join(appDirectory, '.next', 'required-server-files.json'); + if (!existsSync(manifestFile)) return; + const { config } = JSON.parse(readFileSync(manifestFile, 'utf8')); + if (!config?.cacheHandler || basename(config.cacheHandler.replace(/\\/g, '/')) !== 'VersionedCacheHandler.cjs') return; + const nativeRequire = createRequire(join(appDirectory, 'package.json')); + const handler = join(appDirectory, 'node_modules', '@harperfast', 'nextjs', 'dist', 'VersionedCacheHandler.cjs'); + const packagedHandler = config.cacheHandler.replace(/\\/g, '/').endsWith('/node_modules/@harperfast/nextjs/dist/VersionedCacheHandler.cjs'); + if (!packagedHandler && nativeRequire.resolve(resolve(appDirectory, config.cacheHandler)) !== nativeRequire.resolve(handler)) return; + + const distDirectory = resolve(appDirectory, config.distDir ?? '.next'); + const serverDistDir = join(distDirectory, 'server'); + const cacheRoot = cacheDirectory ? resolve(appDirectory, cacheDirectory) : join(dirname(dirname(appDirectory)), '.nextjs-cache'); + const cacheRelative = relative(appDirectory, cacheRoot); + if (!cacheRelative || (!isAbsolute(cacheRelative) && !cacheRelative.startsWith('..' + sep) && cacheRelative !== '..')) { + throw new Error(`Next.js cacheDirectory must be outside the component directory: ${cacheRoot}`); + } + const identity = buildIdentity(appDirectory, distDirectory); + const binding: CacheBinding = { + appDirectory, + distDirectory, + serverDistDir, + cacheDirectory: join(cacheRoot, hash(appDirectory), identity), + identity, + FileSystemCache: nativeRequire('next/dist/server/lib/incremental-cache/file-system-cache.js').default, + seedFs: seedFileSystem(nativeRequire('next/dist/server/lib/node-fs-methods.js').nodeFs, serverDistDir), + }; + const previous = bindings.get(serverDistDir); + if (previous && (previous.identity !== identity || previous.cacheDirectory !== binding.cacheDirectory)) { + throw new Error(`A different Next.js build is already bound in this worker: ${appDirectory}; restart the worker`); + } + mkdirSync(join(binding.cacheDirectory, 'server'), { recursive: true }); + bindings.set(serverDistDir, Object.freeze(binding)); + return binding; +} + +export function assertVersionedCacheBuild(binding: CacheBinding): void { + if (buildIdentity(binding.appDirectory, binding.distDirectory) !== binding.identity) { + throw new Error(`Next.js build changed while starting ${binding.appDirectory}; restart the application`); + } +} + +export function getCacheBinding(serverDistDir: string): CacheBinding | undefined { + return bindings.get(serverDistDir); +} + +export function markProductionCache(serverDistDir: string): void { + productionContexts.add(resolve(serverDistDir)); +} + +export function isProductionCache(serverDistDir: string): boolean { + return productionContexts.has(serverDistDir); +} + +export function stockCacheConstructor(serverDistDir: string): typeof FileSystemCache { + const cached = fallbackConstructors.get(serverDistDir); + if (cached) return cached; + const constructor = createRequire(join(serverDistDir, 'package.json'))('next/dist/server/lib/incremental-cache/file-system-cache.js').default as typeof FileSystemCache; + fallbackConstructors.set(serverDistDir, constructor); + return constructor; +} + +export async function sweepVersionedCache(binding: CacheBinding): Promise { + let deleted = 0; + const appCache = dirname(binding.cacheDirectory); + const entries = await readdir(appCache, { withFileTypes: true }); + if (buildIdentity(binding.appDirectory, binding.distDirectory) !== binding.identity) return 0; + for (const entry of entries) { + if (entry.isDirectory() && /^[a-f0-9]{64}$/.test(entry.name) && entry.name !== binding.identity) { + await rm(join(appCache, entry.name), { recursive: true, force: true }); + deleted++; + } + } + return deleted; +} diff --git a/src/withHarper.cts b/src/withHarper.cts index e0e394a..d50aa02 100644 --- a/src/withHarper.cts +++ b/src/withHarper.cts @@ -12,6 +12,10 @@ export function cacheHandlerPath(configDir: string): string { return join(configDir, 'node_modules', '@harperfast', 'nextjs', 'dist', 'CacheHandler.cjs'); } +export function versionedCacheHandlerPath(configDir: string): string { + return join(configDir, 'node_modules', '@harperfast', 'nextjs', 'dist', 'VersionedCacheHandler.cjs'); +} + /** * Returns the path to the Harper "use cache" handler, resolved the same way as `cacheHandlerPath`. * diff --git a/src/withHarper.test.ts b/src/withHarper.test.ts index cb46b55..2eaab13 100644 --- a/src/withHarper.test.ts +++ b/src/withHarper.test.ts @@ -2,13 +2,19 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; import { createRequire } from 'node:module'; -const { withHarper, cacheHandlerPath, useCacheHandlerPath } = createRequire(import.meta.url)('./withHarper.cjs') as { +const { withHarper, cacheHandlerPath, useCacheHandlerPath, versionedCacheHandlerPath } = createRequire(import.meta.url)('./withHarper.cjs') as { withHarper(config?: Record, options?: { useCache?: boolean; configDir?: string }): Record; cacheHandlerPath(configDir: string): string; useCacheHandlerPath(configDir: string): string; + versionedCacheHandlerPath(configDir: string): string; }; describe('handler paths', () => { + it('resolves filesystem isolation independently of the Harper-backed handlers', () => { + assert.match(versionedCacheHandlerPath('/app'), /node_modules.*@harperfast.*nextjs.*dist.*VersionedCacheHandler\.cjs$/); + assert.notEqual(versionedCacheHandlerPath('/app'), cacheHandlerPath('/app')); + assert.notEqual(versionedCacheHandlerPath('/app'), useCacheHandlerPath('/app')); + }); it('resolves the two handlers to different modules', () => { // The ISR handler and the "use cache" handler implement different Next.js interfaces; pointing // `cacheHandlers` at the ISR one would silently do nothing. From d1cab8a103730b4e05a0b701f4e4ea42a374e11c Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 8 Oct 2026 16:51:33 -0600 Subject: [PATCH 2/6] fix: preserve cache invalidation and validate startup bindings Persist key ownership before disk writes so invalidation cannot revive a build seed. Read artifact hashes asynchronously, validate prepared configuration, wire cacheDirectory, and retain namespaces for safe stopped-worker cleanup. Co-Authored-By: OpenAI Codex GPT-6.1 --- README.md | 12 +- .../next-16-versioned-cache.pw.ts | 19 ++- src/DESIGN.md | 6 +- src/VersionedCacheHandler.cts | 26 +++- src/VersionedCacheHandler.test.ts | 120 ++++++++++++------ src/plugin.ts | 25 ++-- src/versionedCache.cts | 87 ++++++------- 7 files changed, 182 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index fed221a..4b90ed5 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,10 @@ When enabled, the plugin will look for an existing `.next` directory and skip th Build the Next.js application and then exit (including shutting down Harper). Defaults to `false`. +### `cacheDirectory: string` + +Root for the opt-in [versioned filesystem cache](#versioned-filesystem-cache), outside the component directory. Defaults to `/.nextjs-cache` for the standard `/components/` layout. Set an absolute path for other layouts or a separate volume; relative paths resolve from the app directory and must still point outside it. + ### `port: number` Specify a custom HTTP port for the Next.js server. Defaults to the Harper default port (`9926`). @@ -211,9 +215,9 @@ Each production worker captures its build identity before serving. Runtime HTML, ~/harper/.nextjs-cache/// ``` -The identity includes `BUILD_ID` and a digest of the build artifacts, so changing an artifact still separates its cache when `generateBuildId` returns the same ID. Cache contents survive worker restarts and are shared by workers of that app/build on the same filesystem. This is local disk storage; it does not replicate entries or tag invalidations across cluster nodes. Next.js's installed filesystem-cache implementation supplies the cache formats and invalidation behavior. +The identity includes `BUILD_ID` and a digest of the build artifacts, so changing an artifact still separates its cache when `generateBuildId` returns the same ID. Startup reads those files asynchronously twice, before and after Next prepares; its I/O cost grows with build size. The build and runtime configuration must both select this handler with the default `.next` output directory; a mismatch fails startup. Cache contents survive worker restarts and are shared by workers of that app/build on the same filesystem. This is local disk storage; it does not replicate entries across cluster nodes or broadcast tag invalidations between workers or nodes. Next.js's installed filesystem-cache implementation supplies the cache formats and invalidation behavior. -Set `cacheMaxMemorySize: 0` in every app sharing a physical Next.js installation, then restart the workers. Next.js's process-local LRU uses unqualified keys and, once another app initializes it, can also be used by an app configured with zero memory. This handler isolates disk caches, not that shared LRU. Disk caching remains enabled. Development and builds outside the Harper plugin retain Next.js's normal filesystem behavior. +Set `cacheMaxMemorySize: 0` in every app sharing a physical Next.js installation, then restart the workers. Next.js's process-local LRU uses unqualified keys and, once another app initializes it, can also be used by an app configured with zero memory. For a single app using its own Next installation, you can omit that setting and keep Next's memory-cache default. This handler isolates disk caches, not that shared LRU. Disk caching remains enabled. Development and builds outside the Harper plugin retain Next.js's normal filesystem behavior. For a custom component layout or a separate cache volume, set the plugin's `cacheDirectory` in `config.yaml` to an absolute path outside the component directory: @@ -223,11 +227,11 @@ For a custom component layout or a separate cache volume, set the plugin's `cach cacheDirectory: /var/cache/harper-nextjs ``` -Build seeds remain read-only in `.next`; whole-entry misses can read them without combining their files with partial runtime entries. Legacy `.next/server/route-cache` entries are ignored. Enable the handler on outgoing workers before subsequent rolling deployments. On Next.js 14/15, the first migration from stock caching needs those old workers stopped before replacing the app, because their runtime writes share paths with build seeds. Existing contaminated build seeds require a clean deployment. +Build seeds remain read-only in `.next`; cold whole-entry misses can read them without combining their files with partial runtime entries. Once a runtime write takes ownership of a key, a persisted marker prevents invalidated or missing entries from falling back to an older seed; Next.js must regenerate them. Legacy `.next/server/route-cache` entries are ignored. Enable the handler on outgoing workers before subsequent rolling deployments. On Next.js 14/15, the first migration from stock caching needs those old workers stopped before replacing the app, because their runtime writes share paths with build seeds. Existing contaminated build seeds require a clean deployment. This isolates incremental-cache writes. It does not isolate arbitrary app-code reads after a directory swap, move image-optimization caches, or change the Harper-backed and `'use cache'` handlers. Builds must contain regular files and directories under `.next/server`. -Namespaces are retained by default, including after dropping an app. Plan disk capacity or remove unused namespaces while their workers are stopped. `HARPER_NEXTJS_SWEEP_OLD_BUILDS=true` also enables a filesystem sweep after the existing five-minute delay: a worker still on the live build removes older namespaces of its app. Cache-only removal can cost extra renders in older workers; their writes recreate their own namespace and cannot enter the new build's cache. +Namespaces are retained, including after dropping an app. Plan disk capacity and remove unused namespaces while their workers are stopped. There is no automatic cache sweep: removing ownership markers while a worker is serving could revive older seeds after tag invalidation. The existing `HARPER_NEXTJS_SWEEP_OLD_BUILDS` option continues to control static-build cleanup only. ## Caching (Work In Progress) diff --git a/integrationTests/next-16-versioned-cache.pw.ts b/integrationTests/next-16-versioned-cache.pw.ts index 59f7f72..a4c4587 100644 --- a/integrationTests/next-16-versioned-cache.pw.ts +++ b/integrationTests/next-16-versioned-cache.pw.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { createHarperContext, setupHarperWithFixture, startHarper, killHarper, teardownHarper, type StartedHarperTestContext } from '@harperfast/integration-testing'; +import { createHarperContext, startHarper, killHarper, teardownHarper, type StartedHarperTestContext } from '@harperfast/integration-testing'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import { mkdtemp, cp, mkdir, readFile, writeFile, readdir, rename, rm } from 'node:fs/promises'; @@ -37,7 +37,10 @@ function nonce(html: string): string { test('an old regeneration cannot pollute the next release, and regenerated pages survive restarts', async ({ request }) => { test.setTimeout(300_000); const gate = await mkdtemp(join(tmpdir(), 'next-cache-render-gate-')); + const dataRootDir = await mkdtemp(join(tmpdir(), 'next-cache-deployment-')); + const cacheDirectory = join(dataRootDir, 'cache-by-build'); const context = createHarperContext(fixtureName); + context.harper = { dataRootDir }; const options = { harperBinPath: join(dirname(require.resolve('harper')), 'bin', 'harper.js'), startupTimeoutMs: 120_000, @@ -45,13 +48,16 @@ test('an old regeneration cannot pollute the next release, and regenerated pages HARPER_NEXTJS_CACHE_GATE: gate, ...(process.env.HARPER_NEXTJS_CACHE_BASELINE && { HARPER_NEXTJS_CACHE_BASELINE: process.env.HARPER_NEXTJS_CACHE_BASELINE }), }, - config: { applications: { lockdown: 'none', moduleLoader: 'none', dependencyLoader: 'native', allowedDirectory: 'any' } }, + config: { threads: { count: 1 }, applications: { lockdown: 'none', moduleLoader: 'none', dependencyLoader: 'native', allowedDirectory: 'any' } }, }; let started: StartedHarperTestContext | undefined; try { - started = await setupHarperWithFixture(context, join(import.meta.dirname, '..', 'fixtures', fixtureName), options); - const { dataRootDir, httpURL } = started.harper; const app = join(dataRootDir, 'components', fixtureName); + await cp(join(import.meta.dirname, '..', 'fixtures', fixtureName), app, { recursive: true, dereference: true }); + const configFile = join(app, 'config.yaml'); + await writeFile(configFile, await readFile(configFile, 'utf8') + ` cacheDirectory: ${JSON.stringify(cacheDirectory)}\n`); + started = await startHarper(context, options); + const { httpURL } = started.harper; const candidate = join(dataRootDir, 'candidate'); await cp(app, candidate, { recursive: true, dereference: true, filter: (file) => file !== join(app, '.next') }); await writeFile(join(candidate, 'release.mjs'), "export const release = 'v2';\n"); @@ -71,7 +77,7 @@ test('an old regeneration cannot pollute the next release, and regenerated pages await writeFile(join(gate, 'release'), ''); expect(await (await held).text()).toContain('

v1

'); const baseline = process.env.HARPER_NEXTJS_CACHE_BASELINE === '1'; - const writeRoot = baseline ? join(app, '.next', 'server', 'route-cache') : join(dataRootDir, '.nextjs-cache'); + const writeRoot = baseline ? join(app, '.next', 'server', 'route-cache') : cacheDirectory; await expect.poll(async () => (await htmlFiles(writeRoot)).some((html) => html.includes('

v1

'))).toBe(true); await killHarper(started); @@ -86,7 +92,7 @@ test('an old regeneration cannot pollute the next release, and regenerated pages regenerated = nonce(await (await request.get(httpURL)).text()); return regenerated; }).not.toBe(seedNonce); - await expect.poll(async () => (await htmlFiles(join(dataRootDir, '.nextjs-cache'))).some((html) => html.includes(regenerated))).toBe(true); + await expect.poll(async () => (await htmlFiles(cacheDirectory)).some((html) => html.includes(regenerated))).toBe(true); await killHarper(started); started = await startHarper(context, options); const afterRestart = await request.get(httpURL); @@ -95,6 +101,7 @@ test('an old regeneration cannot pollute the next release, and regenerated pages } finally { await writeFile(join(gate, 'release'), '').catch(() => {}); if (started) await teardownHarper(started); + await rm(dataRootDir, { recursive: true, force: true }); await rm(gate, { recursive: true, force: true }); } }); diff --git a/src/DESIGN.md b/src/DESIGN.md index b858d30..7da1faf 100644 --- a/src/DESIGN.md +++ b/src/DESIGN.md @@ -4,8 +4,12 @@ `VersionedCacheHandler.cts` writes through the captured runtime delegate. A whole-entry miss can use a second delegate whose filesystem cannot write or read legacy `server/route-cache`. Per-file fallback could mix runtime HTML with seed RSC. Seed reads still use the component pathname; this is cache-write isolation, not arbitrary application-file read isolation. +A native cache null can mean tag invalidation, not only missing files. Before any disk-backed runtime write, the handler persists key ownership under `entries/{fetch,render}/` in its namespace. A null for an owned key stays null, including after worker restart or a partial/failed write; it never revives an older seed whose tags differ. Marker failure prevents the runtime write. Separate fetch/render key spaces avoid collisions between Next's data-cache hashes and route names. Memory-only writes do not create disk markers, and seed delegates preserve the caller's flush setting. + The artifact digest includes build seeds, manifests, compiled server files and Next package metadata; it excludes the legacy route-cache directory. These build files stay immutable when this handler owns runtime writes, so restart identity is stable. This also handles a reused BUILD_ID; Next15/16 preview IDs alone would not, because they can be cached across builds. On 14/15, outgoing stock workers can still mutate seeds during the initial migration. -The optional sweep deletes disposable cache namespaces, never app artifacts. Outgoing workers skip the sweep after their build leaves the live path. Deletion may cost an older worker a render; its captured runtime root is recreated on writes. The default retains namespaces for operator-managed cleanup. +Artifact hashing reads bounded chunks asynchronously before serving and after prepare; it yields between file operations and never runs from a request or cleanup timer. The installed Next constructor is captured before hashing; package metadata uses Node resolution to support a hoisted Next installation. + +Namespaces and ownership markers are retained for operator-managed cleanup after their workers stop. No automatic cache sweep is added: a pre-delete liveness check cannot exclude a concurrent activation, and removing ownership markers while a worker serves can revive older seeds after invalidation. `VersionedCacheHandler.test.ts` exercises installed Next14/15/16 caches against real files and fresh processes. `integrationTests/next-16-versioned-cache.pw.ts` holds a real regeneration across the swap and verifies the new build and persistence after restarts. diff --git a/src/VersionedCacheHandler.cts b/src/VersionedCacheHandler.cts index 456e8f4..740fcfc 100644 --- a/src/VersionedCacheHandler.cts +++ b/src/VersionedCacheHandler.cts @@ -1,4 +1,6 @@ -import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { mkdir, stat, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; import { getCacheBinding, isProductionCache, stockCacheConstructor, type FileSystemCacheContext } from './versionedCache.cjs'; import type FileSystemCache from 'next-16/dist/server/lib/incremental-cache/file-system-cache.js'; @@ -24,15 +26,33 @@ export default class VersionedCacheHandler { async get(...args: Parameters): ReturnType { const entry = await this.runtime.get(...args); if (entry !== null || !this.binding) return entry; + const fetch = args[1]?.kind === 'FETCH' || (args[1] as { kindHint?: string } | undefined)?.kindHint === 'fetch'; + // Native null also means invalidation; a runtime-owned key must not resurrect an older seed. + try { + await stat(this.runtimeEntryFile(args[0], fetch)); + return null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } // Falling back per file could combine a runtime HTML file with a different build seed's RSC. - this.seed ??= new this.binding.FileSystemCache({ ...this.context, fs: this.binding.seedFs, flushToDisk: true }); + this.seed ??= new this.binding.FileSystemCache({ ...this.context, fs: this.binding.seedFs }); return this.seed.get(...args); } - set(...args: Parameters): ReturnType { + async set(...args: Parameters): ReturnType { + if (this.binding && this.context.flushToDisk) { + const fetch = args[1]?.kind === 'FETCH' || (args[2] as { fetchCache?: boolean } | undefined)?.fetchCache === true; + const file = this.runtimeEntryFile(args[0], fetch); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, ''); + } return this.runtime.set(...args); } + private runtimeEntryFile(key: string, fetch: boolean): string { + return join(this.binding!.cacheDirectory, 'entries', fetch ? 'fetch' : 'render', createHash('sha256').update(key).digest('hex')); + } + revalidateTag(...args: Parameters): ReturnType { return this.runtime.revalidateTag(...args); } diff --git a/src/VersionedCacheHandler.test.ts b/src/VersionedCacheHandler.test.ts index 5a3ebf4..5136677 100644 --- a/src/VersionedCacheHandler.test.ts +++ b/src/VersionedCacheHandler.test.ts @@ -1,7 +1,7 @@ import { describe, it, type TestContext } from 'node:test'; import assert from 'node:assert'; import { createRequire } from 'node:module'; -import { mkdtemp, mkdir, writeFile, readFile, rename, rm, symlink } from 'node:fs/promises'; +import { mkdtemp, mkdir, writeFile, readFile, readdir, rename, rm, symlink } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -10,7 +10,7 @@ import { promisify } from 'node:util'; import type { FileSystemCacheContext } from './versionedCache.cjs'; const require = createRequire(import.meta.url); -const { bindVersionedCache, assertVersionedCacheBuild, sweepVersionedCache, markProductionCache } = require('./versionedCache.cjs') as typeof import('./versionedCache.cjs'); +const { bindVersionedCache, assertVersionedCacheBuild, assertVersionedCacheConfig, markProductionCache } = require('./versionedCache.cjs') as typeof import('./versionedCache.cjs'); const Handler = require('./VersionedCacheHandler.cjs').default as typeof import('./VersionedCacheHandler.cjs').default; const pluginDirectory = dirname(import.meta.dirname); const exec = promisify(execFile); @@ -90,10 +90,10 @@ async function inFreshWorker(app: string, version: string, set?: string) { const appRequire = createRequire(join(app, 'package.json')); const { bindVersionedCache } = require(join(plugin, 'dist/versionedCache.cjs')); const Handler = require(join(plugin, 'dist/VersionedCacheHandler.cjs')).default; - const binding = bindVersionedCache(app); const ctx = { fs: appRequire('next/dist/server/lib/node-fs-methods.js').nodeFs, serverDistDir: join(app,'.next/server'), flushToDisk:true, maxMemoryCacheSize:0, revalidatedTags:[], experimental:{ppr:false}, appDir:true, pagesDir:true }; const getCtx = { kind:'APP_PAGE', kindHint:'app', isFallback:false, isRoutePPREnabled:false }; (async () => { + const binding = await bindVersionedCache(app); const cache = new Handler(ctx); if (html) await cache.set(key, version === 'next-14' ? {kind:'PAGE',html,pageData:html,headers:{'x-test':html}} : {kind:'APP_PAGE',html,rscData:Buffer.from(html),headers:{'x-test':html}}, getCtx); const entry = await cache.get(key,getCtx); @@ -107,7 +107,7 @@ async function inFreshWorker(app: string, version: string, set?: string) { for (const version of versions) describe(`versioned filesystem cache (${version})`, () => { it('pins late writes to the old build and persists the new cache across worker restarts', async (t) => { const { root, app } = await temporaryApp(t, version); - const oldBinding = bindVersionedCache(app)!; + const oldBinding = (await bindVersionedCache(app))!; const old = new Handler(context(app)); await old.set(pageKey(version), pageValue(version, 'v1 early') as never, pageContext as never); await rename(app, join(root, 'aside')); @@ -122,9 +122,8 @@ for (const version of versions) describe(`versioned filesystem cache (${version} assert.notEqual(fresh.directory, oldBinding.cacheDirectory, 'reusing BUILD_ID must not merge different artifacts'); assert.equal(existsSync(join(app, '.next', 'server', 'route-cache')), false); assert.equal(existsSync(join(app, '.next', 'server', 'app', 'isr.html')), false); - assert.throws(() => assertVersionedCacheBuild(oldBinding), /build changed/); - assert.throws(() => bindVersionedCache(app), /restart the worker/); - assert.equal(await sweepVersionedCache(oldBinding), 0, 'an outgoing worker must not sweep the new build'); + await assert.rejects(() => assertVersionedCacheBuild(oldBinding), /build changed/); + await assert.rejects(() => bindVersionedCache(app), /restart the worker/); await rm(oldBinding.cacheDirectory, { recursive: true }); await old.set(pageKey(version), pageValue(version, 'v1 recreated') as never, pageContext as never); assert.equal(await pageHTML(anotherOldRequest, pageKey(version)), 'v1 recreated'); @@ -135,8 +134,8 @@ for (const version of versions) describe(`versioned filesystem cache (${version} const { root, app } = await temporaryApp(t, version); const secondApp = join(root, 'components', 'second'); await createApp(secondApp, version, 'v1'); - const firstBinding = bindVersionedCache(app)!; - const secondBinding = bindVersionedCache(secondApp)!; + const firstBinding = (await bindVersionedCache(app))!; + const secondBinding = (await bindVersionedCache(secondApp))!; assert.strictEqual(firstBinding.FileSystemCache, secondBinding.FileSystemCache); const first = new Handler(context(app)); const second = new Handler(context(secondApp)); @@ -150,7 +149,7 @@ for (const version of versions) describe(`versioned filesystem cache (${version} it('uses immutable build seeds on cold misses without combining partial runtime files', async (t) => { const { app } = await temporaryApp(t, version); await seed(app, version, 'seed'); - const binding = bindVersionedCache(app)!; + const binding = (await bindVersionedCache(app))!; const cache = new Handler(context(app)); assert.equal(await pageHTML(cache, pageKey(version)), 'seed'); const runtimePrimary = version === 'next-16-route-cache' @@ -160,7 +159,7 @@ for (const version of versions) describe(`versioned filesystem cache (${version} await writeFile(runtimePrimary, 'incomplete runtime'); assert.equal(await pageHTML(cache, pageKey(version)), 'seed'); assert.equal(await readFile(join(app, '.next', 'server', 'app', 'isr.html'), 'utf8'), 'seed'); - assertVersionedCacheBuild(binding); + await assertVersionedCacheBuild(binding); }); it('keeps fetch seeds read-only and persists runtime fetches outside the component', async (t) => { @@ -171,14 +170,16 @@ for (const version of versions) describe(`versioned filesystem cache (${version} await new Native(context(app)).set('fetch-key', data, fetchContext); const seedFile = join(app, '.next', 'cache', 'fetch-cache', 'fetch-key'); const original = await readFile(seedFile, 'utf8'); - const binding = bindVersionedCache(app)!; + const binding = (await bindVersionedCache(app))!; const cache = new Handler(context(app)); const result = await cache.get('fetch-key', { ...fetchContext, tags: ['another-tag'] } as never); assert.equal(result?.value?.kind, 'FETCH'); assert.equal(await readFile(seedFile, 'utf8'), original); + const disabled = new Handler({ ...context(app), flushToDisk: false }); + assert.equal(await disabled.get('fetch-key', fetchContext as never), null); await cache.set('fetch-key', { ...data, data: { ...data.data, body: 'runtime' } } as never, fetchContext as never); assert.match(await readFile(join(binding.cacheDirectory, 'cache', 'fetch-cache', 'fetch-key'), 'utf8'), /runtime/); - assertVersionedCacheBuild(binding); + await assertVersionedCacheBuild(binding); }); it('leaves external builds and development on the stock filesystem when unbound', async (t) => { @@ -190,7 +191,7 @@ for (const version of versions) describe(`versioned filesystem cache (${version} it('persists Pages Router data and route-handler bodies in the namespace', async (t) => { const { app } = await temporaryApp(t, version); - const binding = bindVersionedCache(app)!; + const binding = (await bindVersionedCache(app))!; const cache = new Handler(context(app)); const pages = { kind: version === 'next-14' ? 'PAGE' : 'PAGES', html: 'pages', pageData: { release: 'v1' }, headers: { 'x-test': 'pages' } }; const pagesContext = { kind: 'PAGES', kindHint: 'pages', isFallback: false }; @@ -212,12 +213,12 @@ for (const version of versions) describe(`versioned filesystem cache (${version} assert.equal(body.value.body.toString(), 'route body'); assert.equal(existsSync(join(app, '.next', 'server', 'pages')), false); assert.equal(existsSync(join(app, '.next', 'server', 'app', 'route.body')), false); - assertVersionedCacheBuild(binding); + await assertVersionedCacheBuild(binding); }); it('preserves the installed Next version\'s tag expiration', async (t) => { const { app } = await temporaryApp(t, version); - bindVersionedCache(app); + await bindVersionedCache(app); const cache = new Handler(context(app)); const tag = app; const value = { ...pageValue(version, 'tagged'), headers: { 'x-next-cache-tags': tag } }; @@ -227,6 +228,22 @@ for (const version of versions) describe(`versioned filesystem cache (${version} assert.equal(await cache.get(pageKey(version), pageContext), null); cache.resetRequestCache(); }); + + it('does not resurrect build seeds after runtime-only tag invalidation, even in another handler or worker', async (t) => { + const { app } = await temporaryApp(t, version); + await seed(app, version, 'seed'); + const binding = (await bindVersionedCache(app))!; + const cache = new Handler(context(app)); + const tag = app; + await cache.set(pageKey(version), { ...pageValue(version, 'runtime'), headers: { 'x-next-cache-tags': tag } } as never, pageContext as never); + assert.equal(await pageHTML(cache, pageKey(version)), 'runtime'); + await cache.revalidateTag(tag); + assert.equal(await cache.get(pageKey(version), pageContext), null); + assert.equal(await new Handler(context(app)).get(pageKey(version), pageContext), null); + await rm(join(binding.cacheDirectory, 'server'), { recursive: true }); + assert.equal((await inFreshWorker(app, version)).html, undefined, 'a persisted runtime-owned key must not fall back to seeds'); + await assertVersionedCacheBuild(binding); + }); }); describe('versioned cache startup and retention', () => { @@ -236,7 +253,7 @@ describe('versioned cache startup and retention', () => { markProductionCache(ctx.serverDistDir); assert.throws(() => new Handler(ctx), /no production build binding/); assert.doesNotThrow(() => new Handler({ ...ctx, dev: true })); - const binding = bindVersionedCache(app)!; + const binding = (await bindVersionedCache(app))!; assert.ok(Object.isFrozen(binding)); assert.doesNotThrow(() => new Handler(ctx)); const dev = new Handler({ ...ctx, dev: true }); @@ -248,31 +265,69 @@ describe('versioned cache startup and retention', () => { const { app } = await temporaryApp(t, 'next-16'); const file = join(app, '.next', 'required-server-files.json'); await writeFile(file, '{"config":{}}'); - assert.equal(bindVersionedCache(app), undefined); + assert.equal(await bindVersionedCache(app), undefined); await writeFile(file, '{"config":{"cacheHandler":"/custom/handler.cjs"}}'); - assert.equal(bindVersionedCache(app), undefined); + assert.equal(await bindVersionedCache(app), undefined); + }); + + it('rejects build/runtime handler mismatches before serving', async (t) => { + const { app } = await temporaryApp(t, 'next-16'); + const config = { cacheHandler: join(app, 'node_modules', '@harperfast', 'nextjs', 'dist', 'VersionedCacheHandler.cjs') }; + const binding = (await bindVersionedCache(app))!; + assert.doesNotThrow(() => assertVersionedCacheConfig(app, config, binding)); + assert.doesNotThrow(() => assertVersionedCacheConfig(app, {})); + assert.throws(() => assertVersionedCacheConfig(app, config), /configuration mismatch/); + assert.throws(() => assertVersionedCacheConfig(app, {}, binding), /configuration mismatch/); + assert.throws(() => assertVersionedCacheConfig(app, { ...config, distDir: 'other' }, binding), /configuration mismatch/); }); it('rejects a cache root inside the component and reports storage failures at startup', async (t) => { const { root, app } = await temporaryApp(t, 'next-16'); - assert.throws(() => bindVersionedCache(app, 'runtime-cache'), /outside the component/); + await assert.rejects(() => bindVersionedCache(app, 'runtime-cache'), /outside the component/); const blockedRoot = join(root, 'file'); await writeFile(blockedRoot, 'not a directory'); - assert.throws(() => bindVersionedCache(app, blockedRoot), /ENOTDIR/); + await assert.rejects(() => bindVersionedCache(app, blockedRoot), /ENOTDIR/); + }); + + it('prevents unowned runtime writes and seed fallback when ownership storage is inaccessible', async (t) => { + const version = 'next-16'; + const { app } = await temporaryApp(t, version); + await seed(app, version, 'seed'); + const binding = (await bindVersionedCache(app))!; + await writeFile(join(binding.cacheDirectory, 'entries'), 'blocked'); + const cache = new Handler(context(app)); + await assert.rejects(cache.set(pageKey(version), pageValue(version, 'runtime') as never, pageContext as never), /ENOTDIR/); + assert.deepEqual(await readdir(join(binding.cacheDirectory, 'server')), []); + await assert.rejects(cache.get(pageKey(version), pageContext), /ENOTDIR/); + await assertVersionedCacheBuild(binding); }); it('accepts a moved prebuilt artifact whose stored handler path names its build machine', async (t) => { const { app, root } = await temporaryApp(t, 'next-16'); const moved = join(root, 'moved-app'); await rename(app, moved); - assert.ok(bindVersionedCache(moved)); + assert.ok(await bindVersionedCache(moved)); + }); + + it('resolves a hoisted Next installation for both the constructor and build identity', async (t) => { + const version = 'next-16'; + const { app } = await temporaryApp(t, version); + await rm(join(app, 'node_modules', 'next')); + const sharedModules = join(dirname(app), 'node_modules'); + await mkdir(sharedModules); + await symlink(dirname(require.resolve(`${version}/package.json`)), join(sharedModules, 'next'), 'junction'); + const binding = (await bindVersionedCache(app))!; + const cache = new Handler(context(app)); + await cache.set(pageKey(version), pageValue(version, 'hoisted') as never, pageContext as never); + assert.equal(await pageHTML(cache, pageKey(version)), 'hoisted'); + await assertVersionedCacheBuild(binding); }); it('separates builds whose only difference is prerendered seed data', async (t) => { const version = 'next-16-route-cache'; const { app, root } = await temporaryApp(t, version); await seed(app, version, 'old seed'); - const binding = bindVersionedCache(app)!; + const binding = (await bindVersionedCache(app))!; await rename(app, join(root, 'aside')); await createApp(app, version, 'v1'); await seed(app, version, 'new seed'); @@ -281,21 +336,6 @@ describe('versioned cache startup and retention', () => { assert.notEqual(fresh.directory, binding.cacheDirectory); }); - it('sweeps only recognized older namespaces of this app', async (t) => { - const { app, root } = await temporaryApp(t, 'next-16'); - const binding = bindVersionedCache(app)!; - const otherBuild = join(dirname(binding.cacheDirectory), 'a'.repeat(64)); - const otherApp = join(root, '.nextjs-cache', 'unrelated-app', 'b'.repeat(64)); - const unrelated = join(dirname(binding.cacheDirectory), 'keep-me'); - await mkdir(otherBuild, { recursive: true }); - await mkdir(otherApp, { recursive: true }); - await mkdir(unrelated); - assert.equal(await sweepVersionedCache(binding), 1); - assert.equal(existsSync(otherBuild), false); - assert.ok(existsSync(binding.cacheDirectory)); - assert.ok(existsSync(otherApp)); - assert.ok(existsSync(unrelated)); - }); }); it('does not import a legacy route-cache entry or promote a build seed into the component', async (t) => { @@ -304,9 +344,9 @@ it('does not import a legacy route-cache entry or promote a build seed into the await seed(app, version, 'new seed'); const Native = require(`${version}/dist/server/lib/incremental-cache/file-system-cache.js`).default; await new Native(context(app)).set(pageKey(version), pageValue(version, 'legacy old render'), pageContext); - const binding = bindVersionedCache(app)!; + const binding = (await bindVersionedCache(app))!; const cache = new Handler(context(app)); assert.equal(await pageHTML(cache, pageKey(version)), 'new seed'); assert.equal(binding.seedFs.existsSync(join(app, '.next', 'server', pageKey(version) + '.html')), false); - assertVersionedCacheBuild(binding); + await assertVersionedCacheBuild(binding); }); diff --git a/src/plugin.ts b/src/plugin.ts index 508dc2f..881b4da 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -95,6 +95,7 @@ function resolveConfig(scope: Scope): NextPluginConfig { // TODO: Remove type casts when we have more proper plugin option validation from core return { buildOnly: (options.buildOnly as boolean) ?? false, + cacheDirectory: options.cacheDirectory as string, // bundler default is set later in handleApplication() based on the detected Next.js version bundler: options.bundler as Bundler, dev: (options.dev as boolean) ?? false, @@ -407,7 +408,7 @@ async function serve(scope: Scope, config: NextPluginConfig, next: NextPackage) ? createRequire(import.meta.url)('./versionedCache.cjs') as typeof import('./versionedCache.cjs') : undefined; cacheBindings?.markProductionCache(join(scope.directory, '.next', 'server')); - const cacheBinding = cacheBindings?.bindVersionedCache(scope.directory, config.cacheDirectory); + const cacheBinding = await cacheBindings?.bindVersionedCache(scope.directory, config.cacheDirectory); let app; switch (next.version) { @@ -423,26 +424,18 @@ async function serve(scope: Scope, config: NextPluginConfig, next: NextPackage) } await app.prepare(); - if (cacheBindings && cacheBinding) { + if (cacheBindings) { try { - cacheBindings.assertVersionedCacheBuild(cacheBinding); + type Renderer = { getServer(): Promise<{ nextConfig: { cacheHandler?: string; distDir?: string } }> }; + const prepared = app as unknown as { server?: Renderer; renderServer?: Renderer }; + const renderer = next.version === 14 ? prepared.renderServer! : prepared.server!; + const { nextConfig } = await renderer.getServer(); + cacheBindings.assertVersionedCacheConfig(scope.directory, nextConfig, cacheBinding); + if (cacheBinding) await cacheBindings.assertVersionedCacheBuild(cacheBinding); } catch (error) { await app.close(); throw error; } - const workerIndex = - (harper as unknown as { server?: WorkerServer }).server?.workerIndex ?? - (globalThis as { server?: WorkerServer }).server?.workerIndex; - if (oldBuildSweepEnabled() && (workerIndex === undefined || workerIndex === 0)) { - const timer = setTimeout(async () => { - try { - await cacheBindings.sweepVersionedCache(cacheBinding); - } catch (error) { - scope.logger.error?.('Error sweeping versioned Next.js cache: ', error); - } - }, OLD_BUILD_SWEEP_DELAY_MS); - timer.unref(); - } } const requestHandler = app.getRequestHandler(); diff --git a/src/versionedCache.cts b/src/versionedCache.cts index 93cf516..18bd47f 100644 --- a/src/versionedCache.cts +++ b/src/versionedCache.cts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; -import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync } from 'node:fs'; -import { readdir, rm } from 'node:fs/promises'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { open, readdir } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import type FileSystemCache from 'next-16/dist/server/lib/incremental-cache/file-system-cache.js'; import type { CacheFs } from 'next-16/dist/shared/lib/utils.js'; @@ -30,41 +30,41 @@ function hash(value: string): string { return createHash('sha256').update(value).digest('hex'); } -function buildIdentity(appDirectory: string, distDirectory: string): string { +async function buildIdentity(appDirectory: string, distDirectory: string): Promise { const digest = createHash('sha256'); const buffer = Buffer.allocUnsafe(256 * 1024); - const addFile = (file: string) => { - const fd = openSync(file, 'r'); + const addFile = async (file: string) => { + const fd = await open(file, 'r'); try { - digest.update(JSON.stringify([relative(appDirectory, file), fstatSync(fd).size])); + digest.update(JSON.stringify([relative(appDirectory, file), (await fd.stat()).size])); let length: number; - while ((length = readSync(fd, buffer, 0, buffer.length, null)) > 0) digest.update(buffer.subarray(0, length)); + while ((length = (await fd.read(buffer, 0, buffer.length, null)).bytesRead) > 0) digest.update(buffer.subarray(0, length)); } finally { - closeSync(fd); + await fd.close(); } }; - const addDirectory = (directory: string, skip?: string) => { - const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + const addDirectory = async (directory: string, skip?: string) => { + const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); for (const entry of entries) { if (entry.name === skip) continue; const file = join(directory, entry.name); - if (entry.isDirectory()) addDirectory(file); - else if (entry.isFile()) addFile(file); + if (entry.isDirectory()) await addDirectory(file); + else if (entry.isFile()) await addFile(file); else throw new Error(`Versioned Next.js cache requires regular build files: ${file}`); } }; const buildIdFile = join(distDirectory, 'BUILD_ID'); if (!readFileSync(buildIdFile, 'utf8').trim()) throw new Error(`Missing Next.js build identity: ${buildIdFile}`); - addFile(buildIdFile); - addFile(join(appDirectory, 'node_modules', 'next', 'package.json')); + await addFile(buildIdFile); + await addFile(createRequire(join(appDirectory, 'package.json')).resolve('next/package.json')); for (const manifest of ['required-server-files.json', 'prerender-manifest.json', 'routes-manifest.json', 'build-manifest.json', 'app-build-manifest.json']) { const file = join(distDirectory, manifest); - if (existsSync(file)) addFile(file); + if (existsSync(file)) await addFile(file); } - addDirectory(join(distDirectory, 'server'), 'route-cache'); + await addDirectory(join(distDirectory, 'server'), 'route-cache'); const fetchSeeds = join(distDirectory, 'cache', 'fetch-cache'); - if (existsSync(fetchSeeds)) addDirectory(fetchSeeds); + if (existsSync(fetchSeeds)) await addDirectory(fetchSeeds); return digest.digest('hex'); } @@ -88,16 +88,21 @@ function seedFileSystem(fs: CacheFs, serverDistDir: string): CacheFs { }; } -export function bindVersionedCache(appDirectory: string, cacheDirectory?: string): CacheBinding | undefined { +function usesVersionedCache(appDirectory: string, cacheHandler?: string): boolean { + if (!cacheHandler || basename(cacheHandler.replace(/\\/g, '/')) !== 'VersionedCacheHandler.cjs') return false; + const nativeRequire = createRequire(join(appDirectory, 'package.json')); + const handler = join(appDirectory, 'node_modules', '@harperfast', 'nextjs', 'dist', 'VersionedCacheHandler.cjs'); + const packagedHandler = cacheHandler.replace(/\\/g, '/').endsWith('/node_modules/@harperfast/nextjs/dist/VersionedCacheHandler.cjs'); + return packagedHandler || nativeRequire.resolve(resolve(appDirectory, cacheHandler)) === nativeRequire.resolve(handler); +} + +export async function bindVersionedCache(appDirectory: string, cacheDirectory?: string): Promise { appDirectory = resolve(appDirectory); const manifestFile = join(appDirectory, '.next', 'required-server-files.json'); if (!existsSync(manifestFile)) return; const { config } = JSON.parse(readFileSync(manifestFile, 'utf8')); - if (!config?.cacheHandler || basename(config.cacheHandler.replace(/\\/g, '/')) !== 'VersionedCacheHandler.cjs') return; + if (!usesVersionedCache(appDirectory, config?.cacheHandler)) return; const nativeRequire = createRequire(join(appDirectory, 'package.json')); - const handler = join(appDirectory, 'node_modules', '@harperfast', 'nextjs', 'dist', 'VersionedCacheHandler.cjs'); - const packagedHandler = config.cacheHandler.replace(/\\/g, '/').endsWith('/node_modules/@harperfast/nextjs/dist/VersionedCacheHandler.cjs'); - if (!packagedHandler && nativeRequire.resolve(resolve(appDirectory, config.cacheHandler)) !== nativeRequire.resolve(handler)) return; const distDirectory = resolve(appDirectory, config.distDir ?? '.next'); const serverDistDir = join(distDirectory, 'server'); @@ -106,15 +111,17 @@ export function bindVersionedCache(appDirectory: string, cacheDirectory?: string if (!cacheRelative || (!isAbsolute(cacheRelative) && !cacheRelative.startsWith('..' + sep) && cacheRelative !== '..')) { throw new Error(`Next.js cacheDirectory must be outside the component directory: ${cacheRoot}`); } - const identity = buildIdentity(appDirectory, distDirectory); + const FileSystemCache = nativeRequire('next/dist/server/lib/incremental-cache/file-system-cache.js').default; + const seedFs = seedFileSystem(nativeRequire('next/dist/server/lib/node-fs-methods.js').nodeFs, serverDistDir); + const identity = await buildIdentity(appDirectory, distDirectory); const binding: CacheBinding = { appDirectory, distDirectory, serverDistDir, cacheDirectory: join(cacheRoot, hash(appDirectory), identity), identity, - FileSystemCache: nativeRequire('next/dist/server/lib/incremental-cache/file-system-cache.js').default, - seedFs: seedFileSystem(nativeRequire('next/dist/server/lib/node-fs-methods.js').nodeFs, serverDistDir), + FileSystemCache, + seedFs, }; const previous = bindings.get(serverDistDir); if (previous && (previous.identity !== identity || previous.cacheDirectory !== binding.cacheDirectory)) { @@ -125,14 +132,22 @@ export function bindVersionedCache(appDirectory: string, cacheDirectory?: string return binding; } -export function assertVersionedCacheBuild(binding: CacheBinding): void { - if (buildIdentity(binding.appDirectory, binding.distDirectory) !== binding.identity) { +export async function assertVersionedCacheBuild(binding: CacheBinding): Promise { + if (await buildIdentity(binding.appDirectory, binding.distDirectory) !== binding.identity) { throw new Error(`Next.js build changed while starting ${binding.appDirectory}; restart the application`); } } +export function assertVersionedCacheConfig(appDirectory: string, config: { cacheHandler?: string; distDir?: string }, binding?: CacheBinding): void { + const optedIn = usesVersionedCache(appDirectory, config.cacheHandler); + if (optedIn !== Boolean(binding) || (binding && binding.serverDistDir !== join(resolve(appDirectory, config.distDir ?? '.next'), 'server'))) { + throw new Error('Versioned Next.js cache build/runtime configuration mismatch; rebuild the application before serving'); + } + if (optedIn) createRequire(join(appDirectory, 'package.json')).resolve(resolve(appDirectory, config.cacheHandler!)); +} + export function getCacheBinding(serverDistDir: string): CacheBinding | undefined { - return bindings.get(serverDistDir); + return bindings.get(resolve(serverDistDir)); } export function markProductionCache(serverDistDir: string): void { @@ -140,7 +155,7 @@ export function markProductionCache(serverDistDir: string): void { } export function isProductionCache(serverDistDir: string): boolean { - return productionContexts.has(serverDistDir); + return productionContexts.has(resolve(serverDistDir)); } export function stockCacheConstructor(serverDistDir: string): typeof FileSystemCache { @@ -150,17 +165,3 @@ export function stockCacheConstructor(serverDistDir: string): typeof FileSystemC fallbackConstructors.set(serverDistDir, constructor); return constructor; } - -export async function sweepVersionedCache(binding: CacheBinding): Promise { - let deleted = 0; - const appCache = dirname(binding.cacheDirectory); - const entries = await readdir(appCache, { withFileTypes: true }); - if (buildIdentity(binding.appDirectory, binding.distDirectory) !== binding.identity) return 0; - for (const entry of entries) { - if (entry.isDirectory() && /^[a-f0-9]{64}$/.test(entry.name) && entry.name !== binding.identity) { - await rm(join(appCache, entry.name), { recursive: true, force: true }); - deleted++; - } - } - return deleted; -} From b99100b0038d04a157d5b6e580667c193d97eaa6 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 8 Oct 2026 17:52:03 -0600 Subject: [PATCH 3/6] fix: validate versioned cache setup and published types Keep public declarations tied to the installed Next peer, guard startup compatibility, and require an explicit cache root outside the standard component layout. Verify persistence through both native and default Harper loaders and wait for strictly later native tag expiration in tests. Co-Authored-By: OpenAI Codex GPT-6.1 --- README.md | 5 ++--- fixtures/next-16-versioned-cache/.npmrc | 1 + .../next-16-versioned-cache.pw.ts | 19 ++++++++++++++----- src/DESIGN.md | 2 +- src/VersionedCacheHandler.cts | 2 +- src/VersionedCacheHandler.test.ts | 16 +++++++++++++--- src/plugin.ts | 8 +++++--- src/versionedCache.cts | 8 ++++++-- 8 files changed, 43 insertions(+), 18 deletions(-) create mode 100644 fixtures/next-16-versioned-cache/.npmrc diff --git a/README.md b/README.md index 4b90ed5..c249c9a 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,6 @@ import { withHarper, versionedCacheHandlerPath } from '@harperfast/nextjs'; export default withHarper({ cacheHandler: versionedCacheHandlerPath(import.meta.dirname), - cacheMaxMemorySize: 0, }); ``` @@ -227,11 +226,11 @@ For a custom component layout or a separate cache volume, set the plugin's `cach cacheDirectory: /var/cache/harper-nextjs ``` -Build seeds remain read-only in `.next`; cold whole-entry misses can read them without combining their files with partial runtime entries. Once a runtime write takes ownership of a key, a persisted marker prevents invalidated or missing entries from falling back to an older seed; Next.js must regenerate them. Legacy `.next/server/route-cache` entries are ignored. Enable the handler on outgoing workers before subsequent rolling deployments. On Next.js 14/15, the first migration from stock caching needs those old workers stopped before replacing the app, because their runtime writes share paths with build seeds. Existing contaminated build seeds require a clean deployment. +Build seeds remain read-only in `.next`; cold whole-entry misses can read them without combining their files with partial runtime entries. Once a runtime write takes ownership of a key, a persisted marker prevents invalidated or missing entries from falling back to an older seed; Next.js must regenerate them. Legacy `.next/server/route-cache` entries are ignored. Stop outgoing stock-cache workers before the first deployment using this handler: they can overwrite render seeds on Next.js 14/15/16.2 and fetch seeds on all supported versions. Subsequent rolling deployments need the outgoing workers already using this handler. Existing contaminated build seeds require a clean deployment. This isolates incremental-cache writes. It does not isolate arbitrary app-code reads after a directory swap, move image-optimization caches, or change the Harper-backed and `'use cache'` handlers. Builds must contain regular files and directories under `.next/server`. -Namespaces are retained, including after dropping an app. Plan disk capacity and remove unused namespaces while their workers are stopped. There is no automatic cache sweep: removing ownership markers while a worker is serving could revive older seeds after tag invalidation. The existing `HARPER_NEXTJS_SWEEP_OLD_BUILDS` option continues to control static-build cleanup only. +Namespaces are retained, including after dropping an app. Debug startup logs identify the bound cache directory. Plan disk capacity and remove unused namespaces while their workers are stopped. There is no automatic cache sweep: removing ownership markers while a worker is serving could revive older seeds after tag invalidation. The existing `HARPER_NEXTJS_SWEEP_OLD_BUILDS` option continues to control static-build cleanup only. ## Caching (Work In Progress) diff --git a/fixtures/next-16-versioned-cache/.npmrc b/fixtures/next-16-versioned-cache/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/fixtures/next-16-versioned-cache/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/integrationTests/next-16-versioned-cache.pw.ts b/integrationTests/next-16-versioned-cache.pw.ts index a4c4587..dff4341 100644 --- a/integrationTests/next-16-versioned-cache.pw.ts +++ b/integrationTests/next-16-versioned-cache.pw.ts @@ -34,7 +34,12 @@ function nonce(html: string): string { return match![1]; } -test('an old regeneration cannot pollute the next release, and regenerated pages survive restarts', async ({ request }) => { +const loaderModes = [ + ['native', { lockdown: 'none', moduleLoader: 'none', dependencyLoader: 'native', allowedDirectory: 'any' }], + ['default', undefined], +] as const; + +for (const [mode, applications] of loaderModes) test(`an old regeneration cannot pollute the next release, and regenerated pages survive restarts (${mode} loader)`, async ({ request }) => { test.setTimeout(300_000); const gate = await mkdtemp(join(tmpdir(), 'next-cache-render-gate-')); const dataRootDir = await mkdtemp(join(tmpdir(), 'next-cache-deployment-')); @@ -48,7 +53,7 @@ test('an old regeneration cannot pollute the next release, and regenerated pages HARPER_NEXTJS_CACHE_GATE: gate, ...(process.env.HARPER_NEXTJS_CACHE_BASELINE && { HARPER_NEXTJS_CACHE_BASELINE: process.env.HARPER_NEXTJS_CACHE_BASELINE }), }, - config: { threads: { count: 1 }, applications: { lockdown: 'none', moduleLoader: 'none', dependencyLoader: 'native', allowedDirectory: 'any' } }, + config: { threads: { count: 1 }, ...(applications && { applications }) }, }; let started: StartedHarperTestContext | undefined; try { @@ -66,7 +71,8 @@ test('an old regeneration cannot pollute the next release, and regenerated pages cwd: candidate, env: { ...process.env, ...options.env }, timeout: 120_000, maxBuffer: 10 * 1024 * 1024, }); - expect(await (await request.get(httpURL)).text()).toContain('

v1

'); + const initial = await (await request.get(httpURL)).text(); + expect(initial).toContain('

v1

'); await writeFile(join(gate, 'hold'), ''); expect((await request.post(`${httpURL}/api/revalidate`)).status()).toBe(200); const held = request.get(httpURL); @@ -75,10 +81,13 @@ test('an old regeneration cannot pollute the next release, and regenerated pages await rename(app, join(dataRootDir, 'aside', fixtureName)); await rename(candidate, app); await writeFile(join(gate, 'release'), ''); - expect(await (await held).text()).toContain('

v1

'); + const late = await (await held).text(); + expect(late).toContain('

v1

'); + const lateNonce = nonce(late); + expect(lateNonce).not.toBe(nonce(initial)); const baseline = process.env.HARPER_NEXTJS_CACHE_BASELINE === '1'; const writeRoot = baseline ? join(app, '.next', 'server', 'route-cache') : cacheDirectory; - await expect.poll(async () => (await htmlFiles(writeRoot)).some((html) => html.includes('

v1

'))).toBe(true); + await expect.poll(async () => (await htmlFiles(writeRoot)).some((html) => html.includes(lateNonce))).toBe(true); await killHarper(started); started = await startHarper(context, options); diff --git a/src/DESIGN.md b/src/DESIGN.md index 7da1faf..e1ce59a 100644 --- a/src/DESIGN.md +++ b/src/DESIGN.md @@ -6,7 +6,7 @@ A native cache null can mean tag invalidation, not only missing files. Before any disk-backed runtime write, the handler persists key ownership under `entries/{fetch,render}/` in its namespace. A null for an owned key stays null, including after worker restart or a partial/failed write; it never revives an older seed whose tags differ. Marker failure prevents the runtime write. Separate fetch/render key spaces avoid collisions between Next's data-cache hashes and route names. Memory-only writes do not create disk markers, and seed delegates preserve the caller's flush setting. -The artifact digest includes build seeds, manifests, compiled server files and Next package metadata; it excludes the legacy route-cache directory. These build files stay immutable when this handler owns runtime writes, so restart identity is stable. This also handles a reused BUILD_ID; Next15/16 preview IDs alone would not, because they can be cached across builds. On 14/15, outgoing stock workers can still mutate seeds during the initial migration. +The artifact digest includes build seeds, manifests, compiled server files and Next package metadata; it excludes the legacy route-cache directory. These build files stay immutable when this handler owns runtime writes, so restart identity is stable. This also handles a reused BUILD_ID; Next15/16 preview IDs alone would not, because they can be cached across builds. Outgoing stock workers can mutate render seeds on 14/15/16.2 and fetch seeds on every supported version, so the initial migration stops them before replacing the app. Artifact hashing reads bounded chunks asynchronously before serving and after prepare; it yields between file operations and never runs from a request or cleanup timer. The installed Next constructor is captured before hashing; package metadata uses Node resolution to support a hoisted Next installation. diff --git a/src/VersionedCacheHandler.cts b/src/VersionedCacheHandler.cts index 740fcfc..34195cd 100644 --- a/src/VersionedCacheHandler.cts +++ b/src/VersionedCacheHandler.cts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { mkdir, stat, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { getCacheBinding, isProductionCache, stockCacheConstructor, type FileSystemCacheContext } from './versionedCache.cjs'; -import type FileSystemCache from 'next-16/dist/server/lib/incremental-cache/file-system-cache.js'; +import type FileSystemCache from 'next/dist/server/lib/incremental-cache/file-system-cache.js'; export default class VersionedCacheHandler { private runtime: FileSystemCache; diff --git a/src/VersionedCacheHandler.test.ts b/src/VersionedCacheHandler.test.ts index 5136677..8b5b725 100644 --- a/src/VersionedCacheHandler.test.ts +++ b/src/VersionedCacheHandler.test.ts @@ -7,6 +7,7 @@ import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; +import { setImmediate } from 'node:timers/promises'; import type { FileSystemCacheContext } from './versionedCache.cjs'; const require = createRequire(import.meta.url); @@ -57,6 +58,14 @@ async function pageHTML(cache: InstanceType, key: string): Promi return entry.value.html; } +async function invalidateWrittenTag(cache: InstanceType, key: string, tag: string) { + const entry = await cache.get(key, pageContext); + assert.ok(entry); + // Next 16.3 expires tags only when invalidation is strictly later than the write. + while (Date.now() <= entry.lastModified) await setImmediate(); + await cache.revalidateTag(tag); +} + function pageKey(version: string): string { return version === 'next-16-route-cache' ? require(`${version}/dist/server/lib/route-cache-key.js`).getRouteCacheKey('/isr', { kind: 'APP_PAGE', sourceRoute: '/isr/page' }) @@ -224,7 +233,7 @@ for (const version of versions) describe(`versioned filesystem cache (${version} const value = { ...pageValue(version, 'tagged'), headers: { 'x-next-cache-tags': tag } }; await cache.set(pageKey(version), value as never, pageContext as never); assert.equal(await pageHTML(cache, pageKey(version)), 'tagged'); - await cache.revalidateTag(tag); + await invalidateWrittenTag(cache, pageKey(version), tag); assert.equal(await cache.get(pageKey(version), pageContext), null); cache.resetRequestCache(); }); @@ -237,7 +246,7 @@ for (const version of versions) describe(`versioned filesystem cache (${version} const tag = app; await cache.set(pageKey(version), { ...pageValue(version, 'runtime'), headers: { 'x-next-cache-tags': tag } } as never, pageContext as never); assert.equal(await pageHTML(cache, pageKey(version)), 'runtime'); - await cache.revalidateTag(tag); + await invalidateWrittenTag(cache, pageKey(version), tag); assert.equal(await cache.get(pageKey(version), pageContext), null); assert.equal(await new Handler(context(app)).get(pageKey(version), pageContext), null); await rm(join(binding.cacheDirectory, 'server'), { recursive: true }); @@ -306,7 +315,8 @@ describe('versioned cache startup and retention', () => { const { app, root } = await temporaryApp(t, 'next-16'); const moved = join(root, 'moved-app'); await rename(app, moved); - assert.ok(await bindVersionedCache(moved)); + await assert.rejects(bindVersionedCache(moved), /Set cacheDirectory.*components/); + assert.ok(await bindVersionedCache(moved, join(root, '.nextjs-cache'))); }); it('resolves a hoisted Next installation for both the constructor and build identity', async (t) => { diff --git a/src/plugin.ts b/src/plugin.ts index 881b4da..c4364f5 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -409,6 +409,7 @@ async function serve(scope: Scope, config: NextPluginConfig, next: NextPackage) : undefined; cacheBindings?.markProductionCache(join(scope.directory, '.next', 'server')); const cacheBinding = await cacheBindings?.bindVersionedCache(scope.directory, config.cacheDirectory); + if (cacheBinding) scope.logger.debug?.(`Using Next.js versioned cache at ${cacheBinding.cacheDirectory}`); let app; switch (next.version) { @@ -429,11 +430,12 @@ async function serve(scope: Scope, config: NextPluginConfig, next: NextPackage) type Renderer = { getServer(): Promise<{ nextConfig: { cacheHandler?: string; distDir?: string } }> }; const prepared = app as unknown as { server?: Renderer; renderServer?: Renderer }; const renderer = next.version === 14 ? prepared.renderServer! : prepared.server!; - const { nextConfig } = await renderer.getServer(); - cacheBindings.assertVersionedCacheConfig(scope.directory, nextConfig, cacheBinding); + const nextConfig = typeof renderer?.getServer === 'function' ? (await renderer.getServer()).nextConfig : undefined; + if (nextConfig) cacheBindings.assertVersionedCacheConfig(scope.directory, nextConfig, cacheBinding); + else if (cacheBinding) throw new Error(`Cannot verify versioned cache configuration for Next.js ${next.version}: unsupported server shape`); if (cacheBinding) await cacheBindings.assertVersionedCacheBuild(cacheBinding); } catch (error) { - await app.close(); + await app.close().catch(() => {}); throw error; } } diff --git a/src/versionedCache.cts b/src/versionedCache.cts index 18bd47f..67426f0 100644 --- a/src/versionedCache.cts +++ b/src/versionedCache.cts @@ -3,8 +3,8 @@ import { createRequire } from 'node:module'; import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import { open, readdir } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; -import type FileSystemCache from 'next-16/dist/server/lib/incremental-cache/file-system-cache.js'; -import type { CacheFs } from 'next-16/dist/shared/lib/utils.js'; +import type FileSystemCache from 'next/dist/server/lib/incremental-cache/file-system-cache.js'; +import type { CacheFs } from 'next/dist/shared/lib/utils.js'; export type FileSystemCacheContext = ConstructorParameters[0]; @@ -106,6 +106,9 @@ export async function bindVersionedCache(appDirectory: string, cacheDirectory?: const distDirectory = resolve(appDirectory, config.distDir ?? '.next'); const serverDistDir = join(distDirectory, 'server'); + if (!cacheDirectory && basename(dirname(appDirectory)) !== 'components') { + throw new Error(`Set cacheDirectory for a Next.js app outside /components/: ${appDirectory}`); + } const cacheRoot = cacheDirectory ? resolve(appDirectory, cacheDirectory) : join(dirname(dirname(appDirectory)), '.nextjs-cache'); const cacheRelative = relative(appDirectory, cacheRoot); if (!cacheRelative || (!isAbsolute(cacheRelative) && !cacheRelative.startsWith('..' + sep) && cacheRelative !== '..')) { @@ -159,6 +162,7 @@ export function isProductionCache(serverDistDir: string): boolean { } export function stockCacheConstructor(serverDistDir: string): typeof FileSystemCache { + serverDistDir = resolve(serverDistDir); const cached = fallbackConstructors.get(serverDistDir); if (cached) return cached; const constructor = createRequire(join(serverDistDir, 'package.json'))('next/dist/server/lib/incremental-cache/file-system-cache.js').default as typeof FileSystemCache; From e5e8fe7d491a02ca84b4f91efbb8cb568e569557 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 8 Oct 2026 18:29:50 -0600 Subject: [PATCH 4/6] fix: preserve unrelated custom cache handlers Skip app-local module comparison when the plugin is absent, leaving valid same-named custom handlers unchanged. Verify the guard against real files and clarify native worker-restart persistence, power-loss limits, and regular fetch-seed requirements. Co-Authored-By: OpenAI Codex GPT-6.1 --- README.md | 4 +++- src/DESIGN.md | 2 +- src/VersionedCacheHandler.test.ts | 10 ++++++++++ src/versionedCache.cts | 4 +++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c249c9a..f7d8438 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,9 @@ For a custom component layout or a separate cache volume, set the plugin's `cach Build seeds remain read-only in `.next`; cold whole-entry misses can read them without combining their files with partial runtime entries. Once a runtime write takes ownership of a key, a persisted marker prevents invalidated or missing entries from falling back to an older seed; Next.js must regenerate them. Legacy `.next/server/route-cache` entries are ignored. Stop outgoing stock-cache workers before the first deployment using this handler: they can overwrite render seeds on Next.js 14/15/16.2 and fetch seeds on all supported versions. Subsequent rolling deployments need the outgoing workers already using this handler. Existing contaminated build seeds require a clean deployment. -This isolates incremental-cache writes. It does not isolate arbitrary app-code reads after a directory swap, move image-optimization caches, or change the Harper-backed and `'use cache'` handlers. Builds must contain regular files and directories under `.next/server`. +Persistence covers worker restarts and retains Next.js's unsynced filesystem writes; it does not add power-loss durability. Versioning applies when Harper's plugin serves production; standalone Next.js serving keeps stock caching. + +This isolates incremental-cache writes. It does not isolate arbitrary app-code reads after a directory swap, move image-optimization caches, or change the Harper-backed and `'use cache'` handlers. Build artifacts under `.next/server` and initial `.next/cache/fetch-cache` must contain regular files and directories. Namespaces are retained, including after dropping an app. Debug startup logs identify the bound cache directory. Plan disk capacity and remove unused namespaces while their workers are stopped. There is no automatic cache sweep: removing ownership markers while a worker is serving could revive older seeds after tag invalidation. The existing `HARPER_NEXTJS_SWEEP_OLD_BUILDS` option continues to control static-build cleanup only. diff --git a/src/DESIGN.md b/src/DESIGN.md index e1ce59a..41805a5 100644 --- a/src/DESIGN.md +++ b/src/DESIGN.md @@ -4,7 +4,7 @@ `VersionedCacheHandler.cts` writes through the captured runtime delegate. A whole-entry miss can use a second delegate whose filesystem cannot write or read legacy `server/route-cache`. Per-file fallback could mix runtime HTML with seed RSC. Seed reads still use the component pathname; this is cache-write isolation, not arbitrary application-file read isolation. -A native cache null can mean tag invalidation, not only missing files. Before any disk-backed runtime write, the handler persists key ownership under `entries/{fetch,render}/` in its namespace. A null for an owned key stays null, including after worker restart or a partial/failed write; it never revives an older seed whose tags differ. Marker failure prevents the runtime write. Separate fetch/render key spaces avoid collisions between Next's data-cache hashes and route names. Memory-only writes do not create disk markers, and seed delegates preserve the caller's flush setting. +A native cache null can mean tag invalidation, not only missing files. Before any disk-backed runtime write, the handler persists key ownership under `entries/{fetch,render}/` in its namespace. A null for an owned key stays null, including after worker restart or a partial/failed write; it never revives an older seed whose tags differ. Marker failure prevents the runtime write. Separate fetch/render key spaces avoid collisions between Next's data-cache hashes and route names. Memory-only writes do not create disk markers, and seed delegates preserve the caller's flush setting. This is worker-restart persistence with Next's unsynced filesystem behavior, not fsync-ordered power-loss durability; losing ownership during host failure can permit a same-build seed, without crossing artifact namespaces. The artifact digest includes build seeds, manifests, compiled server files and Next package metadata; it excludes the legacy route-cache directory. These build files stay immutable when this handler owns runtime writes, so restart identity is stable. This also handles a reused BUILD_ID; Next15/16 preview IDs alone would not, because they can be cached across builds. Outgoing stock workers can mutate render seeds on 14/15/16.2 and fetch seeds on every supported version, so the initial migration stops them before replacing the app. diff --git a/src/VersionedCacheHandler.test.ts b/src/VersionedCacheHandler.test.ts index 8b5b725..289603c 100644 --- a/src/VersionedCacheHandler.test.ts +++ b/src/VersionedCacheHandler.test.ts @@ -279,6 +279,16 @@ describe('versioned cache startup and retention', () => { assert.equal(await bindVersionedCache(app), undefined); }); + it('leaves same-named custom handlers untouched without a local plugin installation', async (t) => { + const { app } = await temporaryApp(t, 'next-16'); + await rm(join(app, 'node_modules', '@harperfast', 'nextjs')); + const cacheHandler = join(app, 'VersionedCacheHandler.cjs'); + await writeFile(cacheHandler, 'module.exports = class CustomCache {};'); + await writeFile(join(app, '.next', 'required-server-files.json'), JSON.stringify({ config: { cacheHandler } })); + assert.equal(await bindVersionedCache(app), undefined); + assert.doesNotThrow(() => assertVersionedCacheConfig(app, { cacheHandler })); + }); + it('rejects build/runtime handler mismatches before serving', async (t) => { const { app } = await temporaryApp(t, 'next-16'); const config = { cacheHandler: join(app, 'node_modules', '@harperfast', 'nextjs', 'dist', 'VersionedCacheHandler.cjs') }; diff --git a/src/versionedCache.cts b/src/versionedCache.cts index 67426f0..97d58d9 100644 --- a/src/versionedCache.cts +++ b/src/versionedCache.cts @@ -93,7 +93,9 @@ function usesVersionedCache(appDirectory: string, cacheHandler?: string): boolea const nativeRequire = createRequire(join(appDirectory, 'package.json')); const handler = join(appDirectory, 'node_modules', '@harperfast', 'nextjs', 'dist', 'VersionedCacheHandler.cjs'); const packagedHandler = cacheHandler.replace(/\\/g, '/').endsWith('/node_modules/@harperfast/nextjs/dist/VersionedCacheHandler.cjs'); - return packagedHandler || nativeRequire.resolve(resolve(appDirectory, cacheHandler)) === nativeRequire.resolve(handler); + if (packagedHandler) return true; + if (!existsSync(handler)) return false; + return nativeRequire.resolve(resolve(appDirectory, cacheHandler)) === nativeRequire.resolve(handler); } export async function bindVersionedCache(appDirectory: string, cacheDirectory?: string): Promise { From 0f0c9a9e5d69d61108e2511864361b2429e2ab32 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 8 Oct 2026 19:14:19 -0600 Subject: [PATCH 5/6] fix: pin external cache roots and recognize workspace handlers Co-Authored-By: OpenAI Codex GPT-6.1 --- README.md | 2 ++ src/DESIGN.md | 2 ++ src/VersionedCacheHandler.test.ts | 48 ++++++++++++++++++++++++++++++- src/versionedCache.cts | 32 +++++++++++++++------ 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f7d8438..cb23623 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,8 @@ For a custom component layout or a separate cache volume, set the plugin's `cach Build seeds remain read-only in `.next`; cold whole-entry misses can read them without combining their files with partial runtime entries. Once a runtime write takes ownership of a key, a persisted marker prevents invalidated or missing entries from falling back to an older seed; Next.js must regenerate them. Legacy `.next/server/route-cache` entries are ignored. Stop outgoing stock-cache workers before the first deployment using this handler: they can overwrite render seeds on Next.js 14/15/16.2 and fetch seeds on all supported versions. Subsequent rolling deployments need the outgoing workers already using this handler. Existing contaminated build seeds require a clean deployment. +The plugin resolves existing cache-root symlinks at startup, rejects roots that point inside the component, and captures the resolved external path. Retargeting a configured alias afterward does not redirect an existing worker's cache writes. + Persistence covers worker restarts and retains Next.js's unsynced filesystem writes; it does not add power-loss durability. Versioning applies when Harper's plugin serves production; standalone Next.js serving keeps stock caching. This isolates incremental-cache writes. It does not isolate arbitrary app-code reads after a directory swap, move image-optimization caches, or change the Harper-backed and `'use cache'` handlers. Build artifacts under `.next/server` and initial `.next/cache/fetch-cache` must contain regular files and directories. diff --git a/src/DESIGN.md b/src/DESIGN.md index 41805a5..65102e4 100644 --- a/src/DESIGN.md +++ b/src/DESIGN.md @@ -10,6 +10,8 @@ The artifact digest includes build seeds, manifests, compiled server files and N Artifact hashing reads bounded chunks asynchronously before serving and after prepare; it yields between file operations and never runs from a request or cleanup timer. The installed Next constructor is captured before hashing; package metadata uses Node resolution to support a hoisted Next installation. +The cache root resolves existing ancestors through `realpath` before directory creation; both its configured and physical paths must be outside the component. Capturing that physical root prevents later alias retargeting from redirecting worker writes. Missing descendants remain supported; other filesystem errors propagate at startup. + Namespaces and ownership markers are retained for operator-managed cleanup after their workers stop. No automatic cache sweep is added: a pre-delete liveness check cannot exclude a concurrent activation, and removing ownership markers while a worker serves can revive older seeds after invalidation. `VersionedCacheHandler.test.ts` exercises installed Next14/15/16 caches against real files and fresh processes. `integrationTests/next-16-versioned-cache.pw.ts` holds a real regeneration across the swap and verifies the new build and persistence after restarts. diff --git a/src/VersionedCacheHandler.test.ts b/src/VersionedCacheHandler.test.ts index 289603c..3a2e98c 100644 --- a/src/VersionedCacheHandler.test.ts +++ b/src/VersionedCacheHandler.test.ts @@ -1,7 +1,7 @@ import { describe, it, type TestContext } from 'node:test'; import assert from 'node:assert'; import { createRequire } from 'node:module'; -import { mkdtemp, mkdir, writeFile, readFile, readdir, rename, rm, symlink } from 'node:fs/promises'; +import { mkdtemp, mkdir, writeFile, readFile, readdir, rename, rm, symlink, realpath } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -308,6 +308,33 @@ describe('versioned cache startup and retention', () => { await assert.rejects(() => bindVersionedCache(app, blockedRoot), /ENOTDIR/); }); + it('rejects a cache root that physically aliases the component', async (t) => { + const { root, app } = await temporaryApp(t, 'next-16'); + const alias = join(root, 'cache-alias'); + const server = join(app, '.next', 'server'); + await symlink(server, alias, 'junction'); + await assert.rejects(bindVersionedCache(app, join(alias, 'new-cache')), /outside the component/); + assert.equal(existsSync(join(server, 'new-cache')), false); + }); + + it('pins an external cache root before its alias is retargeted', async (t) => { + const version = 'next-16'; + const { root, app } = await temporaryApp(t, version); + const externalRoot = join(root, 'external-cache'); + const alias = join(root, 'cache-alias'); + await mkdir(externalRoot); + await symlink(externalRoot, alias, 'junction'); + const binding = (await bindVersionedCache(app, join(alias, 'new-cache')))!; + assert.ok(binding.cacheDirectory.startsWith(join(await realpath(externalRoot), 'new-cache'))); + await rm(alias); + await symlink(join(app, '.next', 'server'), alias, 'junction'); + const cache = new Handler(context(app)); + await cache.set(pageKey(version), pageValue(version, 'external') as never, pageContext as never); + assert.equal(await pageHTML(cache, pageKey(version)), 'external'); + assert.equal(existsSync(join(app, '.next', 'server', 'new-cache')), false); + await assertVersionedCacheBuild(binding); + }); + it('prevents unowned runtime writes and seed fallback when ownership storage is inaccessible', async (t) => { const version = 'next-16'; const { app } = await temporaryApp(t, version); @@ -343,6 +370,25 @@ describe('versioned cache startup and retention', () => { await assertVersionedCacheBuild(binding); }); + it('binds the actual handler path when the plugin is linked in a parent workspace', async (t) => { + const version = 'next-16'; + const { app, root } = await temporaryApp(t, version); + await rm(join(app, 'node_modules', '@harperfast', 'nextjs')); + const sharedModules = join(root, 'node_modules', '@harperfast'); + await mkdir(sharedModules, { recursive: true }); + await symlink(pluginDirectory, join(sharedModules, 'nextjs'), 'junction'); + const appRequire = createRequire(join(app, 'package.json')); + const cacheHandler = join(dirname(appRequire.resolve('@harperfast/nextjs')), 'VersionedCacheHandler.cjs'); + await writeFile(join(app, '.next', 'required-server-files.json'), JSON.stringify({ config: { cacheHandler } })); + const binding = (await bindVersionedCache(app))!; + assert.ok(binding); + assert.doesNotThrow(() => assertVersionedCacheConfig(app, { cacheHandler }, binding)); + const cache = new Handler(context(app)); + await cache.set(pageKey(version), pageValue(version, 'workspace') as never, pageContext as never); + assert.equal(await pageHTML(cache, pageKey(version)), 'workspace'); + await assertVersionedCacheBuild(binding); + }); + it('separates builds whose only difference is prerendered seed data', async (t) => { const version = 'next-16-route-cache'; const { app, root } = await temporaryApp(t, version); diff --git a/src/versionedCache.cts b/src/versionedCache.cts index 97d58d9..8bc5052 100644 --- a/src/versionedCache.cts +++ b/src/versionedCache.cts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import { existsSync, mkdirSync, readFileSync } from 'node:fs'; -import { open, readdir } from 'node:fs/promises'; +import { open, readdir, realpath } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import type FileSystemCache from 'next/dist/server/lib/incremental-cache/file-system-cache.js'; import type { CacheFs } from 'next/dist/shared/lib/utils.js'; @@ -91,11 +91,26 @@ function seedFileSystem(fs: CacheFs, serverDistDir: string): CacheFs { function usesVersionedCache(appDirectory: string, cacheHandler?: string): boolean { if (!cacheHandler || basename(cacheHandler.replace(/\\/g, '/')) !== 'VersionedCacheHandler.cjs') return false; const nativeRequire = createRequire(join(appDirectory, 'package.json')); - const handler = join(appDirectory, 'node_modules', '@harperfast', 'nextjs', 'dist', 'VersionedCacheHandler.cjs'); const packagedHandler = cacheHandler.replace(/\\/g, '/').endsWith('/node_modules/@harperfast/nextjs/dist/VersionedCacheHandler.cjs'); if (packagedHandler) return true; - if (!existsSync(handler)) return false; - return nativeRequire.resolve(resolve(appDirectory, cacheHandler)) === nativeRequire.resolve(handler); + return nativeRequire.resolve(resolve(appDirectory, cacheHandler)) === require.resolve('./VersionedCacheHandler.cjs'); +} + +async function resolveCacheRoot(directory: string): Promise { + try { + return await realpath(directory); + } catch (error) { + const parent = dirname(directory); + if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || parent === directory) throw error; + return join(await resolveCacheRoot(parent), basename(directory)); + } +} + +function assertExternalCacheRoot(appDirectory: string, cacheRoot: string): void { + const cacheRelative = relative(appDirectory, cacheRoot); + if (!cacheRelative || (!isAbsolute(cacheRelative) && !cacheRelative.startsWith('..' + sep) && cacheRelative !== '..')) { + throw new Error(`Next.js cacheDirectory must be outside the component directory: ${cacheRoot}`); + } } export async function bindVersionedCache(appDirectory: string, cacheDirectory?: string): Promise { @@ -111,11 +126,10 @@ export async function bindVersionedCache(appDirectory: string, cacheDirectory?: if (!cacheDirectory && basename(dirname(appDirectory)) !== 'components') { throw new Error(`Set cacheDirectory for a Next.js app outside /components/: ${appDirectory}`); } - const cacheRoot = cacheDirectory ? resolve(appDirectory, cacheDirectory) : join(dirname(dirname(appDirectory)), '.nextjs-cache'); - const cacheRelative = relative(appDirectory, cacheRoot); - if (!cacheRelative || (!isAbsolute(cacheRelative) && !cacheRelative.startsWith('..' + sep) && cacheRelative !== '..')) { - throw new Error(`Next.js cacheDirectory must be outside the component directory: ${cacheRoot}`); - } + const requestedRoot = cacheDirectory ? resolve(appDirectory, cacheDirectory) : join(dirname(dirname(appDirectory)), '.nextjs-cache'); + assertExternalCacheRoot(appDirectory, requestedRoot); + const cacheRoot = await resolveCacheRoot(requestedRoot); + assertExternalCacheRoot(await realpath(appDirectory), cacheRoot); const FileSystemCache = nativeRequire('next/dist/server/lib/incremental-cache/file-system-cache.js').default; const seedFs = seedFileSystem(nativeRequire('next/dist/server/lib/node-fs-methods.js').nodeFs, serverDistDir); const identity = await buildIdentity(appDirectory, distDirectory); From 43f02109107a05b721d0d25d97c605befa930dc5 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 8 Oct 2026 19:41:32 -0600 Subject: [PATCH 6/6] fix: ignore vanished unrelated cache handler paths Co-Authored-By: OpenAI Codex GPT-6.1 --- src/VersionedCacheHandler.test.ts | 11 +++++++++++ src/versionedCache.cts | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/VersionedCacheHandler.test.ts b/src/VersionedCacheHandler.test.ts index 3a2e98c..2fe15df 100644 --- a/src/VersionedCacheHandler.test.ts +++ b/src/VersionedCacheHandler.test.ts @@ -289,6 +289,17 @@ describe('versioned cache startup and retention', () => { assert.doesNotThrow(() => assertVersionedCacheConfig(app, { cacheHandler })); }); + it('ignores a vanished build-machine path for an unrelated runtime handler', async (t) => { + const { app, root } = await temporaryApp(t, 'next-16'); + const cacheHandler = join(app, 'VersionedCacheHandler.cjs'); + await writeFile(cacheHandler, 'module.exports = class CustomCache {};'); + await writeFile(join(app, '.next', 'required-server-files.json'), JSON.stringify({ config: { + cacheHandler: join(root, 'build-machine', 'VersionedCacheHandler.cjs'), + } })); + assert.equal(await bindVersionedCache(app), undefined); + assert.doesNotThrow(() => assertVersionedCacheConfig(app, { cacheHandler })); + }); + it('rejects build/runtime handler mismatches before serving', async (t) => { const { app } = await temporaryApp(t, 'next-16'); const config = { cacheHandler: join(app, 'node_modules', '@harperfast', 'nextjs', 'dist', 'VersionedCacheHandler.cjs') }; diff --git a/src/versionedCache.cts b/src/versionedCache.cts index 8bc5052..03d1268 100644 --- a/src/versionedCache.cts +++ b/src/versionedCache.cts @@ -93,7 +93,9 @@ function usesVersionedCache(appDirectory: string, cacheHandler?: string): boolea const nativeRequire = createRequire(join(appDirectory, 'package.json')); const packagedHandler = cacheHandler.replace(/\\/g, '/').endsWith('/node_modules/@harperfast/nextjs/dist/VersionedCacheHandler.cjs'); if (packagedHandler) return true; - return nativeRequire.resolve(resolve(appDirectory, cacheHandler)) === require.resolve('./VersionedCacheHandler.cjs'); + const handler = resolve(appDirectory, cacheHandler); + if (!existsSync(handler)) return false; + return nativeRequire.resolve(handler) === require.resolve('./VersionedCacheHandler.cjs'); } async function resolveCacheRoot(directory: string): Promise {