Skip to content

feat(cloudflare): prewarm canonical RSC navigation requests - #2784

Draft
james-elicx wants to merge 45 commits into
mainfrom
codex/rsc-vary-cache-warm
Draft

feat(cloudflare): prewarm canonical RSC navigation requests#2784
james-elicx wants to merge 45 commits into
mainfrom
codex/rsc-vary-cache-warm

Conversation

@james-elicx

@james-elicx james-elicx commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

  • add a provider-neutral CDN-adapter contract for caches that select response variants from Vary verbatim, plus adapter-owned Vary fields that deploy warmers can reproduce; Cloudflare declares X-Forwarded-Proto without putting Cloudflare-specific policy in core vinext
  • use one canonical browser/deploy request for certified cacheable RSC payloads: bare ?_rsc, RSC: 1, Accept: text/x-component, and no source/router-state reuse headers
  • make Link prefetch, router.prefetch(), soft navigation, and deploy prewarming share that identity, including after an intercepted navigation; a completed prefetch is reused instead of issuing a second RSC request
  • retain normal Next-like contextual requests for dynamic, non-cacheable, partial, and intercepted payloads: hashed _rsc, Next-Url, router state, mounted slots, and interception context remain available where response selection needs them
  • certify canonical eligibility from final prerender responses and persist adapter-owned Vary metadata; fail closed for private/no-store responses, cookies, redirects, unsafe Vary, middleware/config effects, or incomplete source-independence proof
  • isolate Workers Cache variants by the RSC semantic headers, credentials, Host, and Cloudflare's overwritten X-Forwarded-Proto; HTML does not vary on Accept
  • use stable __vinext_html / __vinext_rsc family tags across all Vary variants, hosts, and schemes so tag/path invalidation purges the whole representation family as required by Workers Cache
  • warm every concrete configured host while respecting Wrangler route path scopes, environments, workers_dev, basePath, trailingSlash, and deployment ID; wildcard hosts cannot be enumerated and exact non-wildcard routes intentionally cannot receive query-bearing RSC/probe requests
  • stage safe deployments at 0%, verify the staged version through an uncached metadata probe, warm, then promote; bound both response headers and body draining, cancel late/stalled bodies, and retry partial post-promotion failures

Request and behavior implications

  • Cacheable App Router routes intentionally omit Next-Url, Next-Router-State-Tree, prefetch/segment-prefetch, mounted-slot, and interception headers. Their completed payload is proven source-independent, so those headers would only fragment the shared cache.
  • Non-cacheable or context-sensitive routes keep those headers and hashed URLs, preserving source-route/interception behavior instead of forcing unsafe reuse.
  • Cookie and Authorization requests remain no-store/isolated. Host and scheme are explicit Vary dimensions because Workers Cache's base key is path/query/version rather than hostname or protocol.
  • The broader stable family tags mean invalidating any HTML/RSC path or render tag can purge more variants than one concrete request, but avoids leaving stale variants behind when Cloudflare requires identical tags across a Vary family.
  • Background prefetch can wait for eligibility metadata; a user-initiated navigation waits at most 250 ms before falling back to the normal contextual request, so cache certification does not block navigation indefinitely.

Validation

  • focused post-merge cache/RSC/warm/deploy units: 365/365
  • Cloudflare cache/warm/deploy/config suite: 148/148
  • browser reuse suites: 20/20 Link/client-cache tests and 7/7 router-prefetch/interception tests
  • Cloudflare preview smoke: 15/15, including warm canonical HIT -> first browser-shaped HIT and credential/invalid-Accept isolation
  • vp run vinext#build
  • vp run @vinext/cloudflare#build
  • changed-file format, lint, type, shell, knip, and diff checks are clean
  • four fresh specialist reviews plus /bigbonk: no remaining blockers; one timeout/body-drain finding was fixed and independently re-reviewed clean
  • exact-head CI, CodeQL, Deploy Examples, preview smoke, and performance workflows are green

@pkg-pr-new

pkg-pr-new Bot commented Jul 31, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2784
npm i https://pkg.pr.new/create-vinext-app@2784
npm i https://pkg.pr.new/@vinext/types@2784
npm i https://pkg.pr.new/vinext@2784

commit: ea69eaa

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared ea69eaa against base f9278af using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 5 regressed · 1 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 134.6 KB 137.1 KB 🔴 +1.9%
Client entry size (gzip) vinext 122.1 KB 124.4 KB 🔴 +1.8%
Dev server cold start vinext 2.95 s 2.95 s ⚫ +0.2%
Production build time vinext 3.22 s 3.28 s 🔴 +1.8%
RSC entry closure size (gzip) vinext 113.8 KB 117.5 KB 🔴 +3.3%
Server bundle size (gzip) vinext 191.3 KB 203.0 KB 🔴 +6.1%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

…warm

# Conflicts:
#	tests/cloudflare-cdn-cache.test.ts
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/deploy-preview

@ask-bonk

ask-bonk Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

/bigbonk review — feat(cloudflare): prewarm canonical RSC navigation requests

Reviewed the security- and correctness-sensitive core of this PR: the canonical RSC request/Vary contract (app-rsc-cache-busting.ts, app-rsc-response-finalizer.ts), the Cloudflare CDN adapter's cache-key/Vary enforcement (cdn-adapter.runtime.ts), the deploy-time warm allowlist (cdn-warm.ts), the prewarm-eligibility gates (rsc-prewarm-eligibility.ts, app-rsc-prewarm-meta.ts), the config/middleware matcher refactor, and the Link/navigation prefetch changes.

Ran locally (all green):

  • app-rsc-cache-busting + rsc-prewarm-eligibility + app-rsc-prewarm-meta: 83/83
  • cloudflare-cdn-cache + cloudflare-cdn-warm: 93/93

Overall this is a strong, defense-in-depth implementation. The canonical-request path consistently fails closed (credentials, non-canonical Accept, contextual router headers, .rsc transports, host-partitioned responses, forged proof headers all fall back to per-source header-digest requests), the deploy warmer independently re-validates the response Vary allowlist, and the sensitive spots are extensively unit-tested including non-looping redirect cases. No blocking issues found.

A few non-blocking observations:

1. Minor: pendingLinkPrefetchSetups can leak an entry if runPrefetch never runs

packages/vinext/src/shims/link.tsx:432 adds the setup to the module-level Set before it is scheduled:

pendingLinkPrefetchSetups.add(setup);
const runPrefetch = () => { void (async () => { ... })().finally(() => pendingLinkPrefetchSetups.delete(setup)); };
...
const schedule = window.requestIdleCallback ?? ((fn) => setTimeout(fn, 100));
schedule(runPrefetch);

The only deletion is inside runPrefetch's .finally (link.tsx:876). All the early returns inside the IIFE are fine (the .finally still runs), but if the idle/setTimeout callback never fires (e.g. the page is torn down before it runs), the setup stays in the Set forever. The leak is bounded by the number of scheduled-but-never-run prefetches, so it's low severity — but a defensive cleanup (or clearing on teardown) would avoid unbounded growth on long-lived SPA sessions with many un-executed viewport prefetches.

2. X-Forwarded-Proto cache isolation depends on Cloudflare overwriting the client header

cdn-adapter.runtime.ts:356-361 varies canonical RSC/HTML on Host, X-Forwarded-Proto, and the comment states this relies on "Cloudflare's overwritten X-Forwarded-Proto". This is correct for the Cloudflare runtime (and it's declared as a controlledResponseVaryHeaders entry), so it's a deliberate, documented decision. Flagging only so it stays on the radar: the whole isolation guarantee for scheme rests on the platform always overwriting a client-supplied X-Forwarded-Proto. If a future entrypoint (custom Worker fetch handler, Nitro adapter) ever serves this adapter's responses without that overwrite, a client could vary that header. Worth an assertion or a doc note at the adapter boundary that it is Cloudflare-edge-only.

3. Bundle-size regression is notable

The perf comment shows Server bundle +6.2% and RSC entry closure +3.3% gzip. AGENTS.md calls out keeping feature-specific runtime off the common request path via dynamic imports. app-rsc-handler.ts already grew substantially (+351 lines) and statically imports the new canonicalization helpers. Worth confirming the added prewarm/canonicalization logic that only runs in response-vary mode is behind the existing lazy boundaries and isn't inflating the common (header-digest / non-Cloudflare) path.

Verified as safe (no action needed)

  • injectRscPrewarmManifestMeta streaming injection correctly buffers partial multibyte via TextDecoder({ stream: true }), caps head-scan buffering (maxHeadScanChars) to avoid unbounded buffering on malformed HTML, and pairs removeRscPrewarmManifestInvalidatedHeaders (drops Content-Length/ETag) with injection under the same hasRscPrewarmManifestMeta() guard.
  • The manifest URL injected into HTML is build-time/content-hashed and additionally escapeHtmlAttr-escaped — no XSS surface.
  • matchesHeaderSource extraction is behavior-preserving: stripTrailingSlashForConfigMatch is idempotent and the pathnameHadTrailingSlash default is computed correctly for the new direct callers.
  • The .rsc compatibility / canonical redirect logic is covered by explicit non-looping tests, including encoded %5Frsc keys and encoded .rsc suffixes.

@ask-bonk

ask-bonk Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Posted my review to PR #2784: #2784 (comment)

Summary

I reviewed the security- and correctness-sensitive core of this large (13k line, 100 file) PR, focusing on the canonical RSC request/Vary contract, the Cloudflare CDN adapter's cache-key enforcement, the deploy-time warm allowlist, prewarm-eligibility gates, and the config/middleware matcher refactor. I ran the relevant unit suites locally (176 tests, all green).

Verdict: no blocking issues. This is a strong, defense-in-depth implementation that consistently fails closed and is extensively tested.

Non-blocking findings I posted:

  1. Minor memory leakpendingLinkPrefetchSetups (link.tsx) adds an entry before scheduling runPrefetch but only deletes it inside that callback's .finally. If the idle/setTimeout callback never fires (page teardown), the entry leaks. Bounded, low severity.

  2. X-Forwarded-Proto isolation is Cloudflare-only — the scheme isolation guarantee rests on the platform overwriting the client header. Deliberate/documented, but worth a boundary assertion so a future non-Cloudflare entrypoint can't reuse the adapter unsafely.

  3. Bundle-size regression (Server +6.2%, RSC entry +3.3% gzip) — worth confirming the new response-vary-only logic stays behind lazy boundaries per AGENTS.md perf guidance.

I explicitly verified as safe: the streaming meta injection (multibyte handling, bounded buffering, header invalidation), no XSS in the manifest URL injection, the behavior-preserving matchesHeaderSource extraction, and the non-looping .rsc redirect logic.

github run

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