* feat: implement revalidateByPathPrefix on KVCacheHandler
Uses KV list metadata to discover entry tags without extra get() calls.
set() now stores tags in KV metadata, and revalidateByPathPrefix reads
them back via kv.list() — making prefix invalidation O(list_pages)
instead of O(entries × get).
Entries written before metadata support are gracefully skipped.
* fix: guard against KV metadata 1024-byte limit
Cloudflare KV limits metadata to 1024 bytes per key. If an entry's
tags exceed this budget when JSON-serialized, omit metadata entirely
and fall back gracefully — prefix invalidation skips entries without
metadata, and exact-path invalidation via revalidateTag still works.
* test: add regression test for KV metadata 1024-byte overflow
* chore: migrate to vite plus
* Disable typeAware and typeCheck
* Update CI
* Fix CI
* Fix test
* Clean
* Run test with vp
* Try revert
* react: false In test
* Fix test
* Revert "Try revert"
This reverts commit 009da10473.
* Update
* Update
* Try revert ci changes
* revert
* Run vp migrate
* Disable typeAware and typeCheck for now
* Better resolve for test
* Use vp dev instead of vite
* Update expect
* Fix NormalizeManifestModuleId
* Try increase timeout
* Update to use vp
* Try new check
* Bring back npx vp
* Migrate CI
* Make next-intl resolvable
* Update
* Update
* Update
* fix: MemoryCacheHandler revalidate:0 creates immediately-stale entries
MemoryCacheHandler.set had an inconsistent guard on data.revalidate:
the ctx path checked `revalidate > 0` but the data path only checked
`typeof data.revalidate === "number"`, allowing revalidate:0 to set
revalidateAt = Date.now() — making entries immediately stale.
This was inconsistent with KVCacheHandler which already had the > 0
guard on both paths. The two handlers would produce different staleness
behavior for the same input: Memory → immediately stale, KV → no TTL.
Add the missing `> 0` guard to align both code paths and both handlers.
* fix: revalidate:0 should skip cache storage entirely
Both MemoryCacheHandler and KVCacheHandler had inconsistent handling of
revalidate:0. In Next.js, revalidate:0 means "don't cache." But the
handlers would still store entries — Memory made them immediately stale
(data path lacked > 0 guard), KV cached them forever (both paths
rejected 0, so revalidateAt=null meant no expiry).
Resolve effective revalidate from both ctx and data paths (data
overrides ctx), and early-return without storing when it's 0. Both
handlers now use identical logic.
Fix existing test data that incidentally used data.revalidate:0 in
tests meant to exercise stale-while-revalidate and tag invalidation.
* perf: cache startup filesystem scans, optimize base64 and path normalization
- Cache hasMdxFiles() result per root directory to avoid redundant
recursive filesystem walks when config() fires per Vite environment
- Cache resolvePostcssStringPlugins() result per project root to skip
repeated existsSync checks across 17 PostCSS config file candidates
- Replace byte-by-byte base64 encode/decode in KV cache handler with
Buffer APIs for significantly faster serialization
- Pass pre-normalized URL to nodeToWebRequest() in App Router prod
server to eliminate redundant path normalization in the RSC handler
* fix: validate base64 structural length and improve test spy cleanup
Add length % 4 check to base64ToArrayBuffer to reject structurally
invalid base64 strings that pass the character-set regex but produce
empty/truncated buffers via Buffer.from(). Replace manual mockRestore()
calls with afterEach(vi.restoreAllMocks) to prevent spy leaks on
assertion failure.
* fix: address bonk review comments — composite mdx cache key, postcss comment order, hoist test import
* fix: cache Promise in resolvePostcssStringPlugins to prevent concurrent scan race; add POST urlOverride test
* fmt
---------
Co-authored-by: James <james@eli.cx>
* perf(kv): add local in-memory tag cache to reduce KV round-trips
When KVCacheHandler.get() validates tags, it was issuing one kv.get()
per tag in parallel. For entries with N tags, that's N KV round-trips
per cache hit. Same pattern in revalidateTag() — N parallel PUTs.
Add a local Map<string, { timestamp, fetchedAt }> with a 5-second TTL
that caches tag invalidation timestamps. Within the TTL window, tag
checks are served from memory with zero I/O. After TTL expiry, the
next request re-fetches from KV.
Key behaviors:
- revalidateTag() updates the local cache immediately so invalidations
are reflected without waiting for TTL expiry
- resetRequestCache() clears the local cache for per-request isolation
- NaN tag timestamps are cached and correctly treated as invalidation
- Only uncached/expired tags trigger KV reads (partial cache hits work)
* fix(test): guard fake timers with try/finally to prevent leaks
Wrap vi.useFakeTimers() in try/finally so vi.useRealTimers() runs even
if an assertion fails mid-test, preventing timer leaks into subsequent
tests.
* fix(kv): address bonk review comments on local tag cache
- Fix early return bug: split KV tag fetch loop into populate-cache pass
then invalidation-check pass, so already-fetched tag results are always
cached even when an earlier tag triggers an early return
- Fix unbounded _tagCache growth: delete stale entries when encountered
during the TTL check (cheap eviction instead of accumulation forever)
- Make tag cache TTL configurable via tagCacheTtlMs constructor option
(default 5000ms); eliminates fake timers in TTL test
- Clarify resetRequestCache() docstring: it is not called per-request by
vinext, it is an opt-in escape hatch for callers that need explicit isolation
- Add resetRequestCache() test: verifies tags are re-fetched from KV after
calling resetRequestCache()
* refactor(kv): reuse cached timestamps in invalidation check loop
The second pass over uncachedTags was re-calling Number(tagTime) and
re-reading tagResults[i]. Now it reads directly from _tagCache (which was
just populated in the first pass), eliminating the duplicate number
conversion and making the intent clearer.
---------
Co-authored-by: James <james@eli.cx>
* fix(isr): KVCacheHandler.set() now awaits KV put to prevent perpetual STALE
Background ISR regen was calling ctx.waitUntil(renderFn()) where renderFn()
resolved before the KV put network operation completed. set() returned
Promise.resolve() immediately, so await __isrSet() in the regen resolved early,
the waitUntil expired, and the Workers runtime killed the in-flight KV write —
leaving the cache entry perpetually STALE on every request after the TTL.
Fix: replace the fire-and-forget _putInBackground() with _put() which returns
the actual kv.put() promise. set() now returns that promise, so await __isrSet()
only resolves after the write is fully persisted. ctx.waitUntil() is also still
registered as belt-and-suspenders.
Adds three regression tests covering the STALE → regen → HIT lifecycle with a
delayed KV put mock to prove the set() promise is not an immediately-resolved stub.
* fix: remove unused variable flagged by lint
* fix: register background KV ops and ISR regen with ctx.waitUntil on Workers
On Cloudflare Workers, any async work started after a Response is returned
is killed immediately. This caused three silent failures:
1. ISR background regeneration (stale-while-revalidate) was never
registered with ctx.waitUntil, so every revalidation on Workers was
silently dropped.
2. KV deletes for corrupt/invalid/tag-invalidated cache entries were
awaited on the critical path, blocking the response for pure cleanup work.
3. KV writes in KVCacheHandler.set() were awaited on the critical path;
the client already has the rendered content so the write can happen
after the response is sent.
Fixes:
- Thread ExecutionContext through triggerBackgroundRegeneration and call
ctx.waitUntil(regenPromise) when ctx is present (isr-cache.ts and the
inlined copy in pages-server-entry.ts).
- Add _deleteInBackground / _putInBackground helpers to KVCacheHandler
that use ctx.waitUntil when ctx is present and fall back to
fire-and-forget on Node.js.
- Convert all three delete callsites (corrupt JSON, invalid shape,
tag-invalidation) and the set() KV write to use the new helpers.
- All changes are backward-compatible: ctx is optional everywhere;
Node.js dev/prod behaviour is unchanged.
* regen snaps
* add oxfmt formatter: config, scripts, CI, editor setup, docs
* rebuild lockfile
* fix: add Format to required checks list, remove dead ignore pattern
* run fmt
* add format to agents.md again