Skip to content

fix(dev): preserve public asset conditional requests - #2711

Open
Boyeep wants to merge 41 commits into
cloudflare:mainfrom
Boyeep:feat/static-byte-ranges
Open

fix(dev): preserve public asset conditional requests#2711
Boyeep wants to merge 41 commits into
cloudflare:mainfrom
Boyeep:feat/static-byte-ranges

Conversation

@Boyeep

@Boyeep Boyeep commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

What changed

  • mirror Vite and sirv public-file identity in development so equivalent strong or weak If-None-Match validators can take the correct 304 path
  • keep public-file ETags and verified request-path aliases in an in-memory index rather than doing filesystem work on each request
  • preserve Vite filesystem behavior for base paths, encoded paths, case and Unicode normalization, and file or directory symlinks
  • refresh the index from Vite watcher events when public files or filesystem capabilities change
  • fail closed for ambiguous aliases, redirect collisions, malformed paths, and symlink cycles

Why

Vite public-file serving delegates to sirv, whose development ETags use file size and mtime. Equivalent request validators and filesystem aliases could miss sirv exact identity and return 200 instead of 304. This normalizes only matching GET and HEAD validators to the exact indexed weak ETag while leaving non-matching requests untouched.

Scope note

This branch originally implemented static byte ranges. That work landed independently through #2715 while the branch was being prepared. The current diff against main is only the development public-asset conditional-request fix described above.

Validation

@pkg-pr-new

pkg-pr-new Bot commented Jul 26, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: ec3ec0b

@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: e5271927a5

ℹ️ 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 on lines +58 to +59
const timestamp = Date.parse(trimmed);
return Number.isFinite(timestamp) && Math.floor(mtimeMs / 1000) * 1000 <= timestamp;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject non-HTTP dates before honoring If-Range

If-Range only permits an HTTP-date, but Date.parse() also accepts formats such as 12/31/2099 and 2099-12-31. A client resuming an older download with such an invalid future validator will therefore receive 206 bytes from the current file instead of the required full 200 response, potentially combining bytes from different file versions. Validate the HTTP-date grammar before comparing the timestamp.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared ec3ec0b against base 3d1ab6b using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 132.4 KB 132.4 KB ⚫ +0.0%
Client entry size (gzip) vinext 119.8 KB 119.8 KB ⚫ +0.0%
Dev server cold start vinext 2.91 s 2.91 s ⚫ +0.1%
Production build time vinext 3.12 s 3.14 s ⚫ +0.6%
RSC entry closure size (gzip) vinext 105.6 KB 105.6 KB ⚫ 0.0%
Server bundle size (gzip) vinext 179.7 KB 179.8 KB ⚫ +0.0%

View detailed results and traces

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

@Boyeep
Boyeep force-pushed the feat/static-byte-ranges branch from 3988cd5 to 4db2e69 Compare July 26, 2026 16:38
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review

Overall this is a well-structured, thoroughly tested implementation of RFC 9110 byte ranges for the Node production static server. The parseByteRange / ifRangeAllowsRange / parseHttpDate helpers are cleanly separated and well unit-tested, and dev/prod parity is handled on both the startup-cache and filesystem-fallback paths.

The Codex bot comment is already addressed (stale)

The automated Codex review (against e5271927a5) flagged that If-Range might honor non-HTTP dates like 12/31/2099 via Date.parse(). The current head resolves this: ifRangeAllowsRange routes through the strict parseHttpDate grammar (http-date.ts), and tests/http-range.test.ts explicitly asserts 12/31/2099 and 2099-12-31 return false. No action needed there.

Correctness spot-checks (all pass)

  • 304 (If-None-Match) is evaluated before the range branch, so a conditional GET with a matching validator returns 304 not 206 — matches RFC precedence and is covered by tests.
  • Ranges are served from the identity representation while Vary: Accept-Encoding is preserved; weak ETags correctly cause If-Range to fail closed (full 200), avoiding cross-representation byte mixing.
  • resolveRequestedRange disables ranges for any non-200 base status, so error/custom responses can't be partial.
  • Large wire integers are handled losslessly via the "overflow" sentinel.

Main concern: scope

The PR title/body is about byte ranges, but the single largest change is dev-public-etag.ts (551 lines) plus its two test files (~700 lines) — a complex dev-server public-file ETag folding subsystem (case/normalization-insensitive filesystem aliasing, symlink cycle handling, Unicode case expansion). This is only loosely related to byte-range support (shared matchesIfNoneMatch / Last-Modified plumbing) and materially increases review surface and long-term maintenance cost. Consider splitting it into its own PR so each change can be reviewed and reverted independently. See inline notes below.


const simpleCasePatterns = new Map<string, RegExp>();

export function createDevPublicFileEtags(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This 551-line module (dev public-file ETag folding across case/normalization-insensitive filesystems, symlink cycle detection, Unicode case-expansion aliasing) is essentially a separate feature from static byte ranges. It's the bulk of the diff yet unrelated to the PR's stated purpose. Recommend extracting it into its own PR — it would get more focused review and keeps the byte-range change small and independently revertable.

else node.realPath = null;
}
if (redirect) {
if (node.redirect === undefined || node.redirect === redirect) node.redirect = redirect;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Likely a copy/paste bug: the redirect conflict branch sets node.realPath = null instead of node.redirect. When two different redirects collide this poisons realPath (which may be unset) rather than marking the redirect as ambiguous, so node.redirect retains the first value and can still be followed. Compare with the realPath branch just above (line 270-271) which correctly nulls its own field.

Suggested change
if (node.redirect === undefined || node.redirect === redirect) node.redirect = redirect;
if (redirect) {
if (node.redirect === undefined || node.redirect === redirect) node.redirect = redirect;
else node.redirect = undefined;
}

);
const fiftyYearsFromNow = new Date(now);
fiftyYearsFromNow.setUTCFullYear(now.getUTCFullYear() + 50);
if (candidateTimestamp > fiftyYearsFromNow.getTime()) year -= 100;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: when the RFC 850 date has an invalid calendar value (e.g. 31-Feb), candidateTimestamp is NaN, so NaN > fiftyYearsFromNow is false and the year is left unadjusted. The final timestampFromHttpDateParts still returns NaN via its own validation, so the result is correct — but the two-pass structure is subtle. A short comment noting that the candidate pass is only a year-boundary probe (and that invalid dates are rejected by the second pass) would help future readers.

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully. Here's a summary of what I found and posted to PR #2711:

Review summary

Overall: A well-structured, thoroughly tested RFC 9110 byte-range implementation. The parseByteRange/ifRangeAllowsRange/parseHttpDate helpers are cleanly separated with solid unit coverage, and dev/prod parity is handled on both cache and filesystem paths.

Findings posted:

  1. Codex bot comment is stale/resolved — The automated review flagged If-Range honoring non-HTTP dates (12/31/2099). The current head already fixes this by routing through the strict parseHttpDate grammar, with explicit tests asserting those values return false. No action needed.

  2. Likely bug in dev-public-etag.ts:274 — The redirect conflict branch sets node.realPath = null instead of node.redirect = undefined. On a redirect collision it poisons the wrong field, so an ambiguous redirect can still be followed. Posted a suggestion.

  3. Scope creep (main concern) — The PR is titled/described as byte-range support, but the largest single change is the unrelated 551-line dev-public-etag.ts dev-server ETag-folding subsystem (~700 more lines of tests). Recommended splitting it into its own PR for focused review and independent revertability.

  4. Minor readability note in http-date.ts:61 — The two-pass RFC 850 year-boundary probe is subtle (relies on NaN comparisons falling through to the second-pass validation); suggested a clarifying comment.

Correctness spot-checks (all pass): 304 precedence over 206, identity-representation range serving with weak-ETag fail-closed, ranges disabled for non-200 base statuses, and lossless large-integer handling. Ran the http-range/http-date/http-conditional test files locally — 28/28 passed.

github run

# Conflicts:
#	packages/vinext/src/server/prod-server.ts
#	tests/http-conditional.test.ts
# Conflicts:
#	packages/vinext/src/server/http-date.ts
#	packages/vinext/src/server/http-range.ts
#	packages/vinext/src/server/prod-server.ts
#	tests/app-router-production-server.test.ts
#	tests/http-date.test.ts
#	tests/http-range.test.ts
#	tests/serve-static.test.ts
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review

I reviewed the actual diff against main (not the branch commit history), and there is a significant mismatch between what this PR claims to do and what it changes.

🚩 Blocking: PR title/description do not match the diff

The PR is titled "feat(server): support static asset byte ranges" and the body describes byte-range parsing in detail (parseByteRange, 206 Partial Content, 416 Range Not Satisfiable, If-Range, http-range.ts, http-date.ts, Accept-Ranges, etc.).

But the diff against main contains zero byte-range code. git diff origin/main...HEAD --stat is:

 packages/vinext/src/index.ts                  |  42 ++
 packages/vinext/src/server/dev-public-etag.ts | 551 ++++++++++++++++++++++++++
 tests/dev-public-etag-vite.test.ts            | 363 ++++++++
 tests/dev-public-etag.test.ts                 | 390 ++++++++++
 tests/e2e/pages-router/script.spec.ts         |  37 ++

The byte-range files the description references (http-range.ts, http-date.ts) exist in the tree but are not part of this PR — they were merged separately via #2710 / #2713 / #2715. The branch's first commit is e5271927 feat(server): support static asset byte ranges, but that work already landed on main through other PRs. What is left unmerged here is entirely the dev-server public-file ETag folding subsystem.

This matters concretely for this repo:

  • Per AGENTS.md, changesets are auto-generated from the Conventional Commit / PR title. Merging this as-is produces a changelog entry feat(server): support static asset byte ranges for a diff that adds no byte-range code. The changelog will be wrong.
  • Anyone reviewing based on the description reviews the wrong feature.

Action: Retitle to describe the real change (e.g. feat(dev): weak/case-folded If-None-Match for public assets) and rewrite the body. Given the repo's stacked-PR guidance for high-risk work, this 551-line dev subsystem should also stand on its own PR.

Scope / complexity

The net change is a large dev-only subsystem: Unicode case-folding, NFD/NFC normalization-insensitive aliasing, symlink cycle detection, and an 8-candidate bucket cap — all to pre-resolve a client If-None-Match into sirv's exact weak-ETag string so its 304 path fires. It's dev-only and well tested, but it's a lot of surface area and long-term maintenance for a 304 optimization. Worth confirming this complexity is warranted vs. a narrower approach.

Correctness spot-checks (pass)

  • The previously-flagged copy/paste bug (node.realPath = null in the redirect branch) is already fixed at dev-public-etag.ts:273-276 — the redirect branch correctly nulls node.redirect. Stale.
  • Ran tests/dev-public-etag.test.ts locally: 12 passed, 4 skipped (platform-gated). vp check clean on both source files.
  • Security boundary looks OK: resolveDevPublicIfNoneMatch only ever returns an ETag string already present in etagsByRealPath (indexed from within the public tree) and never serves content, so even a mis-resolution yields at worst a spurious 304, not a content leak.

Minor inline notes below.

const leftTokens = expandCaseTokens(normalizedLeft);
return (
leftTokens.length === candidate.expandedCaseTokens.length &&
leftTokens.every((token, index) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The .every((token, index) => ...) callback parameter index shadows the outer index: DevPublicFileEtagIndex. It works today only because the outer index isn't referenced inside the callback, but shadowing a load-bearing name like this is a maintenance hazard — a future edit that needs the outer index here would silently pick up the array index. Consider renaming the callback param:

Suggested change
leftTokens.every((token, index) =>
leftTokens.every((token, tokenIndex) =>
simpleCaseEquivalent(token, candidate.expandedCaseTokens[tokenIndex]!),

segment: string;
node: DevPublicPathNode;
simpleCasePattern: RegExp;
expandedCaseTokens: string[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

simpleCasePatterns is a module-level Map that is never evicted, so every distinct case token seen across the process lifetime accumulates a compiled RegExp. In dev this is effectively bounded by the number of distinct public path segments, so it's likely fine, but a module-global unbounded cache is worth a brief comment noting the bound (or scoping it to the index) so it isn't mistaken for a leak later.

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully to PR #2711.

Summary

The most important finding is a title/description vs. diff mismatch that the prior automated reviews missed because they reviewed the branch's commit history rather than the actual diff against main.

Blocking issue

The PR is titled "feat(server): support static asset byte ranges" and its body describes byte-range parsing in detail, but the actual diff against main contains zero byte-range code. The byte-range work already landed via separate PRs (#2710, #2713, #2715). The entire net change is a 551-line dev-server public-file ETag folding subsystem (dev-public-etag.ts).

This matters because, per AGENTS.md, changesets are auto-generated from the Conventional Commit / PR title — so merging as-is produces a wrong changelog entry. Recommended retitling and (per repo guidance) making the dev subsystem its own focused PR.

Other notes

  • Scope/complexity: large dev-only Unicode case-folding + normalization + symlink-cycle machinery for a 304 optimization.
  • Minor (inline suggestion): index parameter shadowing at dev-public-etag.ts:400.
  • Minor (inline): unbounded module-level simpleCasePatterns cache.

Confirmed passing

  • The previously-flagged node.realPath = null bug is already fixed (stale).
  • tests/dev-public-etag.test.ts passes locally (12 passed, 4 platform-skipped); vp check clean.
  • Security boundary is sound — resolution only ever yields a 304, never content.

github run

@james-elicx james-elicx changed the title feat(server): support static asset byte ranges fix(dev): preserve public asset conditional requests Jul 27, 2026
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review

Reviewed the actual diff against main. This is a well-structured, thoroughly tested dev-only fix: it pre-resolves a client If-None-Match into sirv's exact weak-ETag string so Vite's public-file 304 path fires for equivalent strong/weak/list validators and for filesystem aliases (case, Unicode normalization, symlinks). Ran tests/dev-public-etag.test.ts locally (12 passed, 4 platform-skipped) and vp check on the source file — clean.

Title/description mismatch is now resolved

The PR is retitled fix(dev): preserve public asset conditional requests and the body now describes the dev public-asset fix, matching the diff. The changelog-correctness blocker raised in earlier reviews (byte-range title over a non-byte-range diff) no longer applies — the byte-range work landed via #2710/#2713/#2715 and is not in this diff. Good.

Correctness spot-checks (pass)

  • The previously-flagged copy/paste bug (node.realPath = null in the redirect branch) is fixed at dev-public-etag.ts:273-276 — the redirect branch correctly nulls node.redirect. Stale.
  • Security boundary is sound: resolveDevPublicIfNoneMatch only ever returns an ETag already indexed from within the public tree and never serves content, so a mis-resolution yields at worst a spurious 304, never a content leak. Ambiguous case/normalization aliases and redirect collisions fail closed (return undefined), verified by the fails ... closed tests.
  • Weak/strong/list/wildcard If-None-Match normalization routes through the shared RFC 9110 matchesIfNoneMatch, and non-GET/HEAD methods, missing files, and out-of-basePath requests are left untouched.
  • viteUsesStatLookup is detected once at startup and preserved across rebuilds, mirroring Vite's own one-time stat-lookup decision (covered by the "keeps Vite's exact/stat lookup mode" tests).

Scope / maintenance note

The net change is a large dev-only subsystem (Unicode case-folding, NFD/NFC normalization-insensitive aliasing, symlink-cycle detection, an 8-candidate bucket cap) for what is ultimately a 304 optimization in development. It's well-tested and fails closed, but it's a lot of surface area and long-term maintenance. Per the repo's stacked-PR guidance for high-risk work, this could stand as its own focused PR (it already is, effectively). Worth confirming the complexity is warranted vs. a narrower approach that only folds the common exact-spelling case.

Minor inline notes below — none blocking.

const leftTokens = expandCaseTokens(normalizedLeft);
return (
leftTokens.length === candidate.expandedCaseTokens.length &&
leftTokens.every((token, index) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The .every((token, index) => ...) callback parameter index shadows the outer index: DevPublicFileEtagIndex. It's harmless today only because the outer index isn't referenced inside the callback, but shadowing a load-bearing name like this is a maintenance hazard — a future edit needing the outer index here would silently bind to the array index instead. Rename the callback param:

Suggested change
leftTokens.every((token, index) =>
leftTokens.every((token, tokenIndex) =>
simpleCaseEquivalent(token, candidate.expandedCaseTokens[tokenIndex]!),

expandedCaseTokens: string[];
};

const simpleCasePatterns = new Map<string, RegExp>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

simpleCasePatterns is a module-level Map that is never evicted, so every distinct case token seen across the process lifetime accumulates a compiled RegExp. In dev this is effectively bounded by the number of distinct public path segment characters, so it's fine in practice — but a module-global unbounded cache is worth a one-line comment noting that bound (or scoping it to the index) so a future reader doesn't mistake it for a leak.

// Vite converts native separators on Windows before applying POSIX path
// normalization. `toSlash` is platform-gated, so a literal backslash
// remains a valid filename character on POSIX.
pathname = normalizePath(toSlash(decodeURI(pathname)));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This uses decodeURI rather than Next.js's segment-wise decodePathParams/decodeURIComponent, so %2F etc. stay encoded and the structural .. in %2e%2e gets resolved by normalizePath. That appears to intentionally mirror sirv's own decode behavior (and the Vite integration tests exercise the real decode path end-to-end), so this is likely correct — flagging only so a future reader knows the divergence from the request-pipeline decoder is deliberate and parity-driven, not an oversight.

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully to PR #2711.

Summary

I reviewed the actual diff against main and verified the code, tests, and prior review findings.

Key conclusions

  1. Title/description mismatch — now resolved. Earlier reviews flagged the PR as a byte-range title over a non-byte-range diff (a changelog-correctness blocker, since changesets auto-generate from the title). The PR has since been retitled to fix(dev): preserve public asset conditional requests and the body rewritten to describe the dev fix. This matches the diff. No longer blocking.

  2. Previously-flagged bug is fixed. The node.realPath = null copy/paste bug from the first Codex/ask-bonk pass is already corrected at dev-public-etag.ts:273-276 — the redirect branch correctly nulls node.redirect. Stale.

  3. Correctness verified. Ran tests/dev-public-etag.test.ts locally (12 passed, 4 platform-skipped) and vp check on the source (clean). Confirmed the security boundary is sound (only ever returns a 304, never content), ambiguous aliases and redirect collisions fail closed, and viteUsesStatLookup is preserved across rebuilds to mirror Vite's one-time decision.

Minor inline notes (non-blocking)

  • dev-public-etag.ts:400index callback param shadows the outer DevPublicFileEtagIndex; posted a rename suggestion.
  • dev-public-etag.ts:33 — module-level simpleCasePatterns cache is unbounded; suggested a bounding comment.
  • dev-public-etag.ts:180decodeURI (vs the request-pipeline decoder) appears to be a deliberate parity-with-sirv choice, exercised by the Vite integration tests; flagged so it reads as intentional.

Scope note

The net change is a large dev-only case-folding/normalization/symlink subsystem for a 304 optimization. Well-tested and fails closed, but worth confirming the complexity is warranted vs. a narrower exact-spelling approach.

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.

2 participants