Skip to content

feat: self-host parity for AI + ISR on Cloudflare - #268

Merged
Brooooooklyn merged 32 commits into
mainfrom
feat/selfhost-ai-isr
Jul 9, 2026
Merged

Brooooooklyn merged 32 commits into
mainfrom
feat/selfhost-ai-isr

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Jul 9, 2026 •

Copy link
Copy Markdown
Member

What

Bring AI and ISR to parity for self-host deploys (vite build && wrangler deploy to the user's own Cloudflare account), alongside the existing managed void deploy path.

Before, both features assumed the managed platform (proxy binding + platform dispatch worker). On self-host they silently no-op'd. This wires them to the user's own account.

                managed (void deploy)          self-host (wrangler deploy)   ← NEW
                ---------------------          ---------------------------
AI  ai.run()    __VOID_PROXY → gateway         env.AI binding (direct)
    ai.provider() __VOID_PROXY → gateway       user's own AI Gateway (ai.gateway)
ISR serve       platform dispatch worker       handleIsr: ISR_CACHE KV + caches.default
    revalidate()  __VOID_PROXY purge           own KV + edge purge (fetch/cron/queue)

How

  • Config — new top-level ai.gateway in void.json (routes ai.provider().fetch() through your own Cloudflare AI Gateway). Distinct from the pre-existing inference.bindings.ai boolean.
  • AI — resolveBackend() falls through to a direct backend: ai.run/image/models/toMarkdown bind env.AI; ai.provider().fetch() routes via directAi.gateway(id).getUrl(provider). Returns a Response on known errors, never throws, no metering.
  • ISR — single-tenant port of the platform dispatch cache. handleIsr runs a KV (ISR_CACHE) + edge (caches.default) stale-while-revalidate ladder, fails open on any degraded cache (never 500s in the serving path). Deployment id is a compile-time literal baked into the worker entry, stamped on fetch + scheduled + queue so revalidate() works from cron/queue too.
  • Gating — ISR is emitted only on non-managed builds (command==='build', no target, no VOID_DEPLOY_PROJECT_ID, and the app declares revalidate). Dev and managed builds are byte-identical to before; the internal ISR_CACHE binding is hidden from c.env and void/env only when ISR is emitted.
  • Dynamic-page fix (managed + self-host) — collectRevalidate now converts generated page-pattern params to * (/blog/:slug → /blog/*) so dynamic-route revalidate rules actually match. This intentionally changes the managed manifest for dynamic-page-revalidate apps.

Verification

  • 14-task plan executed via subagent-driven-development; each task adversarially reviewed.
  • Final whole-branch review: PASS (4 rounds — each round's findings verified against control flow, fixed, and re-reviewed).
  • vp run build:core ✓ · vp run check (16 pkgs) ✓ · packages/void unit suite green (2 integration suites fail for environmental reasons only: wrangler multi-CF-account + analog Angular typescript resolution — both unrelated to this branch, confirmed identical on a clean baseline).
  • Docs updated: docs/integrations/cloudflare.md (AI + ISR self-host + non-goals), packages/void/CLAUDE.md pitfalls, schema.json.

🤖 Generated with Claude Code


Note

Medium Risk
Medium risk: large new ISR cache path and AI gateway routing touch request serving and secrets handling, though managed/dev paths are gated and cache ops fail open. collectRevalidate pattern normalization also changes managed deploy manifests for dynamic page revalidate rules.

Overview
Adds self-hosted (vite build && wrangler deploy) support for AI and ISR, which previously depended on the managed Void proxy and dispatch worker.

AI: New void.json ai.gateway (schema + validation) maps to __VOID_AI_GATEWAY in the built worker. On build, when the app uses void/ai, Void injects env.AI via shouldBindWorkersAi (build-only, won’t override an existing wrangler ai binding). runtime/ai.ts gains a direct backend: Workers AI on env.AI, and ai.provider().fetch() through the user’s AI Gateway with provider-specific auth headers and safe relative paths (501 without gateway).

ISR: New isr-config.ts centralizes revalidate merging (including converting scanned page patterns like :slug to * for matching). Self-host production CF builds wire handleIsr from runtime/isr-cache.ts, bake __VOID_ISR_REVALIDATE and a compile-time __VOID_ISR_DEPLOYMENT_ID, and require a user-declared ISR_CACHE KV. revalidate() purges local KV and edge cache when no platform proxy. ISR_CACHE is hidden from c.env / void/env only while ISR is active.

Docs / agent notes: docs/integrations/cloudflare.md documents self-host AI and ISR setup and limits; AGENTS.md now points at CLAUDE.md, which adds a Self-Host AI + ISR section and pitfalls.

Tests: Broad unit/integration coverage for AI direct backend, ISR compile wiring, config validation, env filtering, and kitchen-sink wrangler output.

Reviewed by Cursor Bugbot for commit 0131eef. Bugbot is set up for automated code reviews on this repo. Configure here.

Brooooooklyn and others added 23 commits July 8, 2026 16:48
Add a top-level `ai` object to void.json config with an optional
`gateway` string field. Introduces the `AiConfig` type, wires
`ai?: AiConfig` into `VoidConfig`, validates the block (rejecting
non-objects, unknown nested keys, and empty/non-string gateway),
and adds the matching top-level `ai` property to schema.json.

This is the config surface only; later tasks emit the
__VOID_AI_GATEWAY worker var and consume it in the AI runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ost builds

Add the exported pure helper `shouldBindWorkersAi` and wire it into
`mergeBindings`: on real builds (`command === 'build'`) inject an
`{ binding: 'AI' }` Workers AI binding into the built wrangler config, and
emit a `__VOID_AI_GATEWAY` worker var when `config.ai.gateway` is set.

Build-gated so dev/miniflare (which has no local Workers AI and uses the
P2 __VOID_TOKEN proxy path) never binds env.AI. The `!resolvedAi` guard is
load-bearing since mergeBindings' return is defu-merged over the resolved
root-wrangler config and wins, preventing clobbering a user's own `ai`
binding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P3 self-host (direct env.AI) previously threw on ai.provider().fetch().
Refactor the inline P3 object into makeDirectBackend, read the
__VOID_AI_GATEWAY worker var, and make providerFetch route through the
user's own Cloudflare AI Gateway (getUrl(provider)) with their own
provider secret. providerFetch always returns a Response (501 no
gateway, 400 unsafe path / bad key, 502 getUrl failure) and passes the
gateway Response through directly. Port providerAuthDefaults,
gatewayEndpointUrl, isUnsafeProviderPath and add a real toDirectBody
(real BodyInit + content-type, not the proxy envelope). No self-host
metering header. Delete unsupportedProviderFetch; broaden the final
"Not available" error to mention binding Workers AI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…alidation

Add P3 direct-backend tests (gateway routing, provider auth, no metering,
501/400/502 error paths, JSON + multipart body serialization), top-level
`ai` config validation, and kitchen-sink build assertions for the injected
`ai` binding + `__VOID_AI_GATEWAY` var (relaxing the __VOID_ leak filter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the per-page revalidate merge out of the void deploy CLI into a
pure module packages/void/src/isr-config.ts so the managed deploy path
and the future self-host build path cannot diverge. collectRevalidate
takes the VoidConfig + already-computed PageScanResult and returns the
same merged map deploy.ts produced before (byte-identical). Also adds
hasRevalidate presence helper for the self-host build path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the platform dispatch ISR cache ladder into a single new
runtime module for self-hosted (`vite build && wrangler deploy`)
Void workers, collapsed from multi-tenant to single-tenant:

- packIsrValue/unpackIsrValue, isStale, serializeHeaders,
  shouldBypassIsr, resolveRevalidateTtl, isrCacheResponse,
  resolvePathTtl + matchLiteralPattern/LRU, page-data extraction,
  and response builders ported from dispatch/isr.ts + index.ts.
- readIsrCache/writeIsrCache/deleteIsrCache collapsed to (env,
  pathname[, resp, ttl]); deployment id derived from
  env.__VOID_ISR_DEPLOYMENT_ID (same source as the revalidate()
  purge path). No projectId, no #rw= rewrite variant, no variant cap.
- Collapsed keys: isrKey / htmlEdgeKey / jsonEdgeKey.
- handleIsr orchestrator + background revalidation (X-VoidPages
  stripped on re-render via a fresh cloned Request).
- Every ISR_CACHE (KV) and caches.default op wrapped to fail open:
  read/hit failures bail to a live render, write failures swallowed.
- writeIsrCache keeps the synchronous resp.clone() before any await.

Wired into the worker entry in Task 7; behavior tested in Task 11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The JSON (X-VoidPages) miss path called populateJsonEdgeCache, but a
pages-protocol JSON response carries no x-void-page-data header and no
__VOID_PAGE_DATA__ script, so it always no-ops there. Remove the dead
call (and its now-unused locals) so the JSON miss just renders and
returns live JSON, matching the platform dispatch behavior. The JSON
edge variant is still warmed as a side-effect of the HTML render path.

Also clarify comments: handleIsr's JSON-path docstring, and a note at
cloneWithoutVoidPages that the Request-OBJECT form preserves request.cf.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validate ISR cache entries at the readIsrCache choke point so the hit
path can never throw a 500 from a corrupt/tampered/schema-drifted KV
entry. Reject non-integer/out-of-range statuses, non-finite/non-positive
createdAt & ttl, and non-constructible headers -> render live. Also wrap
the L2 KV-hit response construction in handleIsr in a belt-and-suspenders
try/catch that falls open on any throw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Emit compile-time ISR consts (__VOID_ISR_REVALIDATE, literal
__VOID_ISR_DEPLOYMENT_ID) beside __VOID_BUILD_KEY; add optional `isr`
param to compileWorkerEntry that wraps the render path with handleIsr,
captures deployment id + host on env before dispatch, and hides
ISR_CACHE from route handlers' c.env. ISR-absent output is byte-
identical to before (modulo the ISR_CACHE filter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thread the merged revalidate map into `compileRouteTable` and pass the
`isr` runtime wiring into `compileWorkerEntry`, but only on a real
(non-managed) production CF `vite build`. The gate reads
`resolvedConfig.command` (set in configResolved, available at buildStart)
and stays off for dev (`serve`), non-CF targets, managed `void deploy`
(which sets `VOID_DEPLOY_PROJECT_ID`), and apps without revalidate config.

Route table and worker entry derive from the same `emitIsr` decision so
the baked `__VOID_ISR_REVALIDATE` / `__VOID_ISR_DEPLOYMENT_ID` consts and
the entry's `handleIsr` wiring are present or absent together within one
build. The ISR runtime path is resolved lazily, only when ISR is emitted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the local-dev no-op fallthrough in revalidate() with a self-host
purge that deletes the entry from the worker's own ISR_CACHE (KV) and
caches.default edge variants, using the key builders from isr-cache.ts.
The managed __VOID_PROXY branch is unchanged (still forwards to the
platform proxy). Stays a silent no-op when neither proxy nor (ISR_CACHE +
__VOID_ISR_DEPLOYMENT_ID) is configured.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds unit tests for the self-host ISR feature (Tasks 5-9):
- isr-cache.test.ts: key builders, pack/unpack, isStale,
  resolveRevalidateTtl, isrCacheResponse, and the handleIsr cache
  ladder (MISS/HIT/STALE, JSON edge miss+hit, HTML edge hit),
  including corrupt-KV fail-open (returns null + degrades handleIsr
  to a live render), getWithMetadata throw fail-open, no-write on the
  JSON path, X-VoidPages-stripped background re-render, and the
  x-revalidate:0 purge.
- isr.test.ts: revalidate() managed proxy branch + self-host KV/edge
  purge (paths + all:true cursor loop) + unconfigured no-op.
- isr-config.test.ts: hasRevalidate + collectRevalidate merge rules.
- compile.test.ts: compileWorkerEntry ISR wiring + compileRouteTable
  baked __VOID_ISR_DEPLOYMENT_ID literal / __VOID_ISR_REVALIDATE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sertions

Review follow-up to Task 11. Four ISR tests passed without proving the
behavior they asserted; strengthen each so it fails on regression.

- X-VoidPages stripping: the JSON gate in handleIsr means an X-VoidPages
  request never reaches the HTML stale path where cloneWithoutVoidPages
  runs, so the old assertion was tautological. Export cloneWithoutVoidPages
  and test the stripper directly (header removed, method/url/other headers
  preserved).
- Stale waitUntil: drive the background render through a test-controlled
  deferred promise; assert handleIsr resolves to STALE before the render
  resolves and that a waitUntil task was queued (background, not inline).
- Corrupt-KV via handleIsr: hoist the full corruptCases table to module
  scope and drive ALL 10 shapes (incl. createdAt NaN/Infinity/negative,
  ttl zero/negative) through handleIsr — each degrades to a live MISS
  render, never 500, with readIsrCache returning null.
- Self-host revalidate: seed unrelated /keepme edge variants and assert
  they survive; assert exact kv.delete (x2) and cache.delete (x4) keys
  with no over-purge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The managed-proxy revalidate tests mocked fetch with no params, so
mock.calls[0] typed as [] and casting to [string, RequestInit] failed
`vp run check` (TS2352). Type the mock params instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The { all: true } self-host purge test proved pagination + key deletion
but not that kv.list is scoped to void-isr/<id>/. An unscoped list would
still pass (purge rekeys through the current id). Assert the prefix on
every list page + no cursor on the first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The stale-hit test asserted tasks.length >= 1, which the edge-warm
waitUntil alone satisfies — a regression to an untracked floating
revalidation promise would still pass. Assert exactly 2 tracked tasks
(revalidation + edge-warm) so dropping the revalidation waitUntil fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…elper

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add AI (self-host) and ISR (self-host) subsections to the "Deploy to
your own Cloudflare account" guide: ai.gateway + auto-bound env.AI,
ai.provider() via the user's own AI Gateway, and the ISR_CACHE KV
requirement, each with its non-goal list. Document ai.gateway /
__VOID_AI_GATEWAY (incl. the leak-check exception) and ISR_CACHE in the
void package instructions, plus 3 Pitfalls entries. Convert
packages/void/AGENTS.md into the real CLAUDE.md with an AGENTS.md
symlink to match the repo-root convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ai.gateway only affects ai.provider().fetch() — Workers AI (ai.run/image)
binds env.AI directly. The schema/config descriptions wrongly implied it
routes Workers AI. Also narrow the "nothing leaves your account" claim:
the provider request + key are sent to the AI Gateway and upstream provider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/env, match dynamic-page revalidate

- Self-host revalidate() from scheduled/queue handlers no longer no-ops:
  stamp env.__VOID_ISR_DEPLOYMENT_ID (id only, no host) at the top of both
  handlers when ISR is wired. isr-absent output is byte-identical.
- ISR_CACHE no longer leaks through the public void/env API: add a shared
  INTERNAL_BINDING_EXACT_NAMES list in env-raw.ts; env.ts isInternalBinding and
  compile.ts's generated c.env filter both derive the exact-name check from it.
  Generated worker code is byte-identical.
- collectRevalidate converts generated per-page route params (:slug, :slug{.+})
  to * wildcards so the literal TTL matcher matches dynamic pages (managed AND
  self-host). User routing.revalidate literal keys are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ge; hide boot-validated ISR_CACHE from void/env

FIX A (isr-cache.ts): edgeCache() now returns Cache | undefined via a
try/catch around the caches.default deref, so handleIsr never rejects
before its fail-open guards. safeCachePut/safeCacheDelete/populateJsonEdgeCache
short-circuit on an undefined cache; cache.match uses optional chaining so an
absent edge cache is a miss (HTML path falls through to L2 KV), never a throw.

FIX B (isr.ts): acquire caches.default lazily inside the if (host) branch of
the self-host purge, so the KV-only cron/queue purge (no __VOID_ISR_HOST) never
depends on the Cache API. Managed __VOID_PROXY branch untouched.

FIX C (env.ts, env-public.ts): export the shared isInternalBinding predicate;
_validateEnvOnce no longer seeds internal-binding keys into the sync cache and
readKey treats internal-binding names as absent before consulting the cache, so
a schema-declared ISR_CACHE is never exposed via void/env even after boot
validation seeds its raw value. Normal env keys unaffected.

Adds one focused regression test per fix (each verified to fail without the fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ore no-ISR byte-identity)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_46dffd58-1dca-41f3-a6f0-556ed6f40ef0)

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ed278d0f9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/runtime/isr-cache.ts
Comment thread packages/void/src/runtime/ai.ts
Brooooooklyn and others added 2 commits July 9, 2026 15:02
The self-host build now injects a Workers AI binding (ai.gateway). Workers AI
has no local emulator, so its presence forces the CF vite-plugin into a remote
proxy session that needs Cloudflare account/creds — unavailable in CI, so the
preview server exited before it was reachable. Strip the ai binding before
preview (same as containers), since the smoke test never exercises AI and the
build-time binding is covered by kitchen-sink-build.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_ce369f84-3cc1-4644-b5c1-d99ea08a4b99)

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 80aed50b09

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/isr-config.ts Outdated
Comment thread packages/void/src/runtime/isr-cache.ts Outdated
…arer provider auth defaults

ISR: jsonCacheResponse now mirrors pageJsonResponse (X-VoidPages: true + Vary:
X-VoidPages) so a cached Pages JSON variant served on a later X-VoidPages
navigation is parsed by the client instead of triggering a full-page hard reload.

AI: PROVIDER_AUTH_DEFAULTS gains the confirmed non-Bearer providers (anthropic,
elevenlabs, deepgram, fal, cartesia, baseten, ideogram) so the self-host direct
gateway path sends each provider's native auth header instead of a wrong
Authorization: Bearer that 401s at the AI Gateway pass-through. Bearer fallback
and caller apiKeyHeader/apiKeyPrefix override are unchanged.

Regression tests added for both fixes (proven to fail without the source change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_f31760cc-0503-4ca4-9efc-1b5b666893c0)

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0faa46289a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/runtime/isr-cache.ts
Comment thread packages/void/src/runtime/isr-cache.ts
…t KV

FIX #3 — segment-aware revalidate (parity with void-platform dispatch):
- collectRevalidate (isr-config.ts): only named/anonymous catch-alls
  (:name{.+}, *) collapse to '*'; single-segment params (:slug) stay as
  :slug so the matcher can anchor them to ONE path segment.
- matchLiteralPattern (isr-cache.ts): whole-segment :name -> [^/]+ via a
  U+E000 private-use sentinel (survives regex-special escaping, expanded
  after). Mid-segment literal ':' (e.g. /api/v1:health) left alone. '*'
  still crosses '/'. Strict SUPERSET of the old '*'-only matcher, so old
  '*'-only manifests match identically (rollout safety). Byte-identical to
  the shared cross-repo matcher spec.
- Manifest emission changes again (single-seg pages now :slug, not '*');
  updated the pinned keys in isr-config/package tests.

FIX #4 — host-scoped self-host ISR KV keys + host registry:
- isrKey -> void-isr/<id>/<host>/<path>; readIsrCache/writeIsrCache/
  deleteIsrCache/handleIsr/revalidateInBackground thread `host`
  (url.hostname). One host never reads/serves another host's cached bytes.
- writeIsrCache registers the host under void-isr-hosts/<id>/<host> (guarded
  by an in-isolate Set, fail-open). New listIsrHosts(kv, id) enumerates them.
- revalidate() self-host purge (isr.ts): {paths} enumerates registered hosts
  (union __VOID_ISR_HOST) and purges each host's KV + edge; {all} splits the
  host off the scanned key tail before denormalizing. KV purge never depends
  on caches.default (edge purge guarded, fail-open). Empty host set no-ops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_cf7f113d-eeb2-4afb-b441-417e2fdc9f67)

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9df3936d6e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/runtime/ai.ts
Comment thread packages/void/src/runtime/env.ts Outdated
…ped)

On a cacheable anonymous GET that MISSes ISR, the client response headers
were cloned from the already-set-cookie-stripped cache copy, so the first
render's Set-Cookie (session/CSRF/prefs) never reached the browser. Build
the client headers from the stripped base but re-append each Set-Cookie from
the original render response via getSetCookie() (preserves multiple cookies).
The KV (serializeHeaders/SKIP_HEADERS) and edge cache copies stay stripped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_5faa24be-8a8a-4e3a-81ad-e59fc195c0b8)

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5602b3f60c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/runtime/isr-cache.ts Outdated
Brooooooklyn and others added 2 commits July 9, 2026 16:40
`parallel` is a first-class provider in PROVIDER_KEY_MAP but was missing
from PROVIDER_AUTH_DEFAULTS, so the self-host direct-gateway path fell back
to `Authorization: Bearer <key>`. Cloudflare's Parallel AI Gateway
authenticates with an `x-api-key` header (no prefix), so Bearer 401s at the
pass-through. Add the entry (matching the anthropic shape) and assert
`ai.provider('parallel').fetch()` sends `x-api-key`, not Authorization.

Completes the earlier provider-auth fix that skipped `parallel` as
unconfirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
….env)

The generated c.env filter (router/compile.ts) hides the internal ISR_CACHE
binding by exact name ONLY when ISR is emitted (the build-time `isr` gate),
so a non-ISR app's own binding named ISR_CACHE stays visible. But the runtime
void/env side hid ISR_CACHE unconditionally, so in a non-ISR app that declares
its own ISR_CACHE KV, `c.env.ISR_CACHE` was visible while `env.ISR_CACHE` /
`getRuntimeBinding('ISR_CACHE')` returned undefined — an inconsistency.

Gate the exact-name (ISR_CACHE) internal check on ISR being active at runtime:
`isIsrActive(rawEnv)` is true iff `__VOID_ISR_DEPLOYMENT_ID` is present on the
raw env (the compile-time deployment id the worker entry stamps only on
ISR-emitting builds — the same condition that fires the c.env gate). All
void/env surfaces now consult the gated predicate consistently: the `env`
proxy get/has/ownKeys, getRuntimeEnv, getRuntimeBinding, requireRuntimeBinding,
and `_validateEnvOnce` cache seeding. Prefix internals (`__VOID_`, `__PROJECT_`)
stay hidden unconditionally; c.env (compile.ts) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_60e3300d-f41a-448e-8534-611a2a218ee1)

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5001ca9d18

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/router/compile.ts Outdated
Comment thread packages/void/src/runtime/isr-cache.ts
A Range GET matching an ISR path (e.g. a byte-range media route under a
global revalidate) rendered 206 Partial Content and, because the write
gates used response.ok (200-299, including 206), was written to the shared
(host,path) full-response entry -- a later full request was then served
that truncated 206. handleIsr also only guarded method/shouldBypassIsr,
never the Range header.

- handleIsr: bail (null -> caller renders live) for any request carrying a
  Range header, BEFORE any cache read or write, so it renders live (correct
  206) and never touches the shared entry.
- Tighten both write gates (revalidateInBackground + miss path) from
  response.ok to response.status === 200 (never store 206/204/etc.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_8899a0f2-52cf-40b1-aff5-b8be7dc81654)

…istration

- compile.ts: stamp __VOID_ISR_HOST with `=` (not `??=`) so a warm env reused
  across hostnames tracks the current request's host; revalidate() from a second
  custom domain no longer reads a frozen first host. Deployment id stays `??=`.
- isr-cache.ts: clear the in-isolate registry guard when the registry `kv.put`
  fails, so a later write retries instead of permanently dropping the host from
  listIsrHosts() (which cron/queue purge relies on).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_2dc57719-d74d-4c49-870d-8130b0b363b1)

@Brooooooklyn
Brooooooklyn merged commit 5b52a8d into main Jul 9, 2026
7 checks passed
@Brooooooklyn
Brooooooklyn deleted the feat/selfhost-ai-isr branch July 9, 2026 09:38
cpojer pushed a commit that referenced this pull request Sep 11, 2026
…0077; framework #272) (#228)

* fix(isr): segment-anchored revalidate matcher (parity with framework)

Make matchLiteralPattern segment-aware in both platform copies
(dispatch/src/pattern.ts and api/src/routes/deploy.ts): a whole-segment
`:name` (preceded by `/` or start, followed by `/` or end) anchors to one
path segment (`[^/]+`), while `*` stays a splat that crosses `/`. A private-use
sentinel (U+E000) survives regex-special escaping and expands to `[^/]+` after
escaping, so a user mid-segment literal `:` (e.g. `/api/v1:health`) is left
alone. The new matcher is a strict SUPERSET of the old one — already-deployed
`*`-collapsed manifests keep matching identically while future `:slug`
manifests match a single segment. Byte-identical to the void self-host copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): host-scope ISR cache keys + per-host revalidate/warming

Insert a `<host>` segment into ISR cache keys:
`<pid>/isr/v2/<did>/<host>/<path>[#rw=...]`. The same path served under a
project slug and a custom domain (or two custom domains) no longer shares a
cache slot. `host` is the request hostname dispatch saw
(`new URL(request.url).hostname`).

- isr-shared: `isrCacheKey`/`isrCacheVariantPrefix` take a `host` param and
  emit the host segment; `isrCachePrefix` stays host-agnostic (deployment
  prefix) so `{all}` scans and `clearIsrCache` still cover every host.
- dispatch: `readIsrCache`/`writeIsrCache`/`deleteIsrCache` +
  `revalidateInBackground` thread `host`; both ISR block call sites pass
  `url.hostname`.
- api revalidate: `{paths}` iterates the resolved per-hostname set building
  host-scoped keys/variant prefixes per host; `{all}` splits the `<host>`
  segment off each scanned key and edge-purges under that key's own host.
- api deploy prerender warming writes the slug-host key
  (`${slug}.${siteDomain}`) so dispatch actually serves the warmed bytes.

Fail-open is preserved everywhere (read miss/error -> live render, never 500).
Migration is additive cold-miss: old host-agnostic entries under the same
deployment id become unreadable -> cold miss -> re-cache host-scoped; `{all}`
still deletes them from KV. Parity with the void self-host host-scoped key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): preserve Set-Cookie on live ISR miss (parity with framework)

The ISR MISS branch cloned the client response headers from the already-
set-cookie-stripped cache copy, so the first render's Set-Cookie (session/
CSRF/prefs) never reached the browser. Build the client headers from the
stripped base but re-append each Set-Cookie from the original worker response
via getSetCookie() (preserves multiple cookies). A narrow-cast readSetCookies
helper works around @cloudflare/workers-types omitting getSetCookie. The KV
(serializeHeaders/SKIP_HEADERS) and edge cache copies stay stripped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): bypass ISR for Range requests + only cache full 200 responses (parity with framework)

A Range GET matching an ISR path rendered 206 Partial Content and, because
the write gates used response.ok (200-299, including 206), was written to the
shared (host,path) full-response entry -- a later full request was then served
that truncated 206. A Range GET could also READ a cached full-body entry and
serve the whole body for a partial request. The inline ISR ladder only guarded
method/shouldBypassIsr, never the Range header.

- ISR-ladder entry guard and secondary SSR write guard: exclude any request
  carrying a Range header, before every ISR cache read and write, so it renders
  live (correct 206) and never touches the shared entry.
- Tighten all three write gates (ladder miss, secondary SSR miss, background
  revalidation) from response.ok to response.status === 200 (never store
  206/204/etc.).

Mirrors the framework fix in packages/void/src/runtime/isr-cache.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): edge-purge legacy hostless ISR keys on full revalidate

Host-scoping did not bump ISR_CACHE_KEY_VERSION, so pre-host-scoped keys
(`<proj>/isr/v2/<D>/<path>`) still live under the scanned prefix and, with no
expirationTtl on ISR puts, persist indefinitely.

On `all: true` the scan derived (host, path) by splitting the key tail on the
first slash, which mishandled both legacy shapes:

  about      slashIndex === -1 -> `continue`: no edge target emitted, yet the
             bulk delete still removed the KV entry. The edge URL kept serving
             stale HTML/JSON for the remaining s-maxage while runRevalidate
             reported success.
  blog/post  parsed as host="blog", path="/post": purged the garbage URL
             https://blog/post and never touched the real one.

Discriminate on the resolved hostname set instead of the first slash: a platform
hostname is always an FQDN present in `perHostname`, a legacy path's first
segment never is. Legacy keys treat the whole tail as the path and fan the edge
purge out to every hostname whose routing resolves to the deployment id (the
union invariant guarantees at least one). Variant tokens fan out the same way,
and `purged` now counts legacy pathnames.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dispatch): scope segment-anchored matching to revalidate patterns

3282937e added whole-segment `:name` -> `[^/]+` handling directly to the shared
matchLiteralPattern, which is also used by applyHeaderRules (user-authored
header rules) and resolveAllowlist (revalidateQueryAllowlist keys). A user
pattern like `/:batchGet` therefore compiled to `^/[^/]+$` and matched every
single-segment path: header rules applied to unrelated routes, and allowlisted
query params leaked onto them, defeating the cache fan-out defense in
buildVariantOriginalUrl.

Split the matcher. matchLiteralPattern returns to literal-only semantics (as on
main, and as its doc comment always promised). The new matchSegmentPattern
carries the route-param behavior and is used only by resolvePathTtl, whose
`revalidate` keys are machine-generated from page routes -- matching the
write-side matcher in api/src/deploy.ts. Mid-segment colons
(`/api/documents:batchGet`) stay literal in both.

The two matchers get separate regex caches: the same pattern string compiles to
different regexes, so a shared cache would cross-contaminate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): purge legacy rewrite variants on path revalidation

Host-scoping changed the `paths` branch to list the host-scoped variant prefix
`<proj>/isr/v2/<D>/<host>/<path>#rw=`. Pre-host-scoped rewrite variants are
written without the `<host>` segment, so they fall outside that prefix and their
tokens are never recovered: the stale edge entry
`https://<host>/<path>?__void_isr=<D>&__void_orig=<token>` is never purged.
Dispatch serves it at L1 and returns before any KV read, so the host-scoped key
is never written either -- revalidation can never reach that variant, while
runRevalidate still reports success.

The `all: true` branch scans the whole deployment prefix and already handles
legacy keys; only `paths` regressed. Before host-scoping this branch used the
hostless variant prefix, so it did see them.

Add `isrLegacyCacheVariantPrefix` and scan it in the `paths` branch, once per
(deploymentId, path) over the union of resolved ids -- the legacy prefix is
host-independent, so scanning per hostname would issue redundant identical KV
lists. Recovered tokens are edge-purged across every hostname carrying the id
(superset purge, mirroring the `all` legacy branch) and the orphaned KV key is
deleted.

The two key spaces are disjoint: a host-scoped `<D>/<host>/<path>#rw=` cannot
fall under `<D>/<path>#rw=` because platform hostnames are FQDNs and a leading
path segment never is -- so nothing is purged or deleted twice.

Legacy direct-hit keys (no `#rw=`) are intentionally left: dispatch reads only
host-scoped keys so they are inert, and their edge URL is already purged by the
existing direct-hit target. `all: true` reaps them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): match any whole-segment route param name in revalidate patterns

The segment-anchored revalidate matcher recognized a whole-segment `:name` only
when the name matched [A-Za-z0-9_]+. A dynamic page like `pages/blog/[post-id]`
serializes to the manifest key `/blog/:post-id` (parse-filename slices the
brackets with no charset validation), and the `-` breaks the `(?=/|$)` lookahead,
so the pattern compiled to the literal `^/blog/:post-id$`.

Effect: resolvePathTtl misses, dispatch gates the whole ISR ladder (cache read
AND write) on `isrTtl > 0`, and deploy skips prerender warming when `ttl <= 0`.
ISR was therefore silently disabled for every route with a non-word param name.
The page still rendered -- the framework's own route matcher uses `:[^/]+` -- it
just never cached.

Widen the param name to any non-empty run of non-`/` characters, in BOTH copies
of the matcher: the dispatch read side and the api write side must agree, or
prewarm would still skip these paths. This aligns with the framework's own
patternToRegex.

Mid-segment colons (`/api/v1:health`, `/api/documents:batchGet`) still compile
literally in both matchers -- the `:` must immediately follow `/` or the start of
the pattern to be a param -- and an empty name (`/blog/:`) also stays literal.

Not a regression from this branch: before the segment matcher landed the pattern
was pure-literal, so no `:name` key matched at all. This closes the remaining gap
rather than restoring lost behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): bump ISR cache key version to v3 for host-scoped keys

Host-scoping changed the KV key format to `<proj>/isr/v2/<D>/<host>/<path>` but
kept the v2 version prefix. Legacy pre-host-scoped keys are
`<proj>/isr/v2/<D>/<path>`, and `normalizeIsrPath` escapes neither `/` nor `.`,
so the two ALIAS whenever a legacy path's leading segment equals a hostname:

  legacy  p/isr/v2/D/ + normalizeIsrPath('/example.com/about')
  new     p/isr/v2/D/ + 'example.com' + '/' + 'about'
  ->      p/isr/v2/D/example.com/about        (byte-identical)

`readIsrCache` validates no stored path or host (metadata is only {s,c,t}), so a
request to https://example.com/about could read and serve the body cached for the
page /example.com/about, then promote it into the edge cache. Reachable: a dotted
leading segment is legal (`[domain]` -> `:domain` = `[^/]+`, `[...slug]` ->
`(.+)`). Same-project only -- keys are projectId-prefixed, so no cross-tenant
leak. `<D>` is routing.version, written by deploy and not rewritten by a platform
upgrade, so the window stays open for every un-redeployed project.

Bump ISR_CACHE_KEY_VERSION to v3, per the policy already documented in
isr-shared/README.md ("any incompatible change to the format requires bumping").
The version now ENCODES the key shape:

  v3  <proj>/isr/v3/<D>/<host>/<path>   always host-scoped
  v2  <proj>/isr/v2/<D>/<path>          always hostless legacy

That removes the ambiguity structurally, so the `perHostname.has(firstSegment)`
discriminator in revalidate-core is deleted rather than adjusted. It also fixes a
latent bug in 2c4ea221: the `paths` legacy-variant scan used prefix
`<D>/<path>#rw=`, which for path /example.com/about exactly matched the LIVE
host-scoped variant key of (example.com, /about) and would have deleted it. The
disjointness argument in that commit -- "hostnames are FQDNs and a leading path
segment never is" -- was false.

The edge URL carries no key version (`https://<host>/<path>?__void_isr=<D>`), so
bumping KV alone would leave stale edge entries serving while runRevalidate
reported success -- the exact failure ae0399d8 and 2c4ea221 fixed. Both purge
branches therefore still sweep the v2 namespace (unambiguously hostless) to
derive and purge those edge URLs and reap the orphaned keys. Those sweeps are
marked transition-only and can be removed once v2 entries have aged out.

Dispatch reads and writes v3 exclusively and holds no reference to the legacy
namespace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(deps): bump framework submodule to v0.10.10 (#268 ISR parity)

Advances the framework submodule f50b223 -> 54dca14 (origin/main, v0.10.10),
bringing in framework #268 "self-host parity for AI + ISR on Cloudflare" so the
platform dispatch/ISR code is true parity with the framework's ISR behavior.
Keeps #267 (void github disconnect) — 54dca14 is a superset of both.

Bumps the catalog hono 4.12.18 -> 4.12.27 to match the framework submodule
(required past framework #256; a mismatch breaks the dashboard vue-tsc
Context<…> typecheck across the void->dashboard link, per #200/#218).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): match named catch-all revalidate keys across path segments

A named catch-all page (pages/docs/[...path]) serializes to the revalidate key
`/docs/:path{.+}`. The segment-anchored matcher's `:[^/]+` param rule swallowed
`path{.+}` whole, compiling to `^/docs/[^/]+$`, so NESTED paths like `/docs/a/b`
got TTL 0 — ISR silently never read/wrote for them at the edge, and prerender
warming was skipped on the deploy write side.

Handle `:name{.+}` before the plain `:name` rule via a second private-use
sentinel: `:name{.+}` -> `.+` (>=1 char, crosses `/`); `:name` -> `[^/]+` (one
segment). The `{.+}` name class `[^/{}]+` excludes braces so a regex-constrained
param like `:path{[0-9]+}` stays single-segment. Mirrors the framework's own
`patternToRegex` (pages/manifest.ts). Applied byte-identically to both the
dispatch read-side (matchSegmentPattern) and the api write-side prewarm matcher,
which must stay in sync.

Framework #268 normalizes scanned catch-alls to `*` at the source, so new
managed deploys send `*` (already handled); this keeps parity for pre-#268
routing blobs and user-authored void.json `:name{.+}` keys, which reach dispatch
untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): write prewarm cache metadata with the field dispatch reads

The deploy prerender-warm path wrote KV metadata as `{ s, context, t }`, but
dispatch's readIsrCache requires `metadata.c` (createdAt) and returns null
without it — so every warmed entry was a permanent cold miss (bytes present but
unreadable), defeating prewarming entirely. The object was not annotated against
the reader's type, so the typecheck never caught the drift.

Move the canonical IsrCacheMetadata shape into @voidcloud/isr-shared (the shared
dep both dispatch and api already use), have dispatch re-import it, and annotate
the prewarm write `satisfies IsrCacheMetadata` — future field drift is now a
typecheck error. The api warm test verifies dispatch-readable metadata (c/t/s),
not just that bytes exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): design 0077 — route-aware ISR TTL resolution

Cross-repo design for the revalidate-map precedence gap surfaced in PR #228
review: a catch-all page's TTL can be applied to a more-specific non-ISR page
(or an API route) because dispatch resolves per-path TTLs from a flat
pattern->TTL first-match map that has lost route specificity.

Design: framework emits an ordered `pages: [{pattern, revalidate?}]` list in its
authoritative match order; platform passes it through the routing blob; dispatch
first-matches over it (mirroring the worker's own resolution) to pick the winning
page route, then applies that route's TTL (0 when it opts out), falling back to
the flat map when no page matches or `pages` is absent. Plus a dispatch-side
SSR-path guard so a catch-all never caches /api/* or extension paths.

Includes prior-art research (Next/Vercel, Nuxt/Nitro, SvelteKit, Astro, Remix):
per-resolved-route TTL + specificity-first resolution is the universal pattern;
a flat first-match map is the anti-pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): plan 0078 — route-aware ISR TTL implementation plan

Task-by-task SDD plan for design 0077. Phase A (framework: emit ordered
manifest.pages), Phase B (submodule bump), Phase C (platform: api passthrough,
dispatch resolvePageTtl at both ISR sites, SSR-path API guard, route-aware
prewarm). Each task is TDD with a revert-proof test; cross-repo sequencing holds
#228 until the framework PR merges and everything is green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): consolidate 0077 design + plan into one doc; drop 0078

Repo convention keeps design + SDD plan in a single numbered doc (cf. 0075/0076).
Fold the implementation plan into 0077 as an "Implementation Plan" section and
delete the separate 0078. Add lineage cross-links to 0008 (ISR), 0020 (deploy
prerender — the flat-map TTL matching this supersedes), and 0048 (ISR KV store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): document config-inheritance known limitation in 0077

Route-aware resolver deliberately diverges from the framework's flat-TTL
runtime: a matched page with no per-page revalidate resolves to 0, losing
config-pattern/global-fallback ISR inheritance. Verified real via control-flow
trace; human-accepted as a documented tradeoff for fixing catch-all shadowing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(isr): accept manifest.pages (api type + validation)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(isr): pass manifest.pages through the routing blob

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(isr): resolvePageTtl — route-aware TTL over the pages table

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(isr): resolve dispatch ISR TTL route-aware at both cache sites

Switch the two dispatch ISR sites (read ladder + write-back) from
resolvePathTtl(routing.revalidate, pathname) to
resolvePageTtl(routing.pages, routing.revalidate, pathname), and widen
their gates so a pages-only manifest (no flat revalidate) still activates
ISR. Backward compatible: when routing.pages is absent, resolvePageTtl
behaves identically to resolvePathTtl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): guard the primary ISR ladder to SSR page paths (skip /api/* + extensions)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): never ISR-cache a fallback that rewrites a page 404 to /api

On an ISR miss, applyWorkerFallbackRules can rewrite a page 404 into a
200 from /api/*. The miss-write (~index.ts) and background-revalidation
write both gated on status only, caching that dynamic API body under the
original page URL — so every later request to the page served one
caller's API response as a stale "page".

Extract a shared isApiPath() predicate and gate both fallback write
sites on the effective POST-fallback pathname (isApiPath(effective…)),
mirroring how the normal write-back already skips /api via its
requestType === 'ssr' guard. The read gate is refactored to route
through a composed isIsrEligiblePath() (behavior-preserving).

Scope is deliberately /api only, NOT extension targets: a fallback to a
SPA /index.html shell is legitimate page content and MUST still cache
(covered by the existing WfP/framework SPA fallback tests in
fallback-rules.test.ts, which the broader extension gate would regress).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): never ISR-cache a response with a Cache-Control opt-out (private/no-store/no-cache)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): match Cache-Control opt-out case-insensitively

Cache-Control directive names are case-insensitive (RFC 9111 §5.2), but
hasCacheOptOut used a case-sensitive regex, so 'No-Store'/'PRIVATE'/'No-Cache'
bypassed every ISR write guard and could cache explicitly-private dynamic
content under a shared page URL. Add the /i flag + a mixed-case regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): evict stale ISR entry when background revalidation returns a Cache-Control opt-out

When a background re-render returns a fresh 200 that now declares a
Cache-Control opt-out (private/no-store/no-cache), C5c correctly skips
the write, but the existing STALE shared entry was only deleted on
x-revalidate: 0 (ttl === 0). The old public copy kept being served after
a page became private — a staleness / data-exposure risk. Also evict
(R2/KV + HTML edge + JSON edge) when the fresh render is a 200 with a
Cache-Control opt-out. The /api-fallback transient-404 case is left
untouched (stale-while-revalidate resilience).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): only evict ISR entry on an authoritative 200 opt-out render (no transient/api over-eviction)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): keep explicitly-configured extension SSR pages in ISR (not 1-year static)

An SSR page whose path ends in an extension (`/feed.xml`, `/sitemap.xml`)
was diverted from the ISR ladder by `isIsrEligiblePath`'s blanket
`!hasExtension` filter, then cached under a deployment-versioned static
edge key with `s-maxage=31536000` — a 1-year cache that ignored the
route's revalidate TTL until the next deploy. On origin/main these went
through ISR with their real TTL; the `!hasExtension` filter was the
regression.

Make the read-gate route-aware: an extension-bearing path is ISR-eligible
only when a matched route EXPLICITLY declares the extension (a literal dot
in the pattern's final segment, e.g. `/feed.xml`), with a positive TTL. A
broad catch-all (`/:path{.+}`) that merely happens to match a static asset
like `/robots.txt` stays out. New `isExplicitExtensionRoute` /
`patternDeclaresExtension` helpers mirror `resolvePageTtl`'s match order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): document irreducible dynamic-fallback residual + extension-route ISR limits

Finding 1 (non-/api dynamic fallback with no Cache-Control) cannot be closed
without breaking legitimate page->page fallback caching + SPA-shell fallback —
both are byte-identical to the leak case in every dispatch-visible signal.
hasCacheOptOut (declared intent) is the only viable discriminator. Record the
boundary at both ISR write sites and in 0077 Known Limitations, alongside the
extension-route ISR eligibility limit from the finding-2 fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): only ISR-cache a fallback render when the target is a positively-identified page/shell

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): update 0077 — dynamic fallback leak CLOSED via positive target ID

Supersedes the earlier 'irreducible residual' framing: the pages table gives
dispatch positive identification of cacheable fallback targets (extension shell
or declared ISR page), so /graphql-style dynamic fallbacks are no longer
shared-cached. Records the narrow catch-all-pages residual and why the normal
write-back is not a serving vector.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): require HTML render for dotted fallback caching + enforce literal extension patterns

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): reflect HTML-render fallback gate + literal-extension rule in comments and 0077

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): require an exact literal declared-page match for fallback caching (close catch-all bypass)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs+chore(isr): reconcile 0077 (catch-all bypass closed) + fmt/lint cleanup

- 0077: fallback gate now requires an exact literal declared-page match
  (matchesLiteralPage); catch-all/param pages no longer vouch — residual (b) closed.
- oxfmt reflow of 0077 + api-deploy.test.ts (this-session formatting debt).
- routing.test.ts: replace `as any` (from C2) with `as unknown as DeployManifest`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): cache fallback only for exact declared pages; allow dotted dynamic page params

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): reflect full fallback close (no extension arm) + non-catch-all dotted eligibility in 0077

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): matchesLiteralPage honors first-match route precedence (no later-literal bypass)

matchesLiteralPage scanned the whole ordered pages table and returned true
for ANY matching literal entry, skipping non-literal entries instead of
stopping at the first route that matches. This broke the first-match routing
invariant (0077): a catch-all declared BEFORE a literal /graphql renders that
path dynamically in the framework, yet the later literal /graphql wrongly
vouched for a /* -> /graphql fallback, shared-caching per-user HTML under the
page key (cross-user leak).

Now stop at the first route that matches and authorize only when that WINNING
route is an exact literal page with a positive TTL. Adds a precedence unit test
(both orderings) and a dispatch-level fallback regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): evict a permanently-stale entry after a bounded grace when it cannot be refreshed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): document bounded stale-convergence grace (24h) in 0077

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): measure stale-eviction grace from stale-onset, not entry creation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): route-aware deploy prewarming (pages table + first-match TTL)

Deploy prewarming was route-unaware, unlike dispatch's read path:
1. The prerender gate required manifest.revalidate, so a pages-only manifest
   (ISR TTLs live only in manifest.pages, no flat map) skipped prewarming.
2. Each prerender path resolved its TTL via the flat resolvePathTtl, never the
   ordered pages table — so a specific non-ISR page beneath a flat catch-all was
   prewarmed with the catch-all's TTL, contrary to dispatch's first-match policy.

Add a local resolvePageTtl mirroring dispatch's resolvePageTtl (first-match over
the ordered pages table, then flat-map fallback), widen the gate to also fire on
manifest.pages, and resolve each prerender path route-aware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): grace-evict a stale entry only on origin 404/410 removal evidence, never a transient failure

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): grace-eviction requires origin 404/410 removal evidence (0077)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): apply the fallback positive-ID gate at the normal write-back (all 3 ISR write sites consistent)

The normal write-back (site 3, requestType === 'ssr') did not apply the
fallback positive-ID gate the miss-write and background-revalidation sites
enforce. A request excluded from the ISR read ladder (e.g. /api/*) that 404s
and is rewritten by a fallback rule to an extensionless dynamic target
(/graphql) was written into the ISR cache under the fallback TARGET key on the
strength of requestType === 'ssr' alone — bypassing the
fallbackResult.applied / declared-literal-page / HTML checks.

That write was orphaned (keyed <target>#rw=hash(<original non-ISR-eligible
path>), which no later request recomputes), so this is not a live cross-user
leak, but it is a wasted KV write and a defense-in-depth gap. Hoist a
writebackFallbackApplied flag (fallbackResult is block-scoped, mirroring the
miss-write's missFallbackApplied) and gate the write with
(!writebackFallbackApplied || isFallbackTargetCacheable(routing.pages,
pathname, response)). All three ISR write sites now apply identical fallback
policy: a non-fallback SSR render caches as before; a fallback render caches
only when the target is a positively identified declared literal page
rendering HTML.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): route-aware TTL in dispatch README + pre-existing static-candidate shared-cache known limitation (0077)

The static-candidate edge branch (index.ts ~848-865/~1027) 1-year-caches a
dynamic dotted SSR 200 lacking Cache-Control under a shared, non-user-keyed
edge key. Verified REAL cross-user (write key == read key) but PRE-EXISTING:
the branch is byte-identical to the merge base and shouldBypassIsr already
diverted cookied/Authorization (per-user) requests there on main. This PR only
widens the uncookied-under-broad-catch-all sub-case duration (short ISR ->
1-year static). Documented as a bounded known limitation with a follow-up fix
sketch (positive ETag/Last-Modified validator signal) rather than expanding this
ISR-scoped PR into the shared static-asset gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): document two LOW final-review findings as bounded known limitations (0077)

Whole-branch adversarial review (PASS) surfaced two LOW items, both adjudicated
to document rather than fix (proportionate; each 'clean' fix duplicates dispatch's
dotted-eligibility gate a third time into api or needs a shared-code refactor):
- prewarm gates on resolvePageTtl>0 only (not the read-side isIsrEligiblePath), so
  a prerendered dotted-under-broad-catch-all or /api path gets an orphaned ISR KV
  write dispatch never reads — harmless wasted write, no wrong-serve, no leak.
- matchSegmentPattern('/docs/*','/docs') is false while Hono's /docs/* matches bare
  /docs, so a bare parent under an anonymous catch-all is served live instead of
  ISR-cached — pre-existing, benign under-match direction (never a wrong-serve).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): clearIsrCache sweeps the legacy v2 namespace too (reap orphaned pre-v3 entries)

codex P2 (isr-shared:12), verified: after the v2->v3 key bump, clearIsrCache
listed only <proj>/isr/v3/<D>/, so retention/failed-deploy eviction of a
pre-v3 deployment orphaned its <proj>/isr/v2/<D>/* entries forever — ISR KV
puts set no expirationTtl and no later active-route revalidation ever names an
evicted deployment id again, making clearIsrCache the last reaper. The purge
paths (revalidate-core.ts) were already legacy-aware; cleanup was the one
KV-deleting path that wasn't. Add an isrLegacyCachePrefix (v2) sweep alongside
the v3 one (prefixes are disjoint; nothing deleted twice). Transition-only.

Regression test asserts both namespaces are reaped and a sibling deployment's
entries survive; RED-verified (fails without the v2 sweep).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): config-driven page ISR preserved via build-time TTL resolution (reverse 0077 config-inheritance acceptance)

Reverses the 2026-07-09 'config-inheritance' Known Limitation after codex P1
(framework #272) surfaced that (1) a flat-map fallback is unsafe (re-opens
catch-all shadowing) and (2) a global-number void.json config made EVERY page
resolve to 0 — page ISR OFF site-wide, a regression #272 introduced. Fixed in
framework 9148de4: collectPages resolves each page's TTL at build time from
its own export then void.json routing.revalidate (source-separated, config-only,
faithful matchSegmentPattern port). Shadowing fix preserved; narrow straddle
residual documented (benign under-cache, never wrong-serve).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): static-candidate edge write requires a full 200 (bars 206 Range partials)

A Range GET on a dotted SSR path bails out of the ISR ladder (Range is
excluded there) and falls through to the static-candidate write. The
origin emits a 206 Partial Content; the prior '.ok' gate (200–299)
admitted it, persisting a partial body under the shared <path>?_v=<version>
edge key with s-maxage=31536000 — a later full request could then be served
that truncated body. Tighten the gate to 'status === 200', mirroring the
ISR write sites. Adds a RED-verified test (206 not cached; full 200 still
cached) covering the static edge path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): object routing.revalidate is not baked into per-page TTLs (final, supersedes probe baking)

Records the framework decision (void#272): the interim probe-based
object-config baking could over-cache a per-path `0` opt-out, so it was
removed. Bake only per-page `export const revalidate` + global-number
routing.revalidate; object config gets no page ISR through the table
(matched pages resolve 0 via resolvePageTtl). Documented as an accepted
per-path-vs-per-page expressiveness limitation with the correctness
rationale (no per-path opt-out can be violated because nothing collapses).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): prewarm honors x-revalidate:0 / Cache-Control opt-out (parity with edge write gate)

Deploy-time ISR prewarm wrote any positive-manifest-TTL, 2xx render into
ISR KV with no opt-out check, while dispatch's live-miss and background
write paths (hardened this branch) refuse/evict responses that opt out via
`x-revalidate: 0` or `Cache-Control: private/no-store/no-cache`. Because
dispatch serves warmed entries as HITs without re-checking — and
serializePrerenderHeaders strips Cache-Control at write time — prewarm was
the one vector that could seed an opted-out body an anonymous request then
receives as an ISR HIT for the whole TTL window.

Mirror the dispatch gate at the prewarm write: compute
resolveRevalidateTtl(response, ttl) and store only when
status === 200 && effectiveTtl > 0 && !hasCacheOptOut(response), persisting
the resolved (reducible) TTL. Both helpers are inlined next to the existing
inlined dispatch helpers (packIsrValue/resolvePageTtl/…) — isr-shared does
not export them. RED-verified test: x-revalidate:0 and Cache-Control:private
renders are not warmed; a normal 200 still is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(isr): dispatch resolves object routing.revalidate per-path (3-state pages + pathRevalidate)

Platform side of the object routing.revalidate fix (codex P1 on the pages
table). Consumes the framework's 3-state pages table + new manifest.pathRevalidate:

- resolvePageTtl / matchedPageTtl (dispatch isr.ts, mirrored in the api prewarm):
  a matched page with baked N → N; with baked 0 → explicit opt-out (0, no
  fallback); with ABSENT revalidate → inherit-eligible, resolve pathRevalidate
  against the ACTUAL request path (honors per-path 0 opt-outs a dynamic pattern
  straddles). matchesLiteralPage + isDottedPathIsrEligible get the same
  treatment so fallback-cache authorization and dotted-path eligibility follow
  the same per-path resolution.
- pathRevalidate threaded: RoutingEntry (dispatch) ← buildRoutingJson (api) ←
  validated manifest.pathRevalidate (api validateManifestShape) ← manifest.
- api prewarm resolvePageTtl passes manifest.pathRevalidate so warming skips a
  per-path 0 opt-out and warms a per-path wildcard TTL.

RED-verified: breaking matchedPageTtl's inherit branch fails the per-path
tests (unit + end-to-end ISR ladder). 0077 updated to record the final design.
Backward compatible; framework pointer intentionally untouched (Phase B gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(isr): document two bounded ISR known limitations from adversarial review (0077)

Verified both codex findings via full control-flow traces; both are bounded by
shouldBypassIsr (no cross-user auth leak) and documented, not fixed:

- [P1 #272] page catch-all shadows a non-/api routes/ handler → PRE-EXISTING
  (global-number config already did this on origin/main with looser guards;
  /api/* already blocked by isApiPath). Fix is framework collectPages
  route-exclusion with method/overlap caveats — out of scope for this branch.
- [P2 #228] a whole-segment leading-colon revalidate key (/:batchGet) is matched
  as a param by matchSegmentPattern. Near-zero reachability (real colon URLs are
  mid-segment = literal = safe; leading-: segment is not a real URL shape). The
  segment matcher is deliberate — the folded flat map carries generated :slug
  keys that must param-match; matchLiteralPattern (origin/main) never matched
  them, a real bug the segment matcher fixed. Inline pointer added at resolvePathTtl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(isr): oxfmt 0077 markdown emphasis (fix vp fmt --check)

The two known-limitation entries used *emphasis*; oxfmt normalizes to _emphasis_.
The pre-commit hook only fmt's JS/TS, so the md wasn't auto-formatted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(isr): bump framework submodule to merged #272 (2e443b2)

Phase B of design 0077. Points the platform submodule at the squash-merged
`feat/isr-pages-manifest` (#272), which lands `manifest.pages`
and `manifest.pathRevalidate` — the producer side dispatch/api already consume.
Identical tree to the PR-branch HEAD (d132293); hono stays ^4.12.27, matching
the pinned catalog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(isr): targeted revalidate purges routed-but-pending custom domains

The `{paths}` revalidate purge built its target hosts from ACTIVE domains
only. Activation writes the `domain:<host>` KV route BEFORE flipping the DB
row to `active` (activate-domain.ts — ROUTING.put then updateDomainStatus), so
a crash in that window strands a `pending` row that dispatch already serves and
ISR-caches under its own host-scoped v3 keys. The active-only filter never
constructed those keys, so the purge missed them (the pre-v3 hostless v2 delete
cleared them by deployment id — this is the regression the host-scoped bump
introduced).

Include `pending` domains as targets too. `resolveCurrentIsrDeployments` reads
each domain's ACTUAL route, so a routed pending host resolves to the real
deployment id it points at (correct even when later deploys leave its route on
an older id — strictly more correct than the old hostless delete, which only
worked when the pending host still pointed at the current deployment). A
never-routed pending host resolves to only the hedge, whose purges are harmless
no-ops. Domains are only ever `pending`|`active` (deletion removes the row).

Adds a regression test: a routed pending domain's host-scoped ISR key + edge
URL are purged on a targeted revalidate (RED-verified against the active-only
filter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
cpojer pushed a commit that referenced this pull request Sep 11, 2026
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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.

1 participant