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..cb23623 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`). @@ -190,6 +194,48 @@ 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), +}); +``` + +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. 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. 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: + +```yaml +'@harperfast/nextjs': + package: '@harperfast/nextjs' + 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. 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. + +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) `@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/.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/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..dff4341 --- /dev/null +++ b/integrationTests/next-16-versioned-cache.pw.ts @@ -0,0 +1,116 @@ +import { test, expect } from '@playwright/test'; +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'; +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]; +} + +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-')); + 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, + env: { + 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 && { applications }) }, + }; + let started: StartedHarperTestContext | undefined; + try { + 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"); + 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, + }); + + 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); + 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'), ''); + 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(lateNonce))).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(cacheDirectory)).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(dataRootDir, { recursive: true, force: true }); + 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..65102e4 --- /dev/null +++ b/src/DESIGN.md @@ -0,0 +1,17 @@ +# 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. + +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. + +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.cts b/src/VersionedCacheHandler.cts new file mode 100644 index 0000000..34195cd --- /dev/null +++ b/src/VersionedCacheHandler.cts @@ -0,0 +1,64 @@ +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/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; + 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 }); + return this.seed.get(...args); + } + + 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); + } + + 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..2fe15df --- /dev/null +++ b/src/VersionedCacheHandler.test.ts @@ -0,0 +1,429 @@ +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, realpath } 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 { setImmediate } from 'node:timers/promises'; +import type { FileSystemCacheContext } from './versionedCache.cjs'; + +const require = createRequire(import.meta.url); +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); +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; +} + +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' }) + : '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 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); + 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 = (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')); + 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); + 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'); + 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 = (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)); + 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 = (await 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'); + await 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 = (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/); + await 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 = (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 }; + 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); + await assertVersionedCacheBuild(binding); + }); + + it('preserves the installed Next version\'s tag expiration', async (t) => { + const { app } = await temporaryApp(t, version); + await 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 invalidateWrittenTag(cache, pageKey(version), tag); + 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 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 }); + 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', () => { + 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 = (await 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(await bindVersionedCache(app), undefined); + await writeFile(file, '{"config":{"cacheHandler":"/custom/handler.cjs"}}'); + 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('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') }; + 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'); + await assert.rejects(() => bindVersionedCache(app, 'runtime-cache'), /outside the component/); + const blockedRoot = join(root, 'file'); + await writeFile(blockedRoot, 'not a directory'); + 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); + 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); + 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) => { + 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('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); + await seed(app, version, 'old seed'); + const binding = (await 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('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 = (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); + await assertVersionedCacheBuild(binding); +}); diff --git a/src/plugin.ts b/src/plugin.ts index d4667e0..c4364f5 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'); @@ -93,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, @@ -401,6 +404,12 @@ 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 = 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) { @@ -416,6 +425,20 @@ async function serve(scope: Scope, config: NextPluginConfig, next: NextPackage) } await app.prepare(); + if (cacheBindings) { + try { + 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 = 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().catch(() => {}); + throw error; + } + } const requestHandler = app.getRequestHandler(); diff --git a/src/versionedCache.cts b/src/versionedCache.cts new file mode 100644 index 0000000..03d1268 --- /dev/null +++ b/src/versionedCache.cts @@ -0,0 +1,189 @@ +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +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'; + +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'); +} + +async function buildIdentity(appDirectory: string, distDirectory: string): Promise { + const digest = createHash('sha256'); + const buffer = Buffer.allocUnsafe(256 * 1024); + const addFile = async (file: string) => { + const fd = await open(file, 'r'); + try { + digest.update(JSON.stringify([relative(appDirectory, file), (await fd.stat()).size])); + let length: number; + while ((length = (await fd.read(buffer, 0, buffer.length, null)).bytesRead) > 0) digest.update(buffer.subarray(0, length)); + } finally { + await fd.close(); + } + }; + 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()) 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}`); + 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)) await addFile(file); + } + await addDirectory(join(distDirectory, 'server'), 'route-cache'); + const fetchSeeds = join(distDirectory, 'cache', 'fetch-cache'); + if (existsSync(fetchSeeds)) await 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 () => {}, + }; +} + +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 packagedHandler = cacheHandler.replace(/\\/g, '/').endsWith('/node_modules/@harperfast/nextjs/dist/VersionedCacheHandler.cjs'); + if (packagedHandler) return true; + const handler = resolve(appDirectory, cacheHandler); + if (!existsSync(handler)) return false; + return nativeRequire.resolve(handler) === 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 { + appDirectory = resolve(appDirectory); + const manifestFile = join(appDirectory, '.next', 'required-server-files.json'); + if (!existsSync(manifestFile)) return; + const { config } = JSON.parse(readFileSync(manifestFile, 'utf8')); + if (!usesVersionedCache(appDirectory, config?.cacheHandler)) return; + const nativeRequire = createRequire(join(appDirectory, 'package.json')); + + 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 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); + const binding: CacheBinding = { + appDirectory, + distDirectory, + serverDistDir, + cacheDirectory: join(cacheRoot, hash(appDirectory), identity), + identity, + FileSystemCache, + seedFs, + }; + 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 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(resolve(serverDistDir)); +} + +export function markProductionCache(serverDistDir: string): void { + productionContexts.add(resolve(serverDistDir)); +} + +export function isProductionCache(serverDistDir: string): boolean { + return productionContexts.has(resolve(serverDistDir)); +} + +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; + fallbackConstructors.set(serverDistDir, constructor); + return constructor; +} 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.