Skip to content

fix(image): preserve middleware ordering for source requests#2554

Draft
james-elicx wants to merge 1 commit into
mainfrom
codex/fix-image-middleware-ordering
Draft

fix(image): preserve middleware ordering for source requests#2554
james-elicx wants to merge 1 commit into
mainfrom
codex/fix-image-middleware-ordering

Conversation

@james-elicx

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

Copy link
Copy Markdown
Member

Summary

  • route local image optimizer source requests through the normal application request pipeline
  • prevent recursive optimizer dispatch while keeping source requests credential-free
  • preserve streamed image handling across App Router, Pages Router, Node production, and Workers
  • filter source response headers so middleware-only headers are not exposed by the optimizer
  • add Pages Router production regressions using the existing fixture and Playwright project

This change intentionally preserves the optimizer's existing status handling and transform-fallback behavior; it only changes how local source requests are dispatched.

Tests

  • vp check packages/vinext/src/server/image-optimization.ts packages/vinext/src/server/app-router-entry.ts packages/vinext/src/server/pages-router-entry.ts packages/vinext/src/server/prod-server.ts tests/e2e/pages-router-prod/image-middleware-ordering.spec.ts tests/fixtures/pages-basic/middleware.ts tests/shims.test.ts tests/features.test.ts
  • vp test run tests/shims.test.ts -t "credential-free GET|recursive local image optimizer|cancels rejected image source bodies|does not expose source response headers"
  • vp test run tests/features.test.ts -t "internalImageOrigin brackets IPv6 listener hosts"
  • CI=1 PLAYWRIGHT_PROJECT=pages-router-prod npx playwright test tests/e2e/pages-router-prod/image-middleware-ordering.spec.ts --project=pages-router-prod --workers=1

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jul 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 35c46a5

@github-actions

github-actions Bot commented Jul 8, 2026

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 8, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 35c46a5 against base aa1b666 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 126.6 KB 126.6 KB ⚫ 0.0%
Client entry size (gzip) vinext 120.6 KB 120.6 KB ⚫ 0.0%
Dev server cold start vinext 2.77 s 2.77 s ⚫ -0.3%
Production build time vinext 3.21 s 3.21 s ⚫ -0.0%
RSC entry closure size (gzip) vinext 98.5 KB 98.5 KB ⚫ -0.0%
Server bundle size (gzip) vinext 165.1 KB 165.1 KB ⚫ -0.0%

View detailed results and traces

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

@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: fix(image): preserve middleware ordering for source requests

Routing image-source requests through the app pipeline so middleware observes them is a solid parity improvement and matches Next.js (fetchInternalImage dispatches through the normal handler). The unit + e2e coverage is good. One regression and a few things worth confirming.

🔴 App Router prod: public-file serving loses static cache, precompressed variants, and ETag/304

In startAppRouterServer, the general request path previously served VINEXT_STATIC_FILE_HEADER-signaled files (i.e. public/ directory files, which "fall through to the RSC handler" per the comment at prod-server.ts:1425-1427) via:

await tryServeStatic(req, res, clientDir, staticFilePath, compress, staticCache, staticResponseHeaders, response.status)

The PR replaces this with dispatchAppRequestcreateStaticFileResponse (prod-server.ts:1366-1374, :809). createStaticFileResponse does not take staticCache and reimplements a stripped-down slow path. Compared to tryServeStatic, every public-file response now:

  • Bypasses StaticFileCache — does resolveStaticFile + fsp.readFile on every request instead of the precomputed in-memory lookup.
  • Loses precompressed .br/.gz/.zst variants — on-the-fly gzip via sendWebResponse is the only compression left (more CPU, and no brotli/zstd).
  • Loses ETag / If-None-Match → 304 — public files now always transfer the full body even when the client holds a valid cached copy.

This isn't image-specific: dispatchAppRequest is the main App Router request handler, so this affects every public/ file served by the Node production server. (Cache-Control lifetime is unchanged — both paths use max-age=3600 for non-hashed files — so this is a performance/bandwidth regression, not a cache-correctness one.)

Suggestion: keep the general App Router static path on tryServeStatic(..., compress, staticCache, ...). If you need a Web-Response-returning helper for the image dispatch flow specifically, have it consult staticCache (and honor precompressed variants + ETag) rather than re-reading from disk, or only use createStaticFileResponse for the image-source dispatch and leave the general path on tryServeStatic.

Note: the Pages Router main path still uses tryServeStatic (prod-server.ts:1933); dispatchPagesRequest/createStaticFileResponse there is scoped to image-source fetches only, which are re-served with image cache headers anyway — so the Pages side is fine.

🟡 Non-ok source responses are returned raw (no security headers, redirects pass through)

handleImageOptimization now returns the source response directly when !source.ok (image-optimization.ts:330-332). This is correct and intended for the middleware-401 case the test covers. Two things to confirm against Next.js:

  • Security headers are dropped on error responses. Content-Security-Policy / X-Content-Type-Options are only applied to 2xx passthrough/transform responses. A source that returns e.g. an HTML error body now reaches the client without X-Content-Type-Options: nosniff. Worth confirming Next.js also forwards error bodies verbatim here (it largely does), but a defense-in-depth nosniff on all image-endpoint responses is cheap.
  • 3xx sources pass through as redirects. Response.ok is false for 3xx, so a middleware/source redirect on the image-source path now surfaces as a redirect from /_next/image. The e2e test asserts location is undefined for the 401 case, but there's no test for a source that redirects. Please confirm this matches Next.js behavior (and add a test if the intended behavior is "don't emit a redirect from the optimizer").

🟢 Minor

  • createStaticFileResponse uses append for extraHeaders merge but tryServeStatic used spread/override semantics ({ ...variant.headers, ...extraHeaders }). For the staged/middleware headers passed here that's probably intended, but double-check you don't end up with duplicate Content-Type/Cache-Control — those are set afterward so they're fine; just flagging the merge-strategy difference.
  • createInternalImageRequest recursion guard + header preservation is nicely tested. 👍

Verified locally

  • vp check on all changed server files — clean.
  • vp test run tests/shims.test.ts -t "handleImageOptimization|recursive image optimizer" — 27 passed.
  • vp test run tests/deploy.test.ts -t "fetchWorkerFilesystemRoute" — 6 passed.

The middleware-ordering fix itself is correct; the main blocker is the App Router public-file static-serving regression.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2554. Summary of findings:

Review Summary

1 regression (🔴), 2 items to confirm (🟡), minor notes (🟢)

🔴 App Router prod public-file serving regression

The PR replaces tryServeStatic(...compress, staticCache...) with createStaticFileResponse in dispatchAppRequest — the main App Router production request handler. Since public/ files "fall through to the RSC handler," every public-file response now:

  • Bypasses the in-memory StaticFileCache (reads from disk per request)
  • Loses precompressed .br/.gz/.zst variants
  • Loses ETag / If-None-Match → 304

Cache lifetime is unchanged (max-age=3600), so it's a performance/bandwidth regression, not cache-correctness. The Pages Router main path correctly still uses tryServeStatic.

🟡 Non-ok source responses returned raw

handleImageOptimization now returns the source response directly when !source.ok. Correct for the middleware-401 case, but: security headers (nosniff, CSP) are dropped on error responses, and 3xx sources pass through as redirects (untested). Worth confirming against Next.js.

🟢 The core fix is sound

The middleware-ordering change matches Next.js's fetchInternalImage, and createInternalImageRequest's recursion guard + header preservation is well-tested.

All changed files pass vp check, and the new unit tests pass locally.

github run

@james-elicx james-elicx force-pushed the codex/fix-image-middleware-ordering branch from ae8d64d to 66ac5d8 Compare July 8, 2026 09:56
@james-elicx

Copy link
Copy Markdown
Member Author

/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: fix(image): preserve middleware ordering for source requests

The core direction is right — routing local image-source requests through the app pipeline so middleware observes them matches Next.js's fetchInternalImage, which dispatches the internal source request through routerServerHandler (.nextjs-ref/packages/next/src/server/next-server.ts:721). The recursion guard + header preservation in createInternalImageRequest is clean and well-tested. But I verified the non-ok / error-body behavior against Next.js source and found a deliberate-but-undocumented divergence, plus a header-leak parity gap. I also want to correct one prior review finding that doesn't hold up.

🔴 Non-image / non-ok source responses diverge from Next.js (401 passthrough vs 400 "isn't a valid image")

handleImageOptimization now does if (!source.ok) return source; (image-optimization.ts:330-332), passing the middleware's status/body/headers through verbatim. The new e2e test enshrines this: a middleware 401 on /protected/private.png is asserted to surface as 401 from /_next/image (image-middleware-ordering.spec.ts:24).

Next.js does not do this. Walking the source:

  • fetchInternalImage only throws when !mocked.res.statusCode (falsy) or the buffer is empty (next-server.ts via image-optimizer.ts:1008-1022). A 401 is truthy with a non-empty body, so it is not an error there — the "Authentication required" buffer flows into imageOptimizer.
  • imageOptimizer runs detectContentType on that buffer; a text/HTML error body fails upstreamType.startsWith('image/') and throws ImageError(400, "The requested resource isn't a valid image.") (image-optimizer.ts:1104-1117).

Next.js has an explicit test for exactly this shape — a non-image source body returns 400 "The requested resource isn't a valid image.", regardless of the source's own status (.nextjs-ref/test/e2e/image-optimizer/util.ts:1544-1549).

So for a middleware-blocked source, Next.js returns 400, not the raw 401. vinext's previous behavior (404) was also wrong, but this PR swaps one divergence for another and locks it in with a test asserting the divergent status.

Please confirm the intended behavior against Next.js:

  • If the goal is Next.js parity, non-image / blocked sources should return 400 "The requested resource isn't a valid image." and the e2e test should assert 400, not 401.
  • If surfacing the middleware status verbatim is a deliberate, better-than-Next.js choice, that's defensible — but per AGENTS.md it must be a documented divergence (comment + PR note linking the Next.js test), not an accidental one. Right now the code comment ("preserves request headers for middleware") describes the request side, not this response-side behavior.

🟡 Staged middleware headers leak onto the optimized image response (parity gap)

On the success path, createStaticFileResponse appends stagedHeaders (e.g. x-custom-middleware, x-mw-pathname from the fixture middleware) onto the source Response (prod-server.ts:832-838, :1719), and then createPassthroughImageResponse copies all source.headers onto the final /_next/image response (image-optimization.ts:266-271).

Next.js's fetchInternalImage extracts only Content-Type, Cache-Control, and ETag from the internal response (image-optimizer.ts:1024-1027) — it does not forward arbitrary middleware headers to the image response. So this PR causes middleware/staged headers to leak onto image responses where Next.js would not emit them. Worth trimming the passthrough to the content/cache/etag set (or stripping staged headers before passthrough) to match.

🟢 Correction to the prior review's "App Router public-file regression"

The earlier review flagged that the general App Router prod request path was switched from tryServeStatic(...) to dispatchAppRequestcreateStaticFileResponse, losing StaticFileCache, precompressed variants, and ETag/304 for all public/ files. I could not reproduce that. The general App Router path still uses tryServeStatic(req, res, clientDir, staticFilePath, compress, staticCache, staticResponseHeaders, response.status) (prod-server.ts:1502-1511). dispatchAppRequest/createStaticFileResponse is invoked only from inside the image-optimization branch (prod-server.ts:1468) to resolve image sources — which are re-served with image cache headers anyway. So there is no general public-file static-serving regression; that finding is a false positive.

(One real consequence remains: a public/ file fetched as an image source is read from disk via createStaticFileResponse rather than the in-memory StaticFileCache, and precompressed variants/ETag aren't used for that intermediate read. That's a minor per-image-source cost, not a general regression, since the final image response replaces those headers.)

🟢 Minor

  • createStaticFileResponse uses headers.append for extraHeaders; Content-Type/Content-Length/Cache-Control are set afterward so no duplication there — fine.
  • createInternalImageRequest recursion guard (base-path-aware isImageOptimizationPath check) + header/GET-coercion is nicely tested. 👍

Verified locally

  • .nextjs-ref behavior traced through fetchInternalImageimageOptimizerdetectContentType, and confirmed against test/e2e/image-optimizer/util.ts (non-image source → 400).
  • vitest run tests/shims.test.ts -t "handleImageOptimization|recursive image optimizer" — 27 passed.

The middleware-ordering mechanism is a solid parity improvement; the main thing to resolve before merge is the non-ok source status semantics (401 passthrough vs Next.js's 400) and confirming/documenting whichever behavior you intend.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2554.

Summary

1 blocker (🔴), 1 parity gap (🟡), plus a correction of the prior review + minor notes (🟢)

🔴 Non-ok source responses diverge from Next.js

The PR's if (!source.ok) return source; (image-optimization.ts:330-332) passes a middleware-blocked source's status/body through verbatim, and the new e2e test asserts a 401 from /_next/image. I traced Next.js source: fetchInternalImage doesn't treat a 401-with-body as an error, so the buffer flows into imageOptimizer, detectContentType fails, and Next.js returns 400 "The requested resource isn't a valid image." (confirmed by test/e2e/image-optimizer/util.ts:1544-1549). So vinext returns 401 where Next.js returns 400 — an undocumented divergence that the test locks in. Needs to either match (400) or be a documented deliberate divergence per AGENTS.md.

🟡 Staged middleware headers leak onto image responses

createStaticFileResponse appends staged headers (x-custom-middleware, x-mw-pathname) and createPassthroughImageResponse copies all source headers through. Next.js only extracts Content-Type/Cache-Control/ETag (image-optimizer.ts:1024-1027).

🟢 Corrected the prior review's headline "regression"

The earlier ask-bonk review claimed the general App Router prod path was switched off tryServeStatic, losing static cache/precompression/ETag for all public/ files. That's a false positive — the general path still uses tryServeStatic (prod-server.ts:1502-1511); createStaticFileResponse is only used inside the image-source branch.

The middleware-ordering mechanism itself (createInternalImageRequest + dispatch through the pipeline) is a sound, well-tested parity improvement.

github run

@james-elicx james-elicx force-pushed the codex/fix-image-middleware-ordering branch from 66ac5d8 to 0454301 Compare July 8, 2026 10:57
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx james-elicx force-pushed the codex/fix-image-middleware-ordering branch from 0454301 to 8902280 Compare July 8, 2026 11:00

@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: fix(image): preserve middleware ordering for source requests

Re-reviewed the current head (0454301). This revision resolves both blockers from the earlier ask-bonk reviews, and I verified the fixes against the code and tests. The core direction — routing local image-source requests through the app pipeline so middleware observes them — matches Next.js's fetchInternalImage, and the recursion guard + credential-free GET in createInternalImageRequest is clean and well-tested.

✅ Prior blocker #1 (401 vs 400) — fixed

The earlier if (!source.ok) return source; is gone. handleImageOptimization now buffers the source via readImageSource, sniffs the content type by magic bytes, and returns 400 "The requested resource isn't a valid image." when no image type is detected (image-optimization.ts:462-468). The e2e test asserts 400, not 401 (image-middleware-ordering.spec.ts:24). This matches Next.js: a non-image body (e.g. a middleware "Authentication required" 401 body) flows into the optimizer, fails detectContentType, and yields 400. New unit tests lock this in: validates non-success internal source bodies as images, rejects non-success bodies that spoof an image content type, rejects bodyless non-success internal source responses.

✅ Prior blocker #2 (staged/middleware header leak) — fixed

createPassthroughImageResponse now builds a fresh Headers and copies only Content-Type / ETag / Cache-Control / Vary + security headers (image-optimization.ts:364-383), rather than cloning source.headers. So staged middleware headers (x-custom-middleware, x-mw-pathname) and stray Set-Cookie/Location no longer reach the /_next/image response. Directly tested: the passthrough test asserts x-middleware-header: must-not-leak is stripped, the transform test asserts Set-Cookie/Location: /must-not-leak are dropped, and the e2e test asserts x-mw-pathname is undefined.

🟢 Other observations (non-blocking)

  • Cache-Control behavior change is a parity improvement, not a regression. Optimized images move from public, max-age=31536000, immutable to the computed imageCacheControl() value: public, max-age=max(minimumCacheTTL ?? 14400, upstreamMaxAge), must-revalidate (image-optimization.ts:385-393). This matches Next.js's max-age=<ttl>, must-revalidate shape (main was incorrectly serving immutable).
  • Source is now fully buffered in memory on every image request, capped by maximumResponseBody (default 50MB). This is required for magic-byte content sniffing and matches Next.js's fetchInternalImage buffering + body-size limit, so it's intentional parity — worth being aware of vs. main's streaming passthrough.
  • Dead export: IMAGE_CACHE_CONTROL (image-optimization.ts:209) is now unused everywhere (source + tests) after the switch to imageCacheControl(). Its value was also updated for consistency but nothing reads it. Consider removing it (or dropping the export) to avoid a stale public constant.
  • Fallback path is noisy but correct. When a transform consumes source.body and the catch-block passthrough throws "body … disturbed or locked", the code refetches (image-optimization.ts:526-551). Tests pass, but since readImageSource already materialized bytes, this fallback could reuse the buffer instead of refetching + re-sniffing. Pre-existing shape; optional cleanup.
  • Dev parity note (pre-existing, not introduced here): the dev server still 302-redirects /_next/image to the raw asset (index.ts:4684-4703) rather than running the optimizer pipeline, so the middleware-ordering behavior this PR adds is a prod-path fix. The browser's follow-up request to the source path still hits dev middleware, so functional parity holds, but dev doesn't exercise the new createInternalImageRequest/dispatch flow.

Cross-seam consistency ✔

The middleware-ordering mechanism is applied consistently across App Router prod (dispatchAppRequest), Pages prod (dispatchPagesRequest), App worker (app-router-entry.ts), and Pages worker (pages-router-entry.ts), with hashed _next/static assets correctly bypassing the pipeline via resolveAppRouterAssetPath/isNextStaticPath/fetchWorkerFilesystemRoute(allowDirect).

Verified locally

  • npx tsc --noEmit -p packages/vinext/tsconfig.json — clean (exit 0).
  • vitest run tests/shims.test.ts -t "handleImageOptimization|recursive image optimizer|createInternalImageRequest|isImageOptimizationPath" — 41 passed.
  • vitest run tests/deploy.test.ts -t "fetchWorkerFilesystemRoute" — 6 passed.

No blockers. The two earlier findings are resolved and well-covered; remaining notes are minor (dead IMAGE_CACHE_CONTROL export is the only concrete cleanup).

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2554.

Summary

I re-reviewed PR #2554 at its current head (0454301). The two prior ask-bonk reviews were written against an earlier revision — the PR has since been updated and both of their blockers are now resolved, with direct test coverage.

No blockers found. Findings:

✅ Prior blocker #1 (401 vs 400) — fixed. The if (!source.ok) return source; is gone. Source is now buffered + magic-byte sniffed; a non-image body returns 400 "The requested resource isn't a valid image." matching Next.js. The e2e test asserts 400.

✅ Prior blocker #2 (header leak) — fixed. createPassthroughImageResponse now builds fresh headers and copies only Content-Type/ETag/Cache-Control/Vary + security headers. Tests explicitly assert x-middleware-header, Set-Cookie, Location, and x-mw-pathname do not leak.

🟢 Non-blocking notes:

  • Cache-Control change (immutable → computed must-revalidate) is a parity improvement.
  • Full in-memory source buffering (capped at 50MB) is intentional Next.js parity for content sniffing.
  • IMAGE_CACHE_CONTROL is now a dead export — the only concrete cleanup suggestion.
  • Fallback refetch path is noisy but correct; could reuse the already-buffered bytes.
  • Dev server still 302-redirects images (pre-existing, not introduced here).

Verified locally: tsc clean, 41 shims tests pass, 6 deploy tests pass. Cross-seam consistency (App/Pages × prod/worker) confirmed.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx james-elicx force-pushed the codex/fix-image-middleware-ordering branch from 8902280 to 812ea1e Compare July 8, 2026 11:02

@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: fix(image): preserve middleware ordering for source requests

Re-reviewed the current head (812ea1e). The core mechanism — routing local image-source requests through the app pipeline so middleware observes them — matches Next.js's fetchInternalImage, which dispatches the internal source request through the normal request handler (packages/next/src/server/image-optimizer.tsfetchInternalImage). The recursion guard + credential-free GET in createInternalImageRequest is clean and well-tested.

I independently confirmed both prior ask-bonk blockers are resolved and traced the Next.js source to verify the fixes and look for remaining gaps.

✅ Prior blockers — verified fixed

  • 401→400 (non-image body): if (!source.ok) return source; is gone. The source is buffered via readImageSource, magic-byte sniffed, and a non-image body returns 400 "The requested resource isn't a valid image." (image-optimization.ts:460-466). This matches Next.js exactly: imageOptimizer runs detectContentType and throws ImageError(400, "The requested resource isn't a valid image.") when !upstreamType.startsWith('image/'). The e2e test asserts 400.
  • Header leak: createPassthroughImageResponse now builds fresh Headers and copies only Content-Type / ETag / Cache-Control / Vary + security headers (image-optimization.ts:362-381). Tests assert x-middleware-header, Set-Cookie, Location, and x-mw-pathname don't leak. This is actually stricter than Next.js, which forwards Content-Type/Cache-Control/ETag — vinext's allowlist is a superset of Next's and drops everything else. Good.

🟡 Cache-Control diverges from Next.js getMaxAge in two ways

imageCacheControl (image-optimization.ts:383-391) differs from Next.js's getMaxAge / setResponseHeaders:

  1. s-maxage precedence. Next.js's getMaxAge does map.get('s-maxage') || map.get('max-age')s-maxage always wins when present. The vinext regex /(?:^|,)\s*(?:s-maxage|max-age)=(\d+)/i matches whichever directive appears first in the string. For an upstream Cache-Control: max-age=60, s-maxage=120, vinext computes 60 where Next.js computes 120. Minor, but a real parity gap for sources that set both.

  2. immutable passthrough. imageCacheControl returns the upstream Cache-Control verbatim when it contains immutable (line 385). Next.js never does this for the optimized response — it emits public, max-age=<ttl>, must-revalidate for non-static images, and only uses public, max-age=315360000, immutable for the specific _next/static/media / _next/static/immutable/media static-import case (keyed on the isStatic URL prefix, not on the upstream header). Preserving immutable for any upstream that declares it is a reasonable approximation of the static-media case, but it's broader than Next.js and header-driven rather than URL-driven. Worth a comment documenting the intent, or narrowing to the _next/static/media prefix to match Next.js's isStatic check.

Neither is a blocker; both are parity nuances. Per AGENTS.md, if the divergence is intentional it should carry a comment.

🟡 Transform-failure fallback re-runs the full request pipeline (middleware side effects fire twice)

This PR changes fetchAsset so the source resolves through the entire app pipeline (middleware included). The transform-failure fallback (image-optimization.ts:522-549) calls handlers.fetchAsset(imageUrl, request) a second time when the first passthrough throws (source.body already consumed/locked by transformImage). I confirmed this path via the shims tests — stderr shows refetching source image firing on the "transform failed after consuming stream" cases.

Before this PR, fetchAsset was a plain ASSETS/filesystem read, so a double-fetch was cheap and side-effect-free. Now it re-dispatches through middleware, so any middleware side effect (logging, rate-limit counters, waitUntil work, auth checks with logging) runs twice for a single /_next/image request whenever a configured optimizer errors. Next.js avoids this entirely: it buffers the upstream once (fetchInternalImageBuffer.concat) and imageOptimizer falls back to upstreamBuffer in-memory on optimize failure — it never re-fetches.

readImageSource already materializes the full bytes buffer but discards it (only the derived Response is returned). Consider returning the buffer so the fallback reconstructs a fresh Response(bytes, …) instead of re-dispatching. That removes the double-middleware execution and the extra I/O, and matches Next.js's in-memory fallback semantics.

🟢 Cross-seam consistency + minor notes

  • App Router worker gates image handling on getImageOptimizer() (app-router-entry.ts:96), so with no configured optimizer, /_next/image falls through to the RSC handler rather than the passthrough path used by the Pages worker (pages-router-entry.ts:160) and Node prod-server (both run handleConfiguredImageOptimization unconditionally). This gate is pre-existing (not introduced here) but the divergence is now more visible because this PR routes the other seams through the middleware-aware dispatch. Flagging for a follow-up, not this PR.
  • The prior review's "dead IMAGE_CACHE_CONTROL export" note is now stale — the constant is already gone from source (image-optimization.ts); it only lingers in dist/ build output, which is expected.
  • allowDirect / resolveDirectAssets threading (pages-request-pipeline.ts:63-66, pages-router-entry.ts:170,210) is correctly scoped to the internal image-source dispatch so phase === "direct" filesystem routes resolve only for image sources.
  • HEAD→GET coercion in createInternalImageRequest (method: "GET") matches Next.js's fetchInternalImage.

Verified locally

  • vitest run tests/shims.test.ts -t "handleImageOptimization|recursive image optimizer|createInternalImageRequest|isImageOptimizationPath" — 41 passed.
  • vitest run tests/deploy.test.ts -t "fetchWorkerFilesystemRoute" — 6 passed.
  • vitest run tests/image-adapters-config.test.ts — 21 passed.
  • Traced Next.js image-optimizer.ts (getMaxAge, imageOptimizer, fetchInternalImage, setResponseHeaders) and image-config.ts defaults (minimumCacheTTL: 14400, maximumResponseBody: 50_000_000 — both match the PR).

No blockers. The two earlier findings are genuinely resolved and well-covered. Remaining items are the two parity nuances in imageCacheControl and the double-middleware execution in the transform-failure fallback — the fallback one is the most worth addressing since it can double-run middleware side effects on a single request.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2554.

Summary

I re-reviewed at head 812ea1e and independently verified both prior ask-bonk blockers are genuinely fixed, then traced Next.js source (image-optimizer.ts, image-config.ts) for remaining parity gaps.

No blockers. Findings:

✅ Prior blockers confirmed fixed — 401→400 for non-image bodies (buffered + magic-byte sniffed, matches Next.js imageOptimizer throwing ImageError(400, ...)), and the header leak (fresh Headers, allowlist copy). Both well-covered by tests.

🟡 imageCacheControl diverges from Next.js getMaxAge in two ways:

  • s-maxage precedence: vinext's regex takes the first directive in the string; Next.js always prefers s-maxage over max-age.
  • immutable passthrough: vinext preserves upstream immutable for any source; Next.js only emits immutable for the URL-keyed _next/static/media static-import case.

🟡 Transform-failure fallback re-runs the full pipeline — because fetchAsset now dispatches through middleware, the fallback refetch (when transformImage consumes the body then throws) re-executes middleware side effects twice. readImageSource already buffers the bytes but discards them; returning the buffer would let the fallback reconstruct in-memory (matching Next.js, which never re-fetches).

🟢 Notes: App Router worker's getImageOptimizer() gate is a pre-existing cross-seam divergence (more visible now); the prior review's "dead IMAGE_CACHE_CONTROL export" note is stale (already removed from source); allowDirect/HEAD→GET wiring is correctly scoped.

Verified locally: 41 shims + 6 deploy + 21 image-adapters tests pass; Next.js defaults (minimumCacheTTL: 14400, maximumResponseBody: 50_000_000) match the PR.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx james-elicx force-pushed the codex/fix-image-middleware-ordering branch from 812ea1e to f505210 Compare July 8, 2026 11:10

@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: fix(image): preserve middleware ordering for source requests

Re-reviewed at the current head (f505210). Note the head advanced since the last ask-bonk review (812ea1ef505210), and this newest revision resolves the two parity nuances and the double-middleware concern the prior review left open. I independently verified against Next.js source (.nextjs-ref, image-optimizer.ts). One small real bug remains.

✅ Prior review items — now resolved in this revision

  • s-maxage precedenceimageCacheControl (image-optimization.ts:387-397) now parses directives into a Map and does directives.get("s-maxage") ?? directives.get("max-age"), so s-maxage always wins. Matches Next.js getMaxAge. Covered by the new test prefers s-maxage over max-age for image cache lifetime (shims.test.ts:21395).
  • immutable passthrough — no longer header-driven. immutable is now emitted only for the URL-keyed static-media case via sourceIsStatic (/_next/static/media/, /_next/static/immutable/media/, image-optimization.ts:470-476), matching Next.js's isStatic URL check rather than trusting an upstream header.
  • Transform-failure double-middleware execution — the fallback no longer re-dispatches through the pipeline. readImageSource now materializes sourceBytes and the fallback reconstructs new Response(sourceBytes.slice().buffer, …) in-memory (image-optimization.ts:541-552), so middleware side effects fire exactly once. Directly covered by reuses buffered source bytes when transform consumes the stream before failing.
  • 401→400 and header leak — still fixed (buffered + magic-byte sniff → 400; fresh Headers allowlist in createPassthroughImageResponse). Confirmed against tests.

🟡 imageCacheControl produces max-age=NaN for a malformed upstream directive

imageCacheControl (image-optimization.ts:394-396) does:

const upstreamMaxAge = directives.get("s-maxage") ?? directives.get("max-age");
const maxAge = Math.max(config?.minimumCacheTTL ?? 14_400, Number(upstreamMaxAge ?? 0));
return `public, max-age=${maxAge}, must-revalidate`;

If an upstream/middleware source sets a non-numeric directive value (e.g. Cache-Control: max-age=abc, or a quoted max-age="600"), Number("abc")NaN, and Math.max(14400, NaN)NaN, so the image response emits Cache-Control: public, max-age=NaN, must-revalidate.

Next.js's getMaxAge guards exactly this (.nextjs-ref/packages/next/src/server/image-optimizer.ts:778-791): it strips surrounding quotes and does const n = parseInt(age, 10); if (!isNaN(n)) return n; otherwise falls back to 0. vinext should do the same — fall back to 0 (letting minimumCacheTTL win) when the parse yields NaN, and ideally strip quotes to match. Verified locally: Math.max(14400, Number('abc'))NaN. Minor (only triggers on a malformed source header), and no test covers it — worth a one-line Number.isFinite guard + a fixture.

🟢 Non-blocking notes

  • App Router worker still gates on getImageOptimizer() (app-router-entry.ts:96) while the Pages worker (pages-router-entry.ts:160) and Node prod server run handleConfiguredImageOptimization unconditionally (passthrough when no optimizer). This gate is pre-existing, not introduced here, but this PR makes the divergence more visible since the other seams now route through the middleware-aware dispatch. Follow-up, not this PR.
  • sourceBytes.slice().buffer (image-optimization.ts:541) is correct — slice() returns a zero-offset full-span copy so the backing ArrayBuffer is safe to hand to new Response.
  • Cross-seam consistency (App/Pages × Node-prod/Worker) for the createInternalImageRequest recursion guard + credential-free GET + allowDirect/static-bypass wiring is applied uniformly and well-tested.

Verified locally

  • vp check on all 5 changed server files — clean.
  • vitest run tests/shims.test.ts -t "handleImageOptimization|recursive image optimizer|createInternalImageRequest|isImageOptimizationPath" — 41 passed.
  • vitest run tests/deploy.test.ts -t "fetchWorkerFilesystemRoute" — 6 passed.
  • vitest run tests/image-adapters-config.test.ts — 21 passed.
  • Traced Next.js getMaxAge (quote-strip + isNaN guard) to confirm the max-age=NaN divergence.

No blockers. The middleware-ordering mechanism is a sound, well-tested parity improvement, and this revision closes the earlier parity gaps. The only concrete fix is the NaN guard in imageCacheControl.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2554.

Summary

Re-reviewed at the current head (f505210), which advanced past the last ask-bonk review's 812ea1e. This newest revision closes the two parity nuances and the double-middleware concern the prior review left open, and I verified against Next.js source.

No blockers. Findings:

✅ Prior items resolved in this revision:

  • s-maxage precedence now correct (directive Map + s-maxage ?? max-age), covered by a new test.
  • immutable is now URL-driven via sourceIsStatic (matches Next.js isStatic), not header-driven.
  • Transform-failure fallback no longer re-dispatches through middleware — it reuses the buffered sourceBytes in-memory, so middleware side effects fire once (directly tested).
  • 401→400 and header-leak fixes still hold.

🟡 One real minor bug: imageCacheControl emits Cache-Control: public, max-age=NaN, must-revalidate for a malformed upstream directive (e.g. max-age=abc or quoted values). Next.js's getMaxAge guards this with isNaN + quote-stripping. Only triggers on a malformed source header, untested — worth a one-line Number.isFinite guard.

🟢 Non-blocking: App Router worker's getImageOptimizer() gate divergence from Pages worker / Node prod is pre-existing (not introduced here) but now more visible.

Verified locally: vp check clean; 41 shims + 6 deploy + 21 image-adapters tests pass.

github run

@james-elicx james-elicx force-pushed the codex/fix-image-middleware-ordering branch 2 times, most recently from faeba57 to 6fd4724 Compare July 8, 2026 14:02
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/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: fix(image): preserve middleware ordering for source requests

Reviewed at the current branch head (6fd4724, single commit, +222/-27).

Heads-up on prior reviews: the earlier ask-bonk reviews on this PR described a substantially different implementation (readImageSource buffering, magic-byte sniffing, imageCacheControl, 400 responses, sourceBytes fallback reuse, sourceIsStatic). None of that is in this branch. The branch was reset back to the original single commit, so those "both blockers fixed" conclusions do not apply to the code that is actually up for merge. This review is against what's actually here.

The core direction — routing local image-source requests through the app pipeline so middleware observes them — is correct and matches Next.js's fetchInternalImage. The createInternalImageRequest recursion guard + credential-free GET is clean and well-tested. But there are two real issues plus a cross-seam divergence.

🔴 Middleware / staged headers leak onto the optimized image response (success path)

The fixture middleware sets x-custom-middleware and x-mw-pathname on every matched response (middleware.ts:9,13), and its matcher excludes only _next — so the internal source request for /protected/private.png (and any other public/ image) runs through middleware and those headers ride back on the source response.

On the success path those source headers are then copied verbatim onto the /_next/image response in every seam:

  • Node prodconst headers = new Headers(source.headers) (prod-server.ts:1475 for App Router, :1809 for Pages) copies all loopback-response headers, then only overlays the image security headers. x-mw-pathname, x-custom-middleware, and any middleware Set-Cookie leak through.
  • Workers — the source flows into handleImageOptimizationcreatePassthroughImageResponse, which does new Headers(source.headers) (image-optimization.ts:283) and passes everything through. Same leak for the passthrough / SVG-passthrough / no-optimizer cases.

Next.js's fetchInternalImage extracts only Content-Type, Cache-Control, and ETag from the internal response — it does not forward arbitrary middleware headers to the image response. Before this PR fetchAsset was a plain ASSETS/static read with no middleware headers, so this is a new leak introduced by routing through the pipeline.

The e2e test does not catch this: it only exercises the blocked/404 branch, where source headers are intentionally dropped. There is no success-path assertion that x-mw-pathname / x-custom-middleware are absent from a successfully optimized image. Please trim the passthrough (Node + createPassthroughImageResponse) to the content/cache/etag/security allowlist and add a success-path test asserting middleware headers don't leak.

🟡 401-blocked (non-image) source returns 404, where Next.js returns 400

handleImageOptimization returns 404 "Image not found" for any !source.ok source (image-optimization.ts:334-337), and the Node path does the same (prod-server.ts:1480-1482). The new e2e test locks this in: a middleware 401 on /protected/private.png is asserted to surface as 404 from /_next/image (image-middleware-ordering.spec.ts:22).

Next.js does not do this. A 401-with-body is not treated as a fetch error by fetchInternalImage; the (non-image) body flows into imageOptimizer, detectContentType fails, and Next.js returns 400 "The requested resource isn't a valid image." So vinext returns 404 where Next.js returns 400 — an undocumented divergence that the test enshrines. Per AGENTS.md, either match Next.js (400) or make this a deliberate, documented divergence with a comment + PR note linking the Next.js test. (Note: this branch has none of the buffering/sniffing machinery the prior reviews credited with producing a 400 — the code genuinely returns 404 here.)

🟡 Transform-failure fallback re-runs the full request pipeline (middleware side effects fire twice)

Because fetchAsset now dispatches the source through the whole pipeline (Node: HTTP loopback; workers: recursive handleRequest), the transform-failure fallback at image-optimization.ts:389 calls handlers.fetchAsset(imageUrl, request) a second time when the first passthrough throws (body consumed by transformImage). That re-executes middleware side effects (logging, rate-limit counters, waitUntil, auth) twice for a single /_next/image request whenever a configured optimizer errors. Next.js buffers the upstream once and falls back in-memory — it never re-fetches. This branch has no buffered bytes to reuse, so the fallback is a genuine double-dispatch. Worth addressing given fetchAsset is now middleware-bearing.

🟢 Notes

  • Node prod uses an HTTP self-fetch loopback (fetchInternalImageSourcefetch('http://127.0.0.1:port/...')) while the workers dispatch in-process via handleRequest. Functionally both run middleware, but the Node loopback opens a real second TCP connection per image request (extra latency + connection pressure, and a potential source of surprises behind connection limits). Flagging the architectural asymmetry; the in-process approach the workers use would be more consistent if feasible for Node.
  • App Router worker still gates image handling on getImageOptimizer() (app-router-entry.ts:96), so with no configured optimizer /_next/image falls through to the RSC handler, unlike the Pages worker (pages-router-entry.ts) and Node prod which run unconditionally. Pre-existing, but more visible now.
  • internalImageOrigin IPv6 bracketing (features.test.ts) and the createInternalImageRequest recursion guard / credential-free GET are nicely tested. 👍

Verified locally

  • Confirmed branch head is 6fd4724 (single commit); the diff matches +222/-27 and contains none of the buffering/sniffing/imageCacheControl code the prior reviews described.
  • vitest run tests/shims.test.ts -t "credential-free GET|recursive local image optimizer|cancels rejected image source bodies" — 3 passed.
  • Traced middleware header propagation (fixture matcher excludes _next only) and createPassthroughImageResponse / Node new Headers(source.headers) header copy to confirm the success-path leak.

The middleware-ordering mechanism itself is a sound parity improvement. The header leak is the main blocker before merge; the 404-vs-400 divergence and the double-dispatch fallback are the follow-ups.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2554.

Summary

I reviewed at the actual branch head (6fd4724) and found something important: the prior ask-bonk reviews were reviewing a different, more advanced implementation that is NOT in this branch. Those reviews described readImageSource buffering, magic-byte sniffing, imageCacheControl, 400 responses, and sourceBytes fallback reuse — none of which exists in the code up for merge. The branch was reset to its original single commit. So the "both blockers fixed, no blockers found" conclusions do not apply.

Against the code that's actually here:

🔴 Blocker — Middleware/staged headers leak onto the optimized image (success path). Because the source now routes through middleware (which sets x-mw-pathname/x-custom-middleware/Set-Cookie), and both the Node path (new Headers(source.headers), prod-server.ts:1475/1809) and the worker path (createPassthroughImageResponse, image-optimization.ts:283) copy all source headers, those middleware headers leak onto /_next/image responses. Next.js only forwards Content-Type/Cache-Control/ETag. The e2e test misses this because it only tests the 404 branch.

🟡 404 vs 400 divergence. A middleware-blocked (401) non-image source returns 404 "Image not found"; Next.js returns 400 "The requested resource isn't a valid image." The e2e test enshrines the 404. Needs to match or be documented.

🟡 Transform-failure fallback double-dispatches through the middleware-bearing pipeline (no buffered bytes to reuse in this branch), running middleware side effects twice.

🟢 Notes: Node uses an HTTP self-fetch loopback while workers dispatch in-process (asymmetry); App Router worker still gates on getImageOptimizer(). The recursion guard + credential-free GET + IPv6 bracketing are well-tested.

The core middleware-ordering mechanism is sound; the header leak is the main thing to fix before merge.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx james-elicx force-pushed the codex/fix-image-middleware-ordering branch from 6fd4724 to 35c46a5 Compare July 8, 2026 14:34

@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: fix(image): preserve middleware ordering for source requests

Reviewed at the actual branch head (35c46a5, single commit, +309/-46). Important note on prior reviews: the earlier ask-bonk reviews on this thread flip-flopped between two different implementations. Several credited a readImageSource/magic-byte-sniffing/imageCacheControl/sourceIsStatic implementation that is not in this branch; the most recent one (6fd4724) reviewed a version that still had the header leak. This branch (35c46a5) is one commit past that and fixes the header leak. This review is against the code actually up for merge.

The core mechanism — routing local image-source requests through the app pipeline so middleware observes them — is correct and matches Next.js's fetchInternalImage (dispatches the internal source through the normal request handler). Recursion guard + credential-free GET in createInternalImageRequest is clean and well-tested.

✅ Header leak (the last review's blocker) — fixed in this revision

Both seams now build fresh, allowlisted headers instead of cloning source.headers:

  • Node prod (App + Pages) uses imageSourceResponseHeaders(source) (image-optimization.ts:295-302), copying only Content-Type/Cache-Control/ETag, then overlaying image security headers (prod-server.ts:1470-1471, :1801-1802). The prior new Headers(source.headers) clone is gone.
  • WorkerscreatePassthroughImageResponse builds a fresh Headers copying only Content-Type/ETag (image-optimization.ts:283-293); the transform path derives from transformed.headers, not the middleware source.

Well covered: the new e2e success-path test asserts x-custom-middleware/x-mw-pathname are absent on a successfully optimized image (image-middleware-ordering.spec.ts:42-43), and the shims test asserts Set-Cookie/X-Middleware-Header don't leak (shims.test.ts). Good — this closes the one real blocker.

🟡 401-blocked source returns 404, where Next.js returns 400 (undocumented divergence)

For a middleware-blocked (401) source, handleImageOptimization (!source.ok404 "Image not found", image-optimization.ts:348-351) and the Node path (prod-server.ts:1472-1474, :1815-1817) return 404. The e2e test enshrines this (image-middleware-ordering.spec.ts:24).

Next.js does not treat a 401-with-body as a fetch error in fetchInternalImage; the (non-image) body flows into imageOptimizer, detectContentType fails, and Next.js returns 400 "The requested resource isn't a valid image." So vinext returns 404 where Next.js returns 400.

Two mitigating facts: (1) the 404 "Image not found" for !source.ok is pre-existing (it predates this PR — it was the genuine "source missing" path); this PR only makes middleware-blocked sources reachable by it. (2) This branch has no body buffering/sniffing, so it can't reproduce Next.js's "sniff then 400" flow without new machinery. Per AGENTS.md this should still be a deliberate, documented divergence — a one-line comment at the !source.ok branch noting "middleware-blocked/non-image sources return 404 here vs Next.js's 400 (no source buffering)" plus a PR note would satisfy the parity-documentation rule. Not a merge blocker, but please don't leave it silent.

🟡 Transform-failure fallback re-dispatches through the middleware-bearing pipeline

fetchAsset now routes the source through the whole pipeline (Node: HTTP loopback; workers: recursive handleRequest). The transform-failure fallback calls handlers.fetchAsset(imageUrl, request) a second time when the first passthrough throws because transformImage consumed source.body (image-optimization.ts:399-415). That re-runs middleware side effects (logging, rate-limit counters, waitUntil, auth) twice for a single /_next/image request whenever a configured optimizer errors. Next.js buffers the upstream once and falls back in-memory — it never re-fetches. This branch has no buffered bytes to reuse, so it's a genuine double-dispatch. This path only fires when a configured transformImage throws, so it's an edge case, but worth a follow-up given fetchAsset is now side-effecting. (The default no-optimizer path serves the passthrough directly and doesn't hit this.)

🟢 Notes (non-blocking)

  • Node uses an HTTP self-fetch loopback (fetchInternalImageSourcefetch('http://127.0.0.1:port/…'), prod-server.ts:1048-1070) while workers dispatch in-process via handleRequest. Functionally both run middleware. The loopback opens a real second TCP connection per image request; the internalImageOrigin 0.0.0.0→127.0.0.1 / ::→::1 translation + IPv6 bracketing is correct and tested (features.test.ts). redirect: "manual" correctly prevents the optimizer from following a source redirect (surfaces as !source.ok → 404). Flagging the architectural asymmetry only.
  • x-forwarded-proto is derived from publicUrl.protocol which in Node prod is typically http even behind an HTTPS-terminating proxy. The source is a local asset so proto rarely matters, but if any middleware branches on proto it'll see http on the internal fetch.
  • App Router worker still gates image handling on getImageOptimizer() (app-router-entry.ts:96), so with no configured optimizer /_next/image falls through to the RSC handler — unlike the Pages worker (pages-router-entry.ts:161) and Node prod which run unconditionally. Pre-existing, but more visible now that the other seams route through the middleware-aware dispatch.
  • SSRF surface is bounded: parseImageParams (image-optimization.ts:182-204) rejects absolute/protocol-relative URLs, so createInternalImageRequest only ever re-points a local path at the local origin. The recursion guard (isImageOptimizationPath after decode + basePath strip) is tested for both literal and %5F-encoded _next/image. Good.
  • cancelResponseBody is now applied on every early-return branch (404/400) so rejected source bodies don't leak — nicely covered by "cancels rejected image source bodies".

Verified locally

  • npx tsc --noEmit -p packages/vinext/tsconfig.json — clean (exit 0).
  • vitest run tests/shims.test.ts -t "credential-free GET|recursive local image optimizer|cancels rejected image source bodies|does not expose source response headers|handleImageOptimization" — 29 passed.
  • Traced both Node seams + both worker seams for header handling; confirmed the fresh-Headers allowlist is applied consistently and the e2e/shims tests cover the success-path no-leak assertion.

The middleware-ordering mechanism is a sound parity improvement and the header leak is genuinely fixed here. No blockers. The two 🟡 items — documenting the 404-vs-400 divergence, and the transform-fallback double-dispatch — are the things worth addressing (or explicitly deferring) before/after merge.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2554.

Summary

I reviewed at the actual branch head (35c46a5, +309/-46), which is one commit past the last ask-bonk review's 6fd4724. The prior reviews on this thread flip-flopped across different implementations (some credited buffering/sniffing/imageCacheControl machinery that isn't in this branch). This review is against the code actually up for merge.

No blockers. Findings:

✅ Header leak (the last review's 🔴 blocker) — genuinely fixed in this revision. Both Node seams now use imageSourceResponseHeaders() (fresh allowlist: Content-Type/Cache-Control/ETag) instead of new Headers(source.headers), and the worker path's createPassthroughImageResponse builds fresh headers too. Covered by a new e2e success-path test (x-custom-middleware/x-mw-pathname absent) and a shims test (Set-Cookie/X-Middleware-Header stripped).

🟡 401-blocked source returns 404 where Next.js returns 400. The e2e test enshrines 404. Mitigating: the 404 for !source.ok is pre-existing and this branch has no body-sniffing to reproduce Next's 400. Per AGENTS.md this should be a documented divergence (one-line comment + PR note), not silent.

🟡 Transform-failure fallback re-dispatches through the middleware-bearing pipeline — double-runs middleware side effects on the edge case where a configured transformImage throws. Worth a follow-up since fetchAsset is now side-effecting.

🟢 Notes: Node HTTP self-fetch loopback vs in-process worker dispatch asymmetry (IPv6 bracketing correct/tested); x-forwarded-proto is http in Node prod; App Router worker still gates on getImageOptimizer() (pre-existing); SSRF surface bounded by parseImageParams local-only check; cancelResponseBody correctly applied on all early returns.

Verified locally: tsc clean, 29 shims tests pass.

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