Skip to content

fix(edge): keep what a middleware let on — optimized images in the cache, hashed files in the browser - #441

Open
soleil-colza wants to merge 4 commits into
mainfrom
fix/edge-middleware-covered-caching
Open

soleil-colza wants to merge 4 commits into
mainfrom
fix/edge-middleware-covered-caching

Conversation

@soleil-colza

@soleil-colza soleil-colza commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Two caching fixes for what an application's middleware covered and let through: optimized images are now kept in the edge's image cache, and build-hashed files go out as browser-cacheable for their year (still private).

Why

  • /_next/image under a continuing middleware read the image cache but never wrote it. Every such request — every image, for a middleware with no matcher (Next.js runs one on _next/image and _next/static unless a matcher excludes them) — and every browser revalidation (answered 304 only after a full answer) re-read the source and re-ran the transformation; a source behind a middleware of its own ran it again. Next.js's own optimizer keys its cache on href, width, quality and type only, and fetches internal sources with no request headers.
  • A build-hashed file under a continuing middleware that set no Cache-Control went out private, max-age=0, must-revalidate, so each repeat view revalidated every render-blocking script and style sheet (a round trip plus a middleware run per file), where Next.js lets every browser keep such a file for a year.

Changes

  • edge/handlers/image.ts: the store condition no longer excludes middleware-covered requests. The bytes are everyone's: the source is fetched anonymously (fetchImageSource), and what the middleware can change of the result (rewritten parameters, the accept the format is chosen by) is in the key. The middleware's headers, rewrite status and private caching are laid on each answer, hit or miss, never on the entry; validation still neither reads nor writes.
  • core/request/constants.ts: PRIVATE_IMMUTABLE_CACHE_CONTROL (private, max-age=31536000, immutable).
  • edge/handlers/static-file.ts, hosted-static-file.ts: a file the middleware let on, with no Cache-Control of its own, no cookie and no conditioned rule beside it, goes out with the new value when it is the file as itself — immutable, at the name the request asked for (or kept for it by a rewrite that adds a query or a locale), under its own status. A file a rewrite landed another path on, a rewrite status, a non-immutable file, a cookie, a conditioned rule, or a rule that gave the file a lifetime of its own keep private, max-age=0, must-revalidate; a rule's no-store stands (private, no-store); a middleware's own Cache-Control still stands; validation and private delivery stay no-store. (Next.js gives _next/static its year only where nothing set a Cache-Control already, so a rule's stands there too.)
  • runtime/native/image-cache.ts: comment only — it no longer claims the edge keeps nothing a middleware had a say on.

Tests

  • edge/test/image-middleware-cache.test.ts: a miss is kept and the next request is a HIT with no source read and no transformation, carrying that request's own middleware headers, rewrite status and private caching; a browser revalidation is a 304 from the entry; a rewritten request is kept under the rewritten parameters; validation neither reads nor writes.
  • edge/test/static-file-middleware-holding.test.ts: the year for the file, its 304, a rewrite that keeps the file; revalidated for a non-immutable file, a cookie, a conditioned rule, a rewrite from another path, a rewrite status; the middleware's own Cache-Control; private delivery.
  • Locally: the touched and neighbouring edge suites (705 tests), eslint, oxfmt, tsc (edge, core), check:vocabulary.

Notes

  • The native target's file and image caching keep their previous, stricter rules; aligning them is a possible follow-up.

Review fixes

  • CodeRabbit (static-file.ts, a rule's no-store written over with the year): the year now goes only where the answer still carries one — the file's own, or the year-long rule a build gives its assets; a rule's no-store goes out as private, no-store, and any other lifetime a rule gave is revalidated. Covered by new cases.
  • cubic (static-file.ts, a middleware's no-store lost beside a cookie): a middleware's no-store now stands as a rule's does (private, no-store); such an answer was revalidated before, never held for the year, as it is never shareable.

Not changed

  • cubic (image.ts, caching results whose gated source was fetched with middleware-provided request headers): the headers a gated source is fetched with come from the source's own middleware, run on an anonymous request the edge builds from the source's path alone (localRequest, then rewrittenRequest) — never from the visitor's request or the middleware the visitor's image request ran. They are the same for every request for that source, whose href is in the key, as Next.js's own optimizer fetches an internal source with no request headers and keys its cache on href, width, quality and type.

`/_next/image` under a middleware that let the request on read the
image cache but never wrote it (`input.middleware === undefined` in the
store condition). Every such request — every image at all, for a
middleware with no matcher — and every browser revalidation, which is
answered 304 only once a full answer has been made, read the source
again and paid for another transformation, and a source behind a
middleware of its own ran that middleware again.

The bytes are everyone's. The source is asked for as an anonymous
visitor asks for it: none of the visitor's headers or cookies, nor what
the middleware set on the request, travel with it (`fetchImageSource`),
and whatever the middleware can change of the result — the parameters
it rewrote the request to, the `accept` the format is chosen by — is in
the key. What the middleware said to one visitor (its headers, the
status it gave a rewrite, the private caching its say calls for) is
laid on each answer, a hit as much as a miss, and never on the entry;
and it runs ahead of every lookup, so an entry only answers a request it
let on to those parameters. The store condition now drops the
middleware test, and keeps validation and the entry size limit.

A comment left from when the gated `Cache-Control` was a constant of
this module is removed: it said a shared cache must not hand on what a
middleware had a say on, which describes the client headers, not the
edge's own cache. The native target's image cache keeps its own,
stricter rule, and its comment no longer says the edge does the same.

`test/image-middleware-cache.test.ts` covers a miss kept and the next
request answered from it with that request's own middleware headers,
status and private caching, a rewritten request kept under the
parameters it was rewritten to, and validation neither reading nor
writing the cache.
A build-hashed file under a middleware that let the request on, set no
`Cache-Control` of its own, and left nothing that made the answer one
visitor's (no cookie on it, no rule the request selected by a
condition) went out `private, max-age=0, must-revalidate`. Every later
view of a page then revalidated each script and style sheet it cannot
render without: a round trip, the edge, and a run of the middleware per
file, where Next.js has every browser keep such a file for a year.

Such a file now goes out `private, max-age=31536000, immutable`
(`PRIVATE_IMMUTABLE_CACHE_CONTROL`, beside `GATED_CACHE_CONTROL` in
core): still kept by no shared cache, with the shared-cache overrides
taken off as before, but kept by the visitor's own browser. The bytes
under a hashed name never change and the visitor already holds them;
all a revalidation could still do is have the middleware refuse that
visitor what it has.

Only for the file as itself (`heldAsTheFile`): marked immutable, at the
name the request asked for — or kept for it by a rewrite that adds a
query or a locale (`ownName`, set where the classified file is served)
— and under its own status. A file a rewrite landed another path on is
that path's answer, chosen per request, and a status the middleware
gave its rewrite is its say on one request; both stay revalidated, as
does a file not marked immutable, one with a cookie or a conditioned
rule beside it. A middleware's own `Cache-Control` still stands, and a
validation or private delivery is still kept by nobody.

`test/static-file-middleware-holding.test.ts` covers the year for the
file, its 304 and a rewrite that keeps it, and the revalidated, the
middleware's own, and the kept-by-nobody cases.
@arkor-deploy

arkor-deploy Bot commented Nov 3, 2026

Copy link
Copy Markdown

Arkor — Preview deployments for arkorlab-upwind

Use the checkbox below to choose whether this pull request gets preview deployments.

  • Build and deploy previews for this pull request

Checking this builds the latest commit and future pushes. Unchecking stops queued and future builds; a running build can finish and an existing preview stays available.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-03T03:04:16.007944Z 9d43705 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 35 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: ea84ea7d-c431-41d6-ba3f-8bc51d0f3e38
Commits

Reviewing files that changed from the base of the PR and between fd06373 and 9d43705.

Files selected for processing: 2
  • packages/edge/src/handlers/static-file.ts
  • packages/edge/test/static-file-middleware-holding.test.ts
Walkthrough

Edge image requests that pass through middleware can now reuse eligible cached transformations. Static-file responses can use private immutable caching when middleware serves a qualifying file as itself and the response is shareable.

Changes: Middleware-aware edge caching

Layer / File(s) Summary
Static-file identity and cache policy
packages/core/src/request/constants.ts, packages/edge/src/handlers/hosted-static-file.ts, packages/edge/src/handlers/static-file.ts, packages/edge/test/static-file-middleware-holding.test.ts
The static-file path records whether the request named the served file. When middleware does not set Cache-Control, qualifying immutable files receive private, one-year caching if the response is shareable and the file is held as itself. Tests cover rewrites, middleware headers, conditional requests, and private delivery.
Middleware image-cache behavior
packages/edge/src/handlers/image.ts, packages/edge/test/image-middleware-cache.test.ts, packages/runtime/src/native/image-cache.ts
Non-validated image results within the size limit can be cached when middleware ran. Tests cover cache reuse, middleware response headers, rewrites, and validated requests. The native-cache comment states that middleware-affected images are not cached on that target.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Suggested reviewers: k-taro56

Merge Risk: Moderate · previous review fd063

Eligible files can remain in a browser cache for a year despite a configured no-store rule, bypassing later middleware evaluation. Preserve restrictive configured cache directives before merging.

Pre-merge checks: Passed 5
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title names two changes: optimized images from middleware-allowed requests go into the edge image cache, and eligible build-hashed files get a one-year private immutable cache header in the browse…
Docstring Coverage Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentati…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Title check

Explanation

The title names two changes: optimized images from middleware-allowed requests go into the edge image cache, and eligible build-hashed files get a one-year private immutable cache header in the browser. The raw summary covers both: image.ts and image-middleware-cache tests for the first, and static-file.ts, the new PRIVATE_IMMUTABLE_CACHE_CONTROL constant and holding tests for the second. The title is specific and accurate. It is long (101 characters) and uses a colon-free dash clause, but it does not use vague terms and it is not off-topic.

Full details: Docstring Coverage

Explanation

Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 5 functions across 7 files.

Finishing Touches
Generate docstrings
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.
Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 9e03447f-0582-4e49-9e3c-c623d82ea851
Commits

Reviewing files that changed from the base of the PR and between ead498e and fd06373.

Files selected for processing: 7
  • packages/core/src/request/constants.ts
  • packages/edge/src/handlers/hosted-static-file.ts
  • packages/edge/src/handlers/image.ts
  • packages/edge/src/handlers/static-file.ts
  • packages/edge/test/image-middleware-cache.test.ts
  • packages/edge/test/static-file-middleware-holding.test.ts
  • packages/runtime/src/native/image-cache.ts

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

@greptile-apps

greptile-apps Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The changes appear safe to merge, with no new blocking issues found.

Summary

This PR keeps optimized images in the edge cache after middleware lets them through. It also lets browsers keep eligible build-hashed files for a year, while keeping those responses private.

  • Middleware-covered image requests can reuse optimized results.
  • Eligible hashed files stay in the visitor's browser for a year.

Reviews (3) · Last reviewed commit: "fix(edge): keep a middleware's no-store ..." · Reviewed by Greptile

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/edge/src/handlers/image.ts">

<violation number="1" location="packages/edge/src/handlers/image.ts:632">
P2: This now caches results whose gated source was fetched with middleware-provided request headers, but those headers are absent from `imageSourceId`/`cacheKey`; a later variant can receive the first request's transformed bytes. Skip caching when the source fetch depends on middleware request headers, or include the effective source identity in the key.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

// Unkept, every request it covers — every image at all, under a middleware with no matcher — and
// every revalidation of one a browser holds read the source and transformed it again, and ran a
// gated source's own middleware again.
if (!edge.validated && bytes.byteLength <= CACHEABLE_RESULT_BYTES) {

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: This now caches results whose gated source was fetched with middleware-provided request headers, but those headers are absent from imageSourceId/cacheKey; a later variant can receive the first request's transformed bytes. Skip caching when the source fetch depends on middleware request headers, or include the effective source identity in the key.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/edge/src/handlers/image.ts, line 632:

<comment>This now caches results whose gated source was fetched with middleware-provided request headers, but those headers are absent from `imageSourceId`/`cacheKey`; a later variant can receive the first request's transformed bytes. Skip caching when the source fetch depends on middleware request headers, or include the effective source identity in the key.</comment>

<file context>
@@ -621,13 +617,19 @@ export async function handleImage(input: ImageInput): Promise<Response> {
+  // Unkept, every request it covers — every image at all, under a middleware with no matcher — and
+  // every revalidation of one a browser holds read the source and transformed it again, and ran a
+  // gated source's own middleware again.
+  if (!edge.validated && bytes.byteLength <= CACHEABLE_RESULT_BYTES) {
     const entry = new Headers({
       'content-type': image.contentType,
</file context>

@coderabbitai coderabbitai 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.

  • Fix comments: Fix CodeRabbit comments on this PR

Under a middleware that let the request on and set no Cache-Control, a
build-hashed file went out held for the visitor's year whatever a rule of
`next.config` had set on its path: a rule's `no-store`, or a lifetime of
its own, was written over with a year-long one (CodeRabbit). Next.js
gives a `_next/static` file its year only where nothing set a
Cache-Control already (`router-server`), so a rule's stands there.

The year now goes only to a file whose answer still carries one — its
own, or the year-long rule a build gives its assets. A rule's `no-store`
stands, kept from every cache (`private, no-store`, where it used to be
revalidated); any other lifetime a rule gave is revalidated, as before
the year was given.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 2 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Where a cookie or a conditioned rule kept a middleware's own
Cache-Control from standing, a file under it went out revalidated
(`private, max-age=0, must-revalidate`) even where the middleware had
said `no-store`, which the visitor's browser could then keep (cubic). A
middleware's `no-store` now stands as a rule's does: `private, no-store`.

Not changed: cubic's reading that such a file was held for the year —
an answer with a cookie or a conditioned rule is never `shareable`, so
it was revalidated, not kept; the fix is for the `no-store` alone.

This branch has not been deployed

No deployments
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