Repository navigation
Update deps. - #256
Update deps.#256
Conversation
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
## Problem Consumers of `@void/md@0.10.6` that don't have `markdown-it` in their tree fail to build: ``` error during build: Build failed with 95 errors: Error: Cannot find module 'markdown-it' - node_modules/markdown-it-attrs/index.js ``` (Real failure on napi.rs website `main`: [run #28873381156](https://github.com/napi-rs/website/actions/runs/28873381156).) ## Root cause `#256 "Update deps"` bumped `markdown-it-attrs` `^4.3.1` → `^5.0.0`. That major added a top-level `require('markdown-it')`: ```js // markdown-it-attrs@5.0.0/index.js const createMarkdownIt = require('markdown-it'); // line 3, module load ... const defaultFence = createMarkdownIt().renderer.rules.fence; // line 112, only for the new `fenceAttrsOnPre` option ``` `@void/md` ships the **markdown-exit** fork and no `markdown-it`, so this previously-optional peer became a hard, unresolvable dependency for any consumer that doesn't happen to have `markdown-it` installed. **Why CI was green:** void's monorepo resolves `markdown-it@14.1.1` from other tooling, silently satisfying the peer. Downstream consumers (yarn/npm without markdown-it) don't get that, so they explode on install → build. ## Fix Pin `markdown-it-attrs` back to `^4.5.0` — the version void shipped through 0.10.5. | | 4.5.0 | 5.0.0 | |---|---|---| | top-level `require('markdown-it')` | ❌ (only JSDoc `@typedef`) | ✅ line 3 | | `{.class #id}` / `curly_attributes` rule | identical | identical | | `fenceAttrsOnPre` option | n/a | new — **void never uses it** | void applies attrs to its own markdown-exit instance and installs its own shiki fence renderer, so no 5.x behavior is lost. ## Validation - ✅ `@void/md` build (`vp pack`) — clean - ✅ md unit tests 30/30 (compile / plugins / metadata), incl. `compile('Hello {.greeting}')` and the `{1,3-5}` fence line-highlight test - ✅ lockfile: 0 remaining refs to `markdown-it-attrs@5` >⚠️ Renovate will try to re-bump this to `^5.0.0`. Consider a renovate ignore/rule so it doesn't regress, or migrate `@void/md` off the markdown-exit fork for attrs. Staying on markdown-exit means attrs must not hard-require `markdown-it`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Dependency downgrade only; attrs behavior used by void (`{.class}`) matches 4.5.0, with no application code changes. > > **Overview** > **Downgrades `markdown-it-attrs` from `^5.0.0` to `^4.5.0`** in `@void/md` so consumers without `markdown-it` in their dependency tree can build again. Version 5.x loads `markdown-it` at module init; this package compiles with **markdown-exit** and does not ship `markdown-it`, which caused `Cannot find module 'markdown-it'` for downstream apps. > > The lockfile is refreshed for the new attrs version; unrelated resolution/tarball metadata churn in `pnpm-lock.yaml` is incidental. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fa6ef09. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…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>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
…work (#259) * fix(api): bound the source download, and make GitHub's Re-run button work Build bld_33gs31a2x6qj (voidzero.dev) ran 16m52s and failed "Build exited with code 1" with a single log line. Cloudflare telemetry showed the container did nothing for the whole run: 2.5s of CPU total, memory flat at the agent's own 145 MB, disk grown 5.4 MB, ~4.4 MB received of a 20.8 MB tarball in bursts separated by dead minutes. A successful build of the SAME repo 12h earlier: 2137 MB memory, 968 MB disk, 1521 KB/s peak. The BuildManagerLarge DO polled the agent ~60/min for the full 17 minutes, every alarm `success` — log transport was healthy; the agent was blocked in downloadSource() with nothing to report. `downloadSource` fetched the tarball with no timeout, no stall detection and no retry. undici cannot catch this: `bodyTimeout` resets on every chunk, so any trickle defeats it. Bound it on three axes: - SOURCE_STALL_TIMEOUT_MS (20s with NO bytes) is the guard that does the work. A healthy download streams continuously, so it cannot fire on one. - SOURCE_PHASE_BUDGET_MS (240s) is the only wall clock and spans every attempt together, sized inside the smallest plan budget (free = 10min). - 3 attempts, each from an empty WORK_DIR. 401/403/404/410 fail on the first attempt; 408/425/429/5xx, transport errors and a non-zero tar exit retry. Deliberately NO per-attempt wall clock and NO throughput floor: either would newly fail a large repo on a slow but honest link. The download also logs progress every 15s. Before this the agent logged one line before the fetch and the next only after extraction, so a 17-minute stall was indistinguishable from a busy build in the live tail AND in the check-run tail. That opacity is why the incident took telemetry to diagnose. extractTarball settles ONLY from the child's 'close' (SIGTERM then SIGKILL after EXTRACT_KILL_GRACE_MS). Settling from the pipeline rejection let the retry loop rm -rf WORK_DIR under a live tar, and the old child's later 'close' cleared `activeChild` out from under the new attempt — making a subsequent cancel a silent no-op. `activeChild` is now cleared by identity. The WORK_DIR reset is async: this agent is PID 1, and a synchronous recursive delete blocks the stall watchdog, the control server (so /cancel and the DO's log pull stop answering) and the event loop. The phase deadline is re-checked after it. Separately, the check-run failure summary now carries onStop's `reason` ("Build exited with code 1 (exit).") — the one bit that separates an agent exit from a runtime kill, which the DB row always had and the surface most people see did not. Finally, GitHub's "Re-run" / "Re-run all checks" buttons now do something. There was no `check_run` case at all: the button rendered, the click delivered an event, and the API answered "unhandled" — so the only way to retry a failed build was to push a new commit. `rerunBuildForCommit` reuses the PUSH pipeline (connection resolution, renamed-repo self-heal, the fail-closed branch-head gate, supersedeAndCreateContainerBuild) so a rerun can never deploy a commit a push could not. NOTE: the App must SUBSCRIBE to `check_run` and `check_suite`. That switch is separate from `Checks: Write`; without it GitHub renders the button, delivers nothing, and the click stays a no-op. See meta/operations.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EahVSpn3Gx6qsisjxYGhC * chore: reformat sandbox.test.ts for the current oxfmt `vp fmt --check` has been red on main since the vite-plus 0.2.9 bump (#256); #257 and #258 both merged with lint-and-check failing. This is a mechanical oxfmt-only change, unrelated to the build-agent work in this PR, but CI gates on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EahVSpn3Gx6qsisjxYGhC * fix(api): close the adversarial-review findings on the rerun path Six rounds of review against the source-download bounds and the Re-run webhook. Each finding was reproduced in the code before being acted on, and each fix is pinned by a test that fails when the fix is reverted. Re-run webhook: - A per-check rerun now targets ONE project. `external_id` (the build id `createCheckRun` stamps on every run) resolves the originating build and narrows candidates to its project. `project_id` is unique on `github_connections` but repo+branch are not, so resolving by branch alone rebuilt every sibling project from a single click — and could enqueue a container build from a click on another project's `github_actions` check. - It FAILS CLOSED when that build cannot be resolved. Deleting a project cascades its build rows away while GitHub keeps check runs for months, so the branch fallback would let an orphaned check rebuild siblings owned by other users. - `head_branch` is no longer a filter. GitHub keeps one suite per app per commit even when that commit heads several branches, so filtering on it dropped the connection of a project tracking the other branch at the same SHA. The per-branch freshness gate decides instead, and it is exact. - A rerequest that enqueues nothing now COMPLETES the clicked run with a reason, because GitHub flips the suite back to `queued` on the click. A refusal ECHOES the run's existing conclusion and never writes one of its own: GitHub counts `neutral`/`skipped` as PASSING for a required status check, so completing a run we did not run with a passing conclusion would let anyone with rerun rights clear branch protection. `stale` is observable but not writable, so it maps to `cancelled`. Only an authoritatively `complete` AND live build writes `success` — that reuse branch returns the existing row NON-null, which used to read as an enqueue and poke a scheduler that could not claim it. Every other reuse is real work, including `queued`. - Reporting runs on `executionCtx.waitUntil`, off the response path. GitHub drops a delivery unanswered for 10s and does not redeliver failures automatically, while `completeCheckRun` mints a token and retries with no clock of its own. Source download: - `extractTarball` settles ONLY from the child's `close`, so a retry cannot wipe WORK_DIR under a live extraction. A real `tar` dies too promptly to prove that, so the test injects a child that outlives the rejection. - 403 is classified from headers and a bounded body prefix, not status alone: GitHub answers 403 for a permission failure AND for both kinds of rate limit. Status and headers classify FIRST — reading the body of a response already known to be rate limited could only lose its `retry-after` to the stall watchdog. A rate-limited response naming no delay waits the documented minimum; `x-ratelimit-reset` counts only when `x-ratelimit-remaining` is `0`, since it rides along on every response and an hour-away routine reset would turn a retryable 500 fatal. - Every non-ok response body is cancelled. undici holds the socket for one that is neither consumed nor cancelled. - `WORK_DIR` takes a test-only override. `build-manager.test.ts` wipes the same absolute path in a parallel worker, which made the real-tar harness flaky. Deliberately NOT changed, with reasoning recorded in code and README: a `check_suite` refusal reports nothing (the Checks API cannot conclude a suite, and both ways to infer its runs are unsafe or unverified), and `sender` is not authorized against per-project deploy rights (no such mapping exists — a connection carries one Void user). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EahVSpn3Gx6qsisjxYGhC * fix(api): compare rerun branches concurrently A `check_suite` fan-out owes one branch-head compare per DISTINCT branch. Awaiting them in series multiplied the single compare the push path affords by the branch count, against GitHub's 10s delivery deadline — which GitHub enforces by dropping the delivery, with no automatic redelivery, so the Re-run click is simply lost. Run them through `Promise.all`. The compares write distinct keys with no read-modify-write on `freshness`, and `getBranchHeadComparisonStatus` resolves rather than throws on every failure path, so one bad branch cannot become a 500. Latency now tracks the slowest branch instead of their sum. Warm the installation token first: `getInstallationToken` memoizes per installation, so the serial loop paid one mint and reused it, while a cold cache under `Promise.all` would stampede N mints. The warm-up ignores its own failure — each compare still mints and retries on its own. Codex asked for the whole rerun to move to durable/background processing instead. Refused: that is an architectural change to webhook handling that the push path would have to follow, and the per-branch multiplication is the only part this feature added. `getBranchHeadComparisonStatus` having no fetch timeout is the true root cause, but push and branch-delete share it, so that is a separate change. The new test parks each compare until both are in flight at once. With the serial loop restored it fails `expected 1 to be 2` after both barrier deadlines expire. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EahVSpn3Gx6qsisjxYGhC --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Updates all the deps except TypeScript and some wrangler thing because they are currently causing issues.