Skip to content

feat: isolate persistent Next.js caches by build - #72

Open
kriszyp wants to merge 6 commits into
mainfrom
fix/versioned-runtime-cache
Open

kriszyp wants to merge 6 commits into
mainfrom
fix/versioned-runtime-cache

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

⊙ Problem

An outgoing Next.js worker can finish an ISR render after Harper replaces components/<app>, writing old HTML into the incoming build's disk cache. A restart then serves that old HTML with stale chunk references. Closes HarperFast/harper#3031.

❓ Your call: Preserve disk persistence and sharing between workers on the same filesystem, as requested; turning off ISR disk writes is smaller but discards regenerated entries across restarts. This opt-in handler can be removed from the app's Next configuration.

💡 Solution

Add versionedCacheHandlerPath() in src/withHarper.cts for an incremental cache backed by each app's installed Next filesystem handler. Production workers capture an app/build-specific directory outside the replaceable component before serving, so an old worker's writes remain in its own cache. Component paths and file-editing APIs stay unchanged.

import { withHarper, versionedCacheHandlerPath } from '@harperfast/nextjs';

export default withHarper({
    cacheHandler: versionedCacheHandlerPath(import.meta.dirname),
});

⚖️ Alternatives

Independent planning review: Framing-Verdict: chosen-approach-sound (d721d889ce0d).

Core versioned component directories would also protect arbitrary app reads but complicate direct editing and component-file APIs. Stopping workers before activation changes rolling availability. isrFlushToDisk: false removes durability; the existing Harper-backed handler changes the storage backend and cluster behavior. The selected folder approach retains Next's cache formats and local disk semantics.

❓ Your call: Keep a separate read-only seed delegate rather than copying seeds into every runtime directory. A real Next 16.3.8 single-delegate proof returned an older copied seed after its tag-invalidated runtime files disappeared. Preserving the owned-key miss rule would still need a read guard; copying also adds initialization coordination and retained seed storage. The selected implementation accepts measured extra I/O on cold seed hits.

❓ Your call: Continue the final artifact review after four full passes: the fourth found two concrete startup-path defects, a workspace-linked handler being skipped and an external cache-root alias resolving into the component. Both repairs were verified and the fifth full pass converged. A final missing-path guard repairs a confirmed compatibility defect within the reviewed opt-in comparison, with a targeted delta review; retain the reviewed architecture and native persistence scope.

🔧 Changes

Startup order

plugin.ts:407

markProductionCache(serverDistDir)
cacheBinding = ⏳ bindVersionedCache(scope.directory, config.cacheDirectory)
⏳ app.prepare()
nextConfig = renderer?.getServer ? (⏳ renderer.getServer()).nextConfig : undefined
if (nextConfig) assertVersionedCacheConfig(scope.directory, nextConfig, cacheBinding)
else if (cacheBinding) ✗ unsupported server shape
if (cacheBinding) ⏳ assertVersionedCacheBuild(cacheBinding)
… attach the request handler

Product and architecture tour

Ownership before serving

How does a worker keep its write destination through a swap?

Production startup binds before preparing Next in src/plugin.ts, then checks the loaded runtime configuration and artifact digest before HTTP attaches at the startup validation. The plugin also types, validates and returns cacheDirectory at option resolution.

src/versionedCache.cts binds the logical server directory to a fixed runtime directory, its installed Next cache constructor and read-only seed filesystem before app.prepare(). A native CommonJS registry bridges the plugin's VM realm and Next's native handler. Rebinding a different artifact in one worker fails; the build is checked again after preparation. Startup checks the prepared renderer's actual configuration against the binding; mismatches fail before HTTP attaches. Missing renderer/config properties are tolerated for unbound apps; bound apps fail closed, while initialization errors still fail startup. Cleanup preserves the original failure. A missing binding in Harper production throws instead of falling back to writes in the app.

Old and new builds keep separate write destinations

The captured directory remains unchanged when the live component path is replaced.

flowchart LR
    A[Old worker] --> B[App hash / old artifact hash]
    C[New worker] --> D[App hash / new artifact hash]
    C -. Cold whole-entry miss .-> E[Read-only build seeds]
Loading

Durable caches and build seeds

What survives restarts without reviving invalidated build output?

The directory is <cacheDirectory>/<app-path-hash>/<build-artifact-hash>, defaulting to ~/harper/.nextjs-cache for the standard component layout. Other layouts must set cacheDirectory; startup gives a clear error. Startup resolves existing cache-root ancestors, rejects configured or physical roots inside the component, and captures the resolved external path so retargeting an alias cannot redirect an existing worker. Missing cache-root descendants are created normally. Debug logs identify each bound directory for stopped-worker cleanup. Hashing BUILD_ID, manifests, server artifacts, initial fetch seeds and Next package metadata also isolates builds that reuse BUILD_ID. Hashing awaits bounded 256 KiB file reads and sorted traversal twice per worker at startup; its I/O cost grows with build size. Requests do not re-read the identity or reload the constructor from the replaced app. A hoisted Next installation is resolved through Node for both metadata and the captured constructor. Handler detection recognizes the plugin’s own canonical path when it is linked from a parent workspace, while an unrelated same-basename custom handler remains unbound.

❓ Your call: Keep two full content checks at startup. BUILD_ID and preview IDs can repeat across different artifacts; replacing the checks with an immutable deployment ID needs a trusted identity contract. The measured startup cost buys automatic separation without that new API.

src/VersionedCacheHandler.cts reads the runtime cache first. Before a disk-backed runtime write, it persists ownership of that key in the captured directory; later native nulls remain misses instead of reviving older seeds with different tags. Ownership storage errors propagate rather than falling back to a seed; marker failures prevent writes. Only unowned whole-entry misses fall back to a separate read-only seed delegate. This prevents combining partial runtime HTML with seed RSC. Legacy server/route-cache entries are excluded; the read-only seed filesystem blocks seed promotion and fetch-tag backfill writes into the app. Writes, tag revalidation and request-cache reset use the installed Next implementation.

⚠️ Look hardest: Seed reads still resolve the component pathname. This fixes incremental-cache writes, not arbitrary app-file reads during a swap, image caches, or the separate Harper-backed and 'use cache' handlers.

❓ Your call: Preserve Next's overlapping-read behavior: a seed read started before ownership or invalidation can finish afterward; subsequent owned-key misses cannot revive seeds. Persistence covers worker restarts with native unsynced writes. Host power loss can discard ownership and allow an older seed from the same build; app/artifact namespaces remain separate. Adding fsync ordering would be a stronger durability contract than the selected native cache. Redeploying a byte-identical artifact deliberately reuses its persistent namespace, including late output; arbitrary pathname reads by outgoing application code can still observe replacement files. This does not promise per-deployment read isolation.

Compatibility and retention

What does rollout require, and who owns cleanup?

The handler is opt-in; default/custom handlers, development and external builds keep their existing paths. The primary example keeps Next's memory default; shared installations must explicitly disable it. It supplies local disk persistence; it does not replicate entries across nodes or broadcast Next's worker-local tag invalidation. Set zero memory in every app sharing a physical Next installation and restart workers, because Next's inherited module-level LRU has unqualified keys. Stop outgoing stock-cache workers before the first migration: render seeds share their paths on 14/15/16.2, and fetch seeds share paths on every supported SDK. Subsequent outgoing workers must already use this handler.

❓ Your call: Retain native local-disk and worker-local tag behavior; applications needing replicated entries or tag convergence can select the existing Harper-backed handler. This API preserves filesystem caching rather than changing that backend.

❓ Your call: Retain cache directories and require stopped-worker cleanup. The proposed timed sweep was removed after review: without an atomic worker-lifetime lease, it can delete active entries and the ownership records preventing stale seed revival. This trades disk capacity for safe persistence; a future cleanup API needs that lifecycle guarantee. The existing static-build sweep is unchanged.

README.md documents activation, artifact/startup constraints, migration, memory requirements and retained-cache cleanup; its option reference explains the default and relative/absolute roots. src/DESIGN.md records constructor binding, ownership, whole-entry seeds, asynchronous identity and retention invariants, indexed from DESIGN.md. No documentation-repository companion is needed: these are plugin-specific APIs documented in this repository.

✅ Verification

Local cache benchmarks use installed Next 16.2, warm filesystem, 4 KiB HTML plus RSC, 1,000 reads and 500 writes, with additional 10,000-hit memory checks. Native/versioned times in milliseconds per operation were 0.266/0.331 for seed disk hits, 0.284/0.241 for runtime disk hits, and 0.266/0.251 for disk writes. Including a constructor per seed request gave 0.260/0.378 ms on disk and 0.0003/0.0044 ms with memory; reused-handler memory hits were 0.0003–0.0005 ms. These local results show extra cold-seed and per-request construction cost; small differences vary with filesystem timing. They isolate cache cost and do not estimate HTTP throughput.

A synthetic startup benchmark uses Next 16.3.8, 1,000 one-KiB files, optionally an additional 256-MiB file, warm filesystem, and both full identity passes in fresh worker threads. With roughly 1 MiB, wall times for 1/4/16 threads were 0.338/0.358/0.677 seconds; with roughly 257 MiB, 1.158/1.082/1.544 seconds. This excludes Next preparation and is a local scaling comparison, not a cold-disk deployment estimate. The full passes detect changed artifacts with reused IDs; a deployment-supplied trusted identity could remove this cost later.

❓ Your call: Keep runtime-first reads and persist ownership before every disk-backed write. A process-wide positive-key set would grow without bound and skip recreating a lost ownership file; marker-first reads would discard valid unmarked runtime files. The extra seed-hit and write I/O is measured, while isolated apps keep Next's default memory cache.

  • npm test: 193 passed (TypeScript build included), Node 26.2.0. src/VersionedCacheHandler.test.ts uses real installed Next 14.2.35, 15.5.15, 16.2.4 and 16.3.8 caches cover late writes, reused build IDs, restarts, same-module app isolation, read-only seeds, partial files, pages/route/fetch data, runtime-only tag expiration with seeds, ownership across fresh workers, storage failures, startup configuration mismatches, hoisted Next resolution, failures and retained namespaces. src/withHarper.test.ts proves the new incremental handler is separate from the existing handlers. The final compatibility guard prevents an unrelated same-basename custom handler from requiring an absent app-local plugin; a real-file regression covers binding and prepared-config checks. Additional real-file tests cover parent-workspace links, physical-root rejection and safe writes after an external alias is retargeted. A final regression proves that an unrelated handler with a vanished build-machine path remains unbound while its valid runtime path passes configuration checks; it failed with MODULE_NOT_FOUND before the guard.
  • HARPER_CLUSTER_REQUIRED=1 npm run test:integration -- --workers=1: 44 passed with freshly installed fixtures, including existing cluster and browser tests.
  • After the final workspace, physical-root and stale-handler fixes, refreshed fixtures and reran npm run test:integration -- integrationTests/next-16-versioned-cache.pw.ts --workers=1: both loader regressions passed. Bound Next14/15 and Turbopack versioned-cache startup are not exercised end-to-end; the actual-cache unit matrix and ordinary version startup tests provide the other coverage.
  • integrationTests/next-16-versioned-cache.pw.ts performs the held render, directory swap, custom-root verification and two restarts against a real Harper. Its fixture package.json pins Next 16.3.8; next.config.mjs selects the handler, disables shared memory and deliberately reuses BUILD_ID, with a stock baseline switch. .npmrc disables generated fixture lockfiles, matching the other fixtures; config.yaml loads the plugin with webpack; the test appends the custom cache root and incoming prebuilt flag. release.mjs distinguishes releases; page.js renders that value and a nonce while using file gates to hold regeneration. layout.js supplies the required app shell and the revalidation route expires the page on demand. It verifies the custom cacheDirectory option, incoming release and regenerated output across two restarts with both native dependencies and Harper's default VM module/dependency loader settings. It waits for the exact late-render nonce to reach disk before stopping the outgoing worker. Harper runs one worker so tag revalidation and the held render share a tag manifest. This exercises the cache/deployment boundary, not the deploy_component canary orchestration.
  • Base regression: failed at the incoming-release assertion (received v1, expected v2) on unchanged origin/main 22fe755, with the same fixture using stock caching and identical Next/Harper versions. The fixed regression passed.
  • npm pack --dry-run --json confirms the new CJS modules and declarations ship; type declarations use the installed next peer and dev aliases are absent from runtime imports. An isolated declaration consumer passes on all four SDKs and rejects an invalid constructor context; the old test-alias declarations failed that type-safety check. The corrected type imports emit identical runtime JavaScript. No lint/format script exists in this repository; TypeScript build and git diff --check are the available mechanical checks. No production dependency is added; package.json adds the exact 16.3.8 dev alias for the regression matrix and package-lock.json locks it and its platform packages. npm 11.13.0 regenerated the lockfile, including optional-platform metadata and semver 7.7.4→7.8.5, nanoid 3.3.12→3.3.20 and emnapi 1.10.0→1.11.3 transitive resolutions; the lockfile is not published.

🤖 Generated by OpenAI Codex; posted via @kriszyp.

Related PRs: none found
Complexity: complicated

Review-Coverage: authored=codex; ran=cursor-composer,gemini,claude,cursor-muse; adjudicated=domain; declined=cursor-grok,cursor-kimi; rounds=6; full=5 @ 43f0210

Review-Attention: study ~9m (sensitive: package-lock.json; decisions: do-less-alternative, native-cache-semantics, ownership-error-policy, isolation-boundary, namespace-retention) @ 43f0210

kriszyp and others added 6 commits October 8, 2026 16:06
Pin runtime cache writes outside the replaceable component tree and retain read-only build seed fallback. Capture each app/build binding before production serving, preserve Next cache semantics, and document migration and memory-cache boundaries.

Co-Authored-By: OpenAI Codex GPT-6.1 <noreply@openai.com>
Persist key ownership before disk writes so invalidation cannot revive a build seed. Read artifact hashes asynchronously, validate prepared configuration, wire cacheDirectory, and retain namespaces for safe stopped-worker cleanup.

Co-Authored-By: OpenAI Codex GPT-6.1 <noreply@openai.com>
Keep public declarations tied to the installed Next peer, guard startup compatibility, and require an explicit cache root outside the standard component layout. Verify persistence through both native and default Harper loaders and wait for strictly later native tag expiration in tests.

Co-Authored-By: OpenAI Codex GPT-6.1 <noreply@openai.com>
Skip app-local module comparison when the plugin is absent, leaving valid same-named custom handlers unchanged. Verify the guard against real files and clarify native worker-restart persistence, power-loss limits, and regular fetch-seed requirements.

Co-Authored-By: OpenAI Codex GPT-6.1 <noreply@openai.com>
Co-Authored-By: OpenAI Codex GPT-6.1 <noreply@openai.com>
Co-Authored-By: OpenAI Codex GPT-6.1 <noreply@openai.com>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednext@​16.3.861100909970

View full report

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a versioned filesystem cache for Next.js to isolate incremental render and data caches on disk across deployments. It includes the core cache handler implementation, build identity verification, configuration validation, and comprehensive integration and unit tests. The feedback focuses on optimizing disk I/O in VersionedCacheHandler by using an in-memory Set to track owned keys, and ensuring complete delegation of filesystem methods in seedFileSystem by spreading the original fs object.

Comment thread src/VersionedCacheHandler.cts
Comment thread src/VersionedCacheHandler.cts
Comment thread src/VersionedCacheHandler.cts
Comment thread src/versionedCache.cts
@kriszyp
kriszyp marked this pull request as ready for review October 9, 2026 03:35
@kriszyp
kriszyp requested a review from Ethan-Arrowood October 9, 2026 03:35

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

deploy_component: old-version workers write into the new release after the swap

1 participant