From 8092463f6337a207c649e2db175d723ac703823f Mon Sep 17 00:00:00 2001 From: James Date: Fri, 22 May 2026 15:39:56 +0100 Subject: [PATCH 1/3] fix(image): emit /_next/image URLs to match Next.js Closes #1513 The default image loader and optimization endpoint switched from the vinext-specific /_vinext/image path to Next.js's canonical /_next/image. This unblocks the deploy suite tests that import Next.js's expected URL shape (/_next/image?url=...&w=...&q=...). --- README.md | 2 +- apps/web/worker/index.ts | 2 +- .../app-router-cloudflare/worker/index.ts | 2 +- .../app-router-playground/worker/index.ts | 2 +- packages/vinext/src/cloudflare/tpr.ts | 2 +- packages/vinext/src/deploy.ts | 6 +- packages/vinext/src/index.ts | 2 +- packages/vinext/src/server/app-rsc-handler.ts | 2 +- .../vinext/src/server/image-optimization.ts | 4 +- packages/vinext/src/shims/image.tsx | 16 +- tests/deploy.test.ts | 10 +- tests/image-component.test.ts | 34 +++- tests/image-optimization-parity.test.ts | 4 +- tests/pages-router.test.ts | 6 +- tests/shims.test.ts | 153 ++++++++---------- 15 files changed, 132 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 985e32fb1..304d3008f 100644 --- a/README.md +++ b/README.md @@ -558,7 +558,7 @@ These are intentional exclusions. For things that are missing today but on the r These are gaps we'd like to close — distinct from the [intentional exclusions](#whats-not-supported-and-wont-be) above. -- **Image optimization doesn't happen at build time.** Remote images work via `@unpic/react` (auto-detects 28 CDN providers). Local images are routed through a `/_vinext/image` endpoint that can resize and transcode on Cloudflare Workers (via the Images binding) in production, but no build-time optimization or static resizing occurs. +- **Image optimization doesn't happen at build time.** Remote images work via `@unpic/react` (auto-detects 28 CDN providers). Local images are routed through a `/_next/image` endpoint that can resize and transcode on Cloudflare Workers (via the Images binding) in production, but no build-time optimization or static resizing occurs. - **Google Fonts are loaded from the CDN, not self-hosted.** No `size-adjust` fallback font metrics. Local fonts work but `@font-face` CSS is injected at runtime, not extracted at build time. - **Route segment config** — `runtime` and `preferredRegion` are ignored (everything runs in the same environment). - **Node.js production server (`vinext start`)** works for testing but is less complete than Workers deployment. Cloudflare Workers is the primary target. diff --git a/apps/web/worker/index.ts b/apps/web/worker/index.ts index 8dc3c8151..da00e2688 100644 --- a/apps/web/worker/index.ts +++ b/apps/web/worker/index.ts @@ -59,7 +59,7 @@ export default { // Image optimization via Cloudflare Images binding. // The parseImageParams validation inside handleImageOptimization // normalizes backslashes and validates the origin hasn't changed. - if (url.pathname === "/_vinext/image") { + if (url.pathname === "/_next/image") { const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES]; return handleImageOptimization( request, diff --git a/examples/app-router-cloudflare/worker/index.ts b/examples/app-router-cloudflare/worker/index.ts index de478b108..ea7650b36 100644 --- a/examples/app-router-cloudflare/worker/index.ts +++ b/examples/app-router-cloudflare/worker/index.ts @@ -23,7 +23,7 @@ export default { const url = new URL(request.url); // Image optimization via Cloudflare Images binding - if (url.pathname === "/_vinext/image") { + if (url.pathname === "/_next/image") { return handleImageOptimization(request, { fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))), transformImage: async (body, { width, format, quality }) => { diff --git a/examples/app-router-playground/worker/index.ts b/examples/app-router-playground/worker/index.ts index c2d105c22..608aabcbd 100644 --- a/examples/app-router-playground/worker/index.ts +++ b/examples/app-router-playground/worker/index.ts @@ -27,7 +27,7 @@ export default { const url = new URL(request.url); // Image optimization via Cloudflare Images binding - if (url.pathname === "/_vinext/image") { + if (url.pathname === "/_next/image") { return handleImageOptimization(request, { fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))), transformImage: async (body, { width, format, quality }) => { diff --git a/packages/vinext/src/cloudflare/tpr.ts b/packages/vinext/src/cloudflare/tpr.ts index 4248c1753..768a55fab 100644 --- a/packages/vinext/src/cloudflare/tpr.ts +++ b/packages/vinext/src/cloudflare/tpr.ts @@ -465,7 +465,7 @@ function filterTrafficPaths(entries: TrafficEntry[]): TrafficEntry[] { // API routes if (e.path.startsWith("/api/")) return false; // Internal routes - if (e.path.startsWith("/_vinext/") || e.path.startsWith("/_next/")) return false; + if (e.path.startsWith("/_next/") || e.path.startsWith("/__vinext/")) return false; // RSC requests if (e.path.endsWith(".rsc")) return false; return true; diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index da89af6f8..4a021d998 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -502,7 +502,7 @@ ${isrSetup} const url = new URL(request.url); // Image optimization via Cloudflare Images binding. // The parseImageParams validation inside handleImageOptimization // normalizes backslashes and validates the origin hasn't changed. - if (url.pathname === "/_vinext/image") { + if (url.pathname === "/_next/image") { const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES]; return handleImageOptimization(request, { fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))), @@ -637,8 +637,8 @@ export default { const basePathState = { basePath, hadBasePath }; // ── Image optimization via Cloudflare Images binding ────────── - // Checked after basePath stripping so //_vinext/image works. - if (pathname === "/_vinext/image") { + // Checked after basePath stripping so //_next/image works. + if (pathname === "/_next/image") { const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES]; return handleImageOptimization(request, { fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))), diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 3762521dd..ff484fa8a 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2797,7 +2797,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // ── Image optimization passthrough (dev mode) ───────────── // In dev, redirect to the original asset URL so Vite serves it. - if (url.split("?")[0] === "/_vinext/image") { + if (url.split("?")[0] === "/_next/image") { const imgParams = new URLSearchParams(url.split("?")[1] ?? ""); const rawImgUrl = imgParams.get("url"); // Normalize backslashes: browsers and the URL constructor treat diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 28779be66..70c530895 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -444,7 +444,7 @@ async function handleAppRscRequest( if (beforeFilesRewrite instanceof Response) return beforeFilesRewrite; if (beforeFilesRewrite) cleanPathname = beforeFilesRewrite; - if (cleanPathname === "/_vinext/image") { + if (cleanPathname === "/_next/image") { const imageUrlResult = validateImageUrl(url.searchParams.get("url"), request.url); if (imageUrlResult instanceof Response) return imageUrlResult; return Response.redirect(new URL(imageUrlResult, url.origin).href, 302); diff --git a/packages/vinext/src/server/image-optimization.ts b/packages/vinext/src/server/image-optimization.ts index b34ec6816..85f68936a 100644 --- a/packages/vinext/src/server/image-optimization.ts +++ b/packages/vinext/src/server/image-optimization.ts @@ -1,7 +1,7 @@ /** * Image optimization request handler. * - * Handles `/_vinext/image?url=...&w=...&q=...` requests. In production + * Handles `/_next/image?url=...&w=...&q=...` requests. In production * on Cloudflare Workers, uses the Images binding (`env.IMAGES`) to * resize and transcode on the fly. On other runtimes (Node.js dev/prod * server), serves the original file as a passthrough with appropriate @@ -20,7 +20,7 @@ import { badRequestResponse } from "./http-error-responses.js"; /** The pathname that triggers image optimization. */ -export const IMAGE_OPTIMIZATION_PATH = "/_vinext/image"; +export const IMAGE_OPTIMIZATION_PATH = "/_next/image"; /** * Image security configuration from next.config.js `images` section. diff --git a/packages/vinext/src/shims/image.tsx b/packages/vinext/src/shims/image.tsx index 5ba11d827..275a78ee8 100644 --- a/packages/vinext/src/shims/image.tsx +++ b/packages/vinext/src/shims/image.tsx @@ -5,7 +5,7 @@ * * Translates Next.js Image props to @unpic/react Image component. * @unpic/react auto-detects CDN from URL and uses native transforms. - * For local images (relative paths), routes through `/_vinext/image` + * For local images (relative paths), routes through `/_next/image` * for server-side optimization (resize, format negotiation, quality). * * Remote images are validated against `images.remotePatterns` and @@ -264,14 +264,14 @@ function resolveImageSource(v: { const RESPONSIVE_WIDTHS = __imageDeviceSizes; /** - * Build a `/_vinext/image` optimization URL. + * Build a `/_next/image` optimization URL. * * In production (Cloudflare Workers), the worker intercepts this path and uses * the Images binding to resize/transcode on the fly. In dev, the Vite dev * server handles it as a passthrough (serves the original file). */ export function imageOptimizationUrl(src: string, width: number, quality: number = 75): string { - return `/_vinext/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality}`; + return `/_next/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality}`; } function preloadImageResource(input: { @@ -294,7 +294,7 @@ function preloadImageResource(input: { /** * Generate a srcSet string for responsive images. * - * Each width points to the `/_vinext/image` optimization endpoint so the + * Each width points to the `/_next/image` optimization endpoint so the * server can resize and transcode the image. Only includes widths that are * <= 2x the original image width to avoid pointless upscaling. */ @@ -589,7 +589,7 @@ const Image = forwardRef(function Image( // (unpic requires them for constrained layout) } - // Route local images through the /_vinext/image optimization endpoint. + // Route local images through the /_next/image optimization endpoint. // In production on Cloudflare Workers, this resizes and transcodes via // the Images binding. In dev, it serves the original file as a passthrough. // When `unoptimized` is true, bypass the endpoint entirely (Next.js compat). @@ -600,7 +600,7 @@ const Image = forwardRef(function Image( const skipOptimization = _unoptimized === true || (isSvg && !__dangerouslyAllowSVG); // Build srcSet for responsive local images (common breakpoints). - // Each entry points to /_vinext/image with the appropriate width. + // Each entry points to /_next/image with the appropriate width. const srcSet = imgWidth && !fill && !skipOptimization ? generateSrcSet(src, imgWidth, imgQuality) @@ -641,7 +641,7 @@ const Image = forwardRef(function Image( }); // For local images, render a standard tag with srcSet and blur support. - // The src and srcSet point to the /_vinext/image optimization endpoint. + // The src and srcSet point to the /_next/image optimization endpoint. return ( { expect(content).toContain("Promise"); }); - it("includes /_vinext/image handler", () => { + it("includes /_next/image handler", () => { const content = generateAppRouterWorkerEntry(); - expect(content).toContain("/_vinext/image"); + expect(content).toContain("/_next/image"); expect(content).toContain("handleImageOptimization"); }); @@ -666,9 +666,9 @@ describe("generatePagesRouterWorkerEntry", () => { expect(content).toContain("Internal Server Error"); }); - it("includes /_vinext/image handler", () => { + it("includes /_next/image handler", () => { const content = generatePagesRouterWorkerEntry(); - expect(content).toContain("/_vinext/image"); + expect(content).toContain("/_next/image"); expect(content).toContain("handleImageOptimization"); }); @@ -920,7 +920,7 @@ describe("generatePagesRouterWorkerEntry", () => { it("checks image optimization after basePath stripping", () => { const content = generatePagesRouterWorkerEntry(); const basePathPos = content.indexOf("const stripped = stripBasePath(pathname, basePath);"); - const imagePos = content.indexOf('pathname === "/_vinext/image"'); + const imagePos = content.indexOf('pathname === "/_next/image"'); expect(basePathPos).toBeGreaterThan(-1); expect(imagePos).toBeGreaterThan(-1); expect(basePathPos).toBeLessThan(imagePos); diff --git a/tests/image-component.test.ts b/tests/image-component.test.ts index 84c52e88b..92868d7a6 100644 --- a/tests/image-component.test.ts +++ b/tests/image-component.test.ts @@ -15,13 +15,43 @@ import Image, { getImageProps, type StaticImageData } from "../packages/vinext/s /** Helper: expected optimization URL matching what the image shim produces. */ function optUrl(src: string, w: number, q = 75): string { - return `/_vinext/image?url=${encodeURIComponent(src)}&w=${w}&q=${q}`; + return `/_next/image?url=${encodeURIComponent(src)}&w=${w}&q=${q}`; } /** Same as optUrl but with HTML entity encoding (for SSR output assertions). */ function optUrlHtml(src: string, w: number, q = 75): string { return optUrl(src, w, q).replace(/&/g, "&"); } +// ─── Issue #1513 reproduction ─────────────────────────────────────────── +// +// The default loader must emit URLs starting with `/_next/image` (Next.js +// canonical) — not the previous `/_vinext/image` prefix. This guards +// against regression of https://github.com/cloudflare/vinext/issues/1513. + +describe("default loader emits /_next/image URLs (issue #1513)", () => { + it("imageOptimizationUrl uses /_next/image prefix", async () => { + const { imageOptimizationUrl } = await import("../packages/vinext/src/shims/image.js"); + const url = imageOptimizationUrl("/photo.png", 828, 85); + expect(url.startsWith("/_next/image?")).toBe(true); + expect(url).toContain("url=%2Fphoto.png"); + expect(url).toContain("w=828"); + expect(url).toContain("q=85"); + }); + + it("Image SSR src starts with /_next/image, not /_vinext/image", () => { + const html = ReactDOMServer.renderToString( + React.createElement(Image, { + alt: "test", + src: "/test.png", + width: 100, + height: 100, + }), + ); + expect(html).toMatch(/src="\/_next\/image\?/); + expect(html).not.toContain("/_vinext/image"); + }); +}); + // ─── SSR rendering ────────────────────────────────────────────────────── describe("Image SSR rendering", () => { @@ -423,7 +453,7 @@ describe("getImageProps", () => { }); expect(props.srcSet).toBeDefined(); - expect(props.srcSet).toContain("/_vinext/image"); + expect(props.srcSet).toContain("/_next/image"); expect(props.srcSet).toContain("photo.png"); expect(props.srcSet).toContain("w"); }); diff --git a/tests/image-optimization-parity.test.ts b/tests/image-optimization-parity.test.ts index 505ce8e1d..ba1cef737 100644 --- a/tests/image-optimization-parity.test.ts +++ b/tests/image-optimization-parity.test.ts @@ -119,7 +119,7 @@ function runLocalImageUrlParitySuite(router: "app" | "pages"): void { const src = getImageSrcFromHtml(html, "unicode"); const imageUrl = new URL(src, baseUrl); - expect(imageUrl.pathname).toBe("/_vinext/image"); + expect(imageUrl.pathname).toBe("/_next/image"); expect(imageUrl.searchParams.get("url")).toBe("/äöüščří.png"); expect(imageUrl.searchParams.get("w")).toBe("64"); expect(imageUrl.searchParams.get("q")).toBe("75"); @@ -136,7 +136,7 @@ function runLocalImageUrlParitySuite(router: "app" | "pages"): void { const src = getImageSrcFromHtml(html, "space"); const imageUrl = new URL(src, baseUrl); - expect(imageUrl.pathname).toBe("/_vinext/image"); + expect(imageUrl.pathname).toBe("/_next/image"); expect(imageUrl.searchParams.get("url")).toBe("/hello world.png"); expect(imageUrl.searchParams.get("w")).toBe("64"); expect(imageUrl.searchParams.get("q")).toBe("75"); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 66f3a8123..edcd70370 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -1518,14 +1518,14 @@ describe("Pages Router dev server origin check", () => { }); it("blocks image endpoint redirect to /@* internal paths", async () => { - const res = await fetch(`${baseUrl}/_vinext/image?url=/@fs/etc/passwd&w=100&q=75`, { + const res = await fetch(`${baseUrl}/_next/image?url=/@fs/etc/passwd&w=100&q=75`, { redirect: "manual", }); expect(res.status).toBe(400); }); it("blocks image endpoint redirect to /__vite internal paths", async () => { - const res = await fetch(`${baseUrl}/_vinext/image?url=/__vite_hmr&w=100&q=75`, { + const res = await fetch(`${baseUrl}/_next/image?url=/__vite_hmr&w=100&q=75`, { redirect: "manual", }); expect(res.status).toBe(400); @@ -1533,7 +1533,7 @@ describe("Pages Router dev server origin check", () => { it("blocks image endpoint redirect to /node_modules paths", async () => { const res = await fetch( - `${baseUrl}/_vinext/image?url=/node_modules/.vite/manifest.json&w=100&q=75`, + `${baseUrl}/_next/image?url=/node_modules/.vite/manifest.json&w=100&q=75`, { redirect: "manual", }, diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 1c5f0706b..36348588c 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -12853,7 +12853,7 @@ describe("next/image enhancements", () => { priority: true, }); // Local images now route through the optimization endpoint - expect(result.props.src).toContain("/_vinext/image"); + expect(result.props.src).toContain("/_next/image"); expect(result.props.src).toContain("url=%2Fphoto.jpg"); expect(result.props.src).toContain("w=800"); expect(result.props.alt).toBe("Test"); @@ -12880,7 +12880,7 @@ describe("next/image enhancements", () => { src: { src: "/imported.jpg", width: 1200, height: 800, blurDataURL: "data:..." }, alt: "Imported", }); - expect(result.props.src).toContain("/_vinext/image"); + expect(result.props.src).toContain("/_next/image"); expect(result.props.src).toContain("url=%2Fimported.jpg"); expect(result.props.src).toContain("w=1200"); expect(result.props.width).toBe(1200); @@ -12896,8 +12896,8 @@ describe("next/image enhancements", () => { height: 800, }); expect(result.props.srcSet).toBeDefined(); - // srcSet entries point to /_vinext/image optimization endpoint - expect(result.props.srcSet).toContain("/_vinext/image"); + // srcSet entries point to /_next/image optimization endpoint + expect(result.props.srcSet).toContain("/_next/image"); expect(result.props.srcSet).toContain("url=%2Fphoto.jpg"); expect(result.props.srcSet).toContain("w"); }); @@ -12968,12 +12968,12 @@ describe("next/image enhancements", () => { height: 600, loader: ({ src, width, quality }) => `https://cdn.example.com${src}?w=${width}&q=${quality}`, }); - // Custom loader bypasses the /_vinext/image endpoint + // Custom loader bypasses the /_next/image endpoint expect(result.props.src).toBe("https://cdn.example.com/photo.jpg?w=800&q=75"); - expect(result.props.src).not.toContain("/_vinext/image"); + expect(result.props.src).not.toContain("/_next/image"); }); - it("unoptimized prop bypasses /_vinext/image endpoint", async () => { + it("unoptimized prop bypasses /_next/image endpoint", async () => { const { getImageProps } = await import("../packages/vinext/src/shims/image.js"); const result = getImageProps({ src: "/photo.jpg", @@ -12984,7 +12984,7 @@ describe("next/image enhancements", () => { }); // unoptimized=true should serve the raw src, not the optimization endpoint expect(result.props.src).toBe("/photo.jpg"); - expect(result.props.src).not.toContain("/_vinext/image"); + expect(result.props.src).not.toContain("/_next/image"); }); it("SVG src auto-skips optimization endpoint (default behavior)", async () => { @@ -12997,7 +12997,7 @@ describe("next/image enhancements", () => { }); // By default (dangerouslyAllowSVG not set), .svg sources bypass the optimizer expect(result.props.src).toBe("/logo.svg"); - expect(result.props.src).not.toContain("/_vinext/image"); + expect(result.props.src).not.toContain("/_next/image"); }); it("non-SVG src still uses optimization endpoint", async () => { @@ -13008,7 +13008,7 @@ describe("next/image enhancements", () => { width: 256, height: 256, }); - expect(result.props.src).toContain("/_vinext/image"); + expect(result.props.src).toContain("/_next/image"); }); }); @@ -13022,7 +13022,7 @@ describe("next/image component rendering", () => { React.createElement(Image, { src: "/photo.jpg", alt: "Test photo", width: 800, height: 600 }), ); // Local images route through the optimization endpoint - expect(html).toContain("/_vinext/image"); + expect(html).toContain("/_next/image"); expect(html).toContain("url=%2Fphoto.jpg"); expect(html).toContain('alt="Test photo"'); expect(html).toContain('width="800"'); @@ -13083,8 +13083,8 @@ describe("next/image component rendering", () => { React.createElement(Image, { src: "/photo.jpg", alt: "Photo", width: 1200, height: 800 }), ); expect(html).toContain("srcSet"); - // srcSet entries point to /_vinext/image optimization endpoint - expect(html).toContain("/_vinext/image"); + // srcSet entries point to /_next/image optimization endpoint + expect(html).toContain("/_next/image"); expect(html).toContain("url=%2Fphoto.jpg"); }); @@ -13169,7 +13169,7 @@ describe("next/image component rendering", () => { const html = renderToStaticMarkup( React.createElement(Image, { src: staticImport, alt: "Imported" }), ); - expect(html).toContain("/_vinext/image"); + expect(html).toContain("/_next/image"); expect(html).toContain("url=%2Fimported.jpg"); expect(html).toContain('width="1200"'); expect(html).toContain('height="800"'); @@ -13235,7 +13235,7 @@ describe("next/image component rendering", () => { ); // SSR should render without errors — the onError replay is client-side only expect(html).toContain(" { @@ -13254,7 +13254,7 @@ describe("next/image component rendering", () => { }), ); expect(html).toContain(" { @@ -13274,7 +13274,7 @@ describe("next/image component rendering", () => { }), ); expect(html).toContain(" { }), ); expect(html).toContain(" { it("imageOptimizationUrl generates correct URL", async () => { const { imageOptimizationUrl } = await import("../packages/vinext/src/shims/image.js"); const url = imageOptimizationUrl("/images/hero.webp", 1200, 75); - expect(url).toBe("/_vinext/image?url=%2Fimages%2Fhero.webp&w=1200&q=75"); + expect(url).toBe("/_next/image?url=%2Fimages%2Fhero.webp&w=1200&q=75"); }); it("imageOptimizationUrl encodes special characters", async () => { @@ -13447,7 +13447,7 @@ describe("image optimization request parsing", () => { it("parseImageParams extracts url, width, quality", async () => { const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js"); - const url = new URL("http://localhost/_vinext/image?url=%2Fimages%2Fhero.webp&w=1200&q=75"); + const url = new URL("http://localhost/_next/image?url=%2Fimages%2Fhero.webp&w=1200&q=75"); const params = parseImageParams(url); expect(params).not.toBeNull(); expect(params!.imageUrl).toBe("/images/hero.webp"); @@ -13458,37 +13458,35 @@ describe("image optimization request parsing", () => { it("parseImageParams returns null when url is missing", async () => { const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js"); - const url = new URL("http://localhost/_vinext/image?w=800&q=75"); + const url = new URL("http://localhost/_next/image?w=800&q=75"); expect(parseImageParams(url)).toBeNull(); }); it("parseImageParams blocks absolute http URLs", async () => { const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js"); - const url = new URL("http://localhost/_vinext/image?url=http%3A%2F%2Fevil.com%2Fimg.jpg&w=800"); + const url = new URL("http://localhost/_next/image?url=http%3A%2F%2Fevil.com%2Fimg.jpg&w=800"); expect(parseImageParams(url)).toBeNull(); }); it("parseImageParams blocks absolute https URLs", async () => { const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js"); - const url = new URL( - "http://localhost/_vinext/image?url=https%3A%2F%2Fevil.com%2Fimg.jpg&w=800", - ); + const url = new URL("http://localhost/_next/image?url=https%3A%2F%2Fevil.com%2Fimg.jpg&w=800"); expect(parseImageParams(url)).toBeNull(); }); it("parseImageParams blocks protocol-relative URLs", async () => { const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js"); - const url = new URL("http://localhost/_vinext/image?url=%2F%2Fevil.com%2Fimg.jpg&w=800"); + const url = new URL("http://localhost/_next/image?url=%2F%2Fevil.com%2Fimg.jpg&w=800"); expect(parseImageParams(url)).toBeNull(); }); it("parseImageParams defaults width to 0 and quality to 75", async () => { const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js"); - const url = new URL("http://localhost/_vinext/image?url=%2Fimg.jpg"); + const url = new URL("http://localhost/_next/image?url=%2Fimg.jpg"); const params = parseImageParams(url); expect(params).not.toBeNull(); expect(params!.width).toBe(0); @@ -13501,7 +13499,7 @@ describe("image optimization request parsing", () => { expect( parseImageParams( new URL( - "http://localhost/_vinext/image?url=data%3Atext%2Fhtml%2C%3Cscript%3Ealert(1)%3C%2Fscript%3E&w=800", + "http://localhost/_next/image?url=data%3Atext%2Fhtml%2C%3Cscript%3Ealert(1)%3C%2Fscript%3E&w=800", ), ), ).toBeNull(); @@ -13511,26 +13509,22 @@ describe("image optimization request parsing", () => { const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js"); expect( - parseImageParams(new URL("http://localhost/_vinext/image?url=javascript%3Aalert(1)&w=800")), + parseImageParams(new URL("http://localhost/_next/image?url=javascript%3Aalert(1)&w=800")), ).toBeNull(); }); it("parseImageParams blocks bare filenames (no leading slash)", async () => { const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js"); - expect( - parseImageParams(new URL("http://localhost/_vinext/image?url=img.jpg&w=800")), - ).toBeNull(); + expect(parseImageParams(new URL("http://localhost/_next/image?url=img.jpg&w=800"))).toBeNull(); }); it("parseImageParams rejects quality outside 1-100", async () => { const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js"); + expect(parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&q=0"))).toBeNull(); expect( - parseImageParams(new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&q=0")), - ).toBeNull(); - expect( - parseImageParams(new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&q=101")), + parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&q=101")), ).toBeNull(); }); @@ -13539,7 +13533,7 @@ describe("image optimization request parsing", () => { await import("../packages/vinext/src/server/image-optimization.js"); // /\evil.com — browsers and the URL constructor treat this as //evil.com expect( - parseImageParams(new URL("http://localhost/_vinext/image?url=%2F%5Cevil.com&w=800")), + parseImageParams(new URL("http://localhost/_next/image?url=%2F%5Cevil.com&w=800")), ).toBeNull(); }); @@ -13548,13 +13542,11 @@ describe("image optimization request parsing", () => { await import("../packages/vinext/src/server/image-optimization.js"); // /\evil.com/img.jpg expect( - parseImageParams( - new URL("http://localhost/_vinext/image?url=%2F%5Cevil.com%2Fimg.jpg&w=800"), - ), + parseImageParams(new URL("http://localhost/_next/image?url=%2F%5Cevil.com%2Fimg.jpg&w=800")), ).toBeNull(); // /\\evil.com (double backslash) expect( - parseImageParams(new URL("http://localhost/_vinext/image?url=%2F%5C%5Cevil.com&w=800")), + parseImageParams(new URL("http://localhost/_next/image?url=%2F%5C%5Cevil.com&w=800")), ).toBeNull(); }); @@ -13565,7 +13557,7 @@ describe("image optimization request parsing", () => { // the origin check catches it. // A valid relative URL should pass const good = parseImageParams( - new URL("http://localhost/_vinext/image?url=%2Fimages%2Fhero.webp&w=800"), + new URL("http://localhost/_next/image?url=%2Fimages%2Fhero.webp&w=800"), ); expect(good).not.toBeNull(); expect(good!.imageUrl).toBe("/images/hero.webp"); @@ -13577,7 +13569,7 @@ describe("image optimization request parsing", () => { // /images\hero.webp should be normalized to /images/hero.webp // (backslash in the middle of a valid path) const result = parseImageParams( - new URL("http://localhost/_vinext/image?url=%2Fimages%5Chero.webp&w=800"), + new URL("http://localhost/_next/image?url=%2Fimages%5Chero.webp&w=800"), ); expect(result).not.toBeNull(); expect(result!.imageUrl).toBe("/images/hero.webp"); @@ -13587,22 +13579,20 @@ describe("image optimization request parsing", () => { const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js"); expect( - parseImageParams(new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=3841")), + parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=3841")), ).toBeNull(); expect( - parseImageParams(new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=999999999")), + parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=999999999")), ).toBeNull(); expect( - parseImageParams(new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=2147483647")), + parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=2147483647")), ).toBeNull(); }); it("parseImageParams accepts width at the absolute maximum (3840)", async () => { const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js"); - const params = parseImageParams( - new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=3840"), - ); + const params = parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=3840")); expect(params).not.toBeNull(); expect(params!.width).toBe(3840); }); @@ -13613,21 +13603,18 @@ describe("image optimization request parsing", () => { const allowedWidths = [640, 750, 828, 1080, 1200, 1920, 2048, 3840]; // Allowed width passes const params = parseImageParams( - new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=1080"), + new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=1080"), allowedWidths, ); expect(params).not.toBeNull(); expect(params!.width).toBe(1080); // Non-allowed width is rejected expect( - parseImageParams( - new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=999"), - allowedWidths, - ), + parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=999"), allowedWidths), ).toBeNull(); // w=0 (no resize) is always allowed even with allowedWidths const noResize = parseImageParams( - new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=0"), + new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=0"), allowedWidths, ); expect(noResize).not.toBeNull(); @@ -13641,7 +13628,7 @@ describe("image optimization request parsing", () => { 16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840, ]; const params = parseImageParams( - new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=64"), + new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=64"), allowedWidths, ); expect(params).not.toBeNull(); @@ -13667,10 +13654,10 @@ describe("image optimization request parsing", () => { expect(negotiateImageFormat(null)).toBe("image/jpeg"); }); - it("IMAGE_OPTIMIZATION_PATH is /_vinext/image", async () => { + it("IMAGE_OPTIMIZATION_PATH is /_next/image", async () => { const { IMAGE_OPTIMIZATION_PATH } = await import("../packages/vinext/src/server/image-optimization.js"); - expect(IMAGE_OPTIMIZATION_PATH).toBe("/_vinext/image"); + expect(IMAGE_OPTIMIZATION_PATH).toBe("/_next/image"); }); it("exports DEFAULT_DEVICE_SIZES and DEFAULT_IMAGE_SIZES matching Next.js defaults", async () => { @@ -13755,7 +13742,7 @@ describe("handleImageOptimization", () => { it("returns 400 for invalid params", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image"); + const request = new Request("http://localhost/_next/image"); const handlers = { fetchAsset: async () => new Response("", { status: 200 }), }; @@ -13766,7 +13753,7 @@ describe("handleImageOptimization", () => { it("returns 404 when fetchAsset fails", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800"); + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800"); const handlers = { fetchAsset: async () => new Response("", { status: 404 }), }; @@ -13777,7 +13764,7 @@ describe("handleImageOptimization", () => { it("returns original image when no transformImage handler", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800"); + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800"); const handlers = { fetchAsset: async () => new Response("original-image-data", { @@ -13795,7 +13782,7 @@ describe("handleImageOptimization", () => { it("calls transformImage when provided", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800&q=90", { + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800&q=90", { headers: { Accept: "image/webp" }, }); let capturedOptions: { width: number; format: string; quality: number } | null = null; @@ -13822,7 +13809,7 @@ describe("handleImageOptimization", () => { it("falls back to original on transform error", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800"); + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800"); let fetchCount = 0; const handlers = { fetchAsset: async () => { @@ -13845,7 +13832,7 @@ describe("handleImageOptimization", () => { it("refetches the source when transform consumes the stream before failing", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800"); + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800"); let fetchCount = 0; const handlers = { fetchAsset: async () => { @@ -13869,7 +13856,7 @@ describe("handleImageOptimization", () => { it("uses refetched source headers when consumed transform falls back", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800"); + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800"); let fetchCount = 0; const handlers = { fetchAsset: async () => { @@ -13899,7 +13886,7 @@ describe("handleImageOptimization", () => { it("returns 404 when refetch fallback cannot reload the source image", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800"); + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800"); let fetchCount = 0; const handlers = { fetchAsset: async () => { @@ -13925,7 +13912,7 @@ describe("handleImageOptimization", () => { it("returns 400 when refetch fallback reloads an unsafe content type", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800"); + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800"); let fetchCount = 0; const handlers = { fetchAsset: async () => { @@ -13951,7 +13938,7 @@ describe("handleImageOptimization", () => { it("returns 400 for backslash open redirect (/\\evil.com)", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2F%5Cevil.com&w=800"); + const request = new Request("http://localhost/_next/image?url=%2F%5Cevil.com&w=800"); const handlers = { fetchAsset: async () => new Response("should not be called", { status: 200 }), }; @@ -13963,7 +13950,7 @@ describe("handleImageOptimization", () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); const request = new Request( - "http://localhost/_vinext/image?url=%2F%5Cgoogle.com%2Fimg.jpg&w=800", + "http://localhost/_next/image?url=%2F%5Cgoogle.com%2Fimg.jpg&w=800", ); let fetchCalled = false; const handlers = { @@ -13980,7 +13967,7 @@ describe("handleImageOptimization", () => { it("blocks SVG content type", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fmalicious.svg&w=100&q=75"); + const request = new Request("http://localhost/_next/image?url=%2Fmalicious.svg&w=100&q=75"); const handlers = { fetchAsset: async () => new Response("", { @@ -13996,7 +13983,7 @@ describe("handleImageOptimization", () => { it("blocks text/html content type", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Ffake.jpg&w=100&q=75"); + const request = new Request("http://localhost/_next/image?url=%2Ffake.jpg&w=100&q=75"); const handlers = { fetchAsset: async () => new Response("", { @@ -14011,7 +13998,7 @@ describe("handleImageOptimization", () => { it("blocks responses with no Content-Type", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800"); + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800"); const handlers = { fetchAsset: async () => new Response("data", { status: 200 }), }; @@ -14022,7 +14009,7 @@ describe("handleImageOptimization", () => { it("sets Content-Security-Policy header on fallback responses", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800"); + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800"); const handlers = { fetchAsset: async () => new Response("image-data", { @@ -14042,7 +14029,7 @@ describe("handleImageOptimization", () => { it("sets Content-Security-Policy header on transformed responses", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800&q=90", { + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800&q=90", { headers: { Accept: "image/webp" }, }); const handlers = { @@ -14068,7 +14055,7 @@ describe("handleImageOptimization", () => { it("overrides unsafe Content-Type from transform handler", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800&q=90", { + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800&q=90", { headers: { Accept: "image/webp" }, }); const handlers = { @@ -14089,7 +14076,7 @@ describe("handleImageOptimization", () => { it("allows SVG passthrough with dangerouslyAllowSVG: true", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Flogo.svg&w=100&q=75"); + const request = new Request("http://localhost/_next/image?url=%2Flogo.svg&w=100&q=75"); const handlers = { fetchAsset: async () => new Response('', { @@ -14108,7 +14095,7 @@ describe("handleImageOptimization", () => { it("still blocks SVG when dangerouslyAllowSVG is false", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Flogo.svg&w=100&q=75"); + const request = new Request("http://localhost/_next/image?url=%2Flogo.svg&w=100&q=75"); const handlers = { fetchAsset: async () => new Response("", { @@ -14125,7 +14112,7 @@ describe("handleImageOptimization", () => { it("SVG passthrough skips transformImage", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Flogo.svg&w=100&q=75"); + const request = new Request("http://localhost/_next/image?url=%2Flogo.svg&w=100&q=75"); let transformCalled = false; const handlers = { fetchAsset: async () => @@ -14149,7 +14136,7 @@ describe("handleImageOptimization", () => { it("applies security headers on SVG passthrough", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Flogo.svg&w=100&q=75"); + const request = new Request("http://localhost/_next/image?url=%2Flogo.svg&w=100&q=75"); const handlers = { fetchAsset: async () => new Response("", { @@ -14171,7 +14158,7 @@ describe("handleImageOptimization", () => { it("applies custom contentDispositionType", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800"); + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800"); const handlers = { fetchAsset: async () => new Response("image-data", { @@ -14189,7 +14176,7 @@ describe("handleImageOptimization", () => { it("defaults Content-Disposition to inline when contentDispositionType is invalid", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800"); + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800"); const handlers = { fetchAsset: async () => new Response("image-data", { @@ -14207,7 +14194,7 @@ describe("handleImageOptimization", () => { it("applies custom contentSecurityPolicy", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800"); + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800"); const handlers = { fetchAsset: async () => new Response("image-data", { @@ -14226,7 +14213,7 @@ describe("handleImageOptimization", () => { it("default behavior unchanged when no imageConfig provided", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js"); - const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800"); + const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=800"); const handlers = { fetchAsset: async () => new Response("image-data", { From 51c7cefa26153e7f6009377c3a0a6550943a2bb8 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 26 May 2026 10:16:18 +0100 Subject: [PATCH 2/3] refactor(image): use IMAGE_OPTIMIZATION_PATH constant at remaining call sites Replace hardcoded "/_next/image" strings in index.ts, app-rsc-handler.ts, and the generated worker entry templates in deploy.ts with the IMAGE_OPTIMIZATION_PATH constant from server/image-optimization, matching the pattern already used in prod-server.ts. Prevents future drift if the path ever changes again. --- packages/vinext/src/deploy.ts | 8 ++++---- packages/vinext/src/index.ts | 3 ++- packages/vinext/src/server/app-rsc-handler.ts | 3 ++- tests/deploy.test.ts | 6 +++--- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 4a021d998..a3f2de54f 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -469,7 +469,7 @@ import { setCacheHandler } from "vinext/shims/cache"; * For apps without image optimization, you can use vinext/server/app-router-entry * directly in wrangler.jsonc: "main": "vinext/server/app-router-entry" */ -import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization"; +import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, IMAGE_OPTIMIZATION_PATH } from "vinext/server/image-optimization"; import type { ImageConfig } from "vinext/server/image-optimization"; import handler from "vinext/server/app-router-entry"; ${isrImports} @@ -502,7 +502,7 @@ ${isrSetup} const url = new URL(request.url); // Image optimization via Cloudflare Images binding. // The parseImageParams validation inside handleImageOptimization // normalizes backslashes and validates the origin hasn't changed. - if (url.pathname === "/_next/image") { + if (url.pathname === IMAGE_OPTIMIZATION_PATH) { const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES]; return handleImageOptimization(request, { fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))), @@ -528,7 +528,7 @@ export function generatePagesRouterWorkerEntry(): string { * Cloudflare Worker entry point -- auto-generated by vinext deploy. * Edit freely or delete to regenerate on next deploy. */ -import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization"; +import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, IMAGE_OPTIMIZATION_PATH } from "vinext/server/image-optimization"; import type { ImageConfig } from "vinext/server/image-optimization"; import { matchRedirect, @@ -638,7 +638,7 @@ export default { // ── Image optimization via Cloudflare Images binding ────────── // Checked after basePath stripping so //_next/image works. - if (pathname === "/_next/image") { + if (pathname === IMAGE_OPTIMIZATION_PATH) { const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES]; return handleImageOptimization(request, { fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))), diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index ff484fa8a..d192fdda8 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -13,6 +13,7 @@ import type { NitroRouteRuleConfig } from "./build/nitro-route-rules.js"; import { createValidFileMatcher } from "./routing/file-matcher.js"; import { createSSRHandler } from "./server/dev-server.js"; import { handleApiRoute } from "./server/api-handler.js"; +import { IMAGE_OPTIMIZATION_PATH } from "./server/image-optimization.js"; import { normalizeDefaultLocalePathname, stripI18nLocaleForApiRoute } from "./server/pages-i18n.js"; import { installSocketErrorBackstop } from "./server/socket-error-backstop.js"; import { shouldInvalidateAppRouteFile } from "./server/dev-route-files.js"; @@ -2797,7 +2798,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // ── Image optimization passthrough (dev mode) ───────────── // In dev, redirect to the original asset URL so Vite serves it. - if (url.split("?")[0] === "/_next/image") { + if (url.split("?")[0] === IMAGE_OPTIMIZATION_PATH) { const imgParams = new URLSearchParams(url.split("?")[1] ?? ""); const rawImgUrl = imgParams.get("url"); // Normalize backslashes: browsers and the URL constructor treat diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 70c530895..93f065f8c 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -45,6 +45,7 @@ import { normalizeDefaultLocalePathname } from "./pages-i18n.js"; import { notFoundResponse } from "./http-error-responses.js"; import { getScriptNonceFromHeaderSources } from "./csp.js"; import { buildPageCacheTags } from "./implicit-tags.js"; +import { IMAGE_OPTIMIZATION_PATH } from "./image-optimization.js"; import { handleMetadataRouteRequest } from "./metadata-route-response.js"; import type { MiddlewareModule } from "./middleware-runtime.js"; import { runWithPrerenderWorkUnit } from "./prerender-work-unit-setup.js"; @@ -444,7 +445,7 @@ async function handleAppRscRequest( if (beforeFilesRewrite instanceof Response) return beforeFilesRewrite; if (beforeFilesRewrite) cleanPathname = beforeFilesRewrite; - if (cleanPathname === "/_next/image") { + if (cleanPathname === IMAGE_OPTIMIZATION_PATH) { const imageUrlResult = validateImageUrl(url.searchParams.get("url"), request.url); if (imageUrlResult instanceof Response) return imageUrlResult; return Response.redirect(new URL(imageUrlResult, url.origin).href, 302); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index ca1e8d6f3..e639c6e21 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -455,7 +455,7 @@ describe("generateAppRouterWorkerEntry", () => { it("includes /_next/image handler", () => { const content = generateAppRouterWorkerEntry(); - expect(content).toContain("/_next/image"); + expect(content).toContain("IMAGE_OPTIMIZATION_PATH"); expect(content).toContain("handleImageOptimization"); }); @@ -668,7 +668,7 @@ describe("generatePagesRouterWorkerEntry", () => { it("includes /_next/image handler", () => { const content = generatePagesRouterWorkerEntry(); - expect(content).toContain("/_next/image"); + expect(content).toContain("IMAGE_OPTIMIZATION_PATH"); expect(content).toContain("handleImageOptimization"); }); @@ -920,7 +920,7 @@ describe("generatePagesRouterWorkerEntry", () => { it("checks image optimization after basePath stripping", () => { const content = generatePagesRouterWorkerEntry(); const basePathPos = content.indexOf("const stripped = stripBasePath(pathname, basePath);"); - const imagePos = content.indexOf('pathname === "/_next/image"'); + const imagePos = content.indexOf("pathname === IMAGE_OPTIMIZATION_PATH"); expect(basePathPos).toBeGreaterThan(-1); expect(imagePos).toBeGreaterThan(-1); expect(basePathPos).toBeLessThan(imagePos); From caa2906b6d0319167c4bfd4de627cc4791996f00 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 26 May 2026 10:23:17 +0100 Subject: [PATCH 3/3] feat(image): accept both /_next/image and /_vinext/image at the optimizer Add a VINEXT_IMAGE_OPTIMIZATION_PATH constant and an isImageOptimizationPath() helper, then route through every match site (prod-server, dev server passthrough, app RSC handler, generated worker templates, and shipped example workers). Apps that wire image URLs to either prefix now hit the same handler; new URLs are still emitted via IMAGE_OPTIMIZATION_PATH. --- apps/web/worker/index.ts | 3 ++- examples/app-router-cloudflare/worker/index.ts | 4 ++-- examples/app-router-playground/worker/index.ts | 4 ++-- packages/vinext/src/deploy.ts | 8 ++++---- packages/vinext/src/index.ts | 4 ++-- packages/vinext/src/server/app-rsc-handler.ts | 4 ++-- .../vinext/src/server/image-optimization.ts | 15 ++++++++++++++- packages/vinext/src/server/prod-server.ts | 6 +++--- tests/deploy.test.ts | 10 +++++----- tests/image-optimization-parity.test.ts | 11 +++++++++++ tests/shims.test.ts | 17 +++++++++++++++++ 11 files changed, 64 insertions(+), 22 deletions(-) diff --git a/apps/web/worker/index.ts b/apps/web/worker/index.ts index da00e2688..936217109 100644 --- a/apps/web/worker/index.ts +++ b/apps/web/worker/index.ts @@ -11,6 +11,7 @@ import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, + isImageOptimizationPath, } from "vinext/server/image-optimization"; import handler from "vinext/server/app-router-entry"; @@ -59,7 +60,7 @@ export default { // Image optimization via Cloudflare Images binding. // The parseImageParams validation inside handleImageOptimization // normalizes backslashes and validates the origin hasn't changed. - if (url.pathname === "/_next/image") { + if (isImageOptimizationPath(url.pathname)) { const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES]; return handleImageOptimization( request, diff --git a/examples/app-router-cloudflare/worker/index.ts b/examples/app-router-cloudflare/worker/index.ts index ea7650b36..5c3a67b65 100644 --- a/examples/app-router-cloudflare/worker/index.ts +++ b/examples/app-router-cloudflare/worker/index.ts @@ -4,7 +4,7 @@ * For apps without image optimization, use vinext/server/app-router-entry * directly in wrangler.jsonc: "main": "vinext/server/app-router-entry" */ -import { handleImageOptimization } from "vinext/server/image-optimization"; +import { handleImageOptimization, isImageOptimizationPath } from "vinext/server/image-optimization"; import handler from "vinext/server/app-router-entry"; interface Env { @@ -23,7 +23,7 @@ export default { const url = new URL(request.url); // Image optimization via Cloudflare Images binding - if (url.pathname === "/_next/image") { + if (isImageOptimizationPath(url.pathname)) { return handleImageOptimization(request, { fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))), transformImage: async (body, { width, format, quality }) => { diff --git a/examples/app-router-playground/worker/index.ts b/examples/app-router-playground/worker/index.ts index 608aabcbd..005871c6a 100644 --- a/examples/app-router-playground/worker/index.ts +++ b/examples/app-router-playground/worker/index.ts @@ -4,7 +4,7 @@ * For apps without image optimization, use vinext/server/app-router-entry * directly in wrangler.jsonc: "main": "vinext/server/app-router-entry" */ -import { handleImageOptimization } from "vinext/server/image-optimization"; +import { handleImageOptimization, isImageOptimizationPath } from "vinext/server/image-optimization"; import handler from "vinext/server/app-router-entry"; interface Fetcher { @@ -27,7 +27,7 @@ export default { const url = new URL(request.url); // Image optimization via Cloudflare Images binding - if (url.pathname === "/_next/image") { + if (isImageOptimizationPath(url.pathname)) { return handleImageOptimization(request, { fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))), transformImage: async (body, { width, format, quality }) => { diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index a3f2de54f..f5f685039 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -469,7 +469,7 @@ import { setCacheHandler } from "vinext/shims/cache"; * For apps without image optimization, you can use vinext/server/app-router-entry * directly in wrangler.jsonc: "main": "vinext/server/app-router-entry" */ -import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, IMAGE_OPTIMIZATION_PATH } from "vinext/server/image-optimization"; +import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, isImageOptimizationPath } from "vinext/server/image-optimization"; import type { ImageConfig } from "vinext/server/image-optimization"; import handler from "vinext/server/app-router-entry"; ${isrImports} @@ -502,7 +502,7 @@ ${isrSetup} const url = new URL(request.url); // Image optimization via Cloudflare Images binding. // The parseImageParams validation inside handleImageOptimization // normalizes backslashes and validates the origin hasn't changed. - if (url.pathname === IMAGE_OPTIMIZATION_PATH) { + if (isImageOptimizationPath(url.pathname)) { const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES]; return handleImageOptimization(request, { fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))), @@ -528,7 +528,7 @@ export function generatePagesRouterWorkerEntry(): string { * Cloudflare Worker entry point -- auto-generated by vinext deploy. * Edit freely or delete to regenerate on next deploy. */ -import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, IMAGE_OPTIMIZATION_PATH } from "vinext/server/image-optimization"; +import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, isImageOptimizationPath } from "vinext/server/image-optimization"; import type { ImageConfig } from "vinext/server/image-optimization"; import { matchRedirect, @@ -638,7 +638,7 @@ export default { // ── Image optimization via Cloudflare Images binding ────────── // Checked after basePath stripping so //_next/image works. - if (pathname === IMAGE_OPTIMIZATION_PATH) { + if (isImageOptimizationPath(pathname)) { const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES]; return handleImageOptimization(request, { fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))), diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index d192fdda8..e1827efa6 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -13,7 +13,7 @@ import type { NitroRouteRuleConfig } from "./build/nitro-route-rules.js"; import { createValidFileMatcher } from "./routing/file-matcher.js"; import { createSSRHandler } from "./server/dev-server.js"; import { handleApiRoute } from "./server/api-handler.js"; -import { IMAGE_OPTIMIZATION_PATH } from "./server/image-optimization.js"; +import { isImageOptimizationPath } from "./server/image-optimization.js"; import { normalizeDefaultLocalePathname, stripI18nLocaleForApiRoute } from "./server/pages-i18n.js"; import { installSocketErrorBackstop } from "./server/socket-error-backstop.js"; import { shouldInvalidateAppRouteFile } from "./server/dev-route-files.js"; @@ -2798,7 +2798,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // ── Image optimization passthrough (dev mode) ───────────── // In dev, redirect to the original asset URL so Vite serves it. - if (url.split("?")[0] === IMAGE_OPTIMIZATION_PATH) { + if (isImageOptimizationPath(url.split("?")[0]!)) { const imgParams = new URLSearchParams(url.split("?")[1] ?? ""); const rawImgUrl = imgParams.get("url"); // Normalize backslashes: browsers and the URL constructor treat diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 93f065f8c..46b503cb0 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -45,7 +45,7 @@ import { normalizeDefaultLocalePathname } from "./pages-i18n.js"; import { notFoundResponse } from "./http-error-responses.js"; import { getScriptNonceFromHeaderSources } from "./csp.js"; import { buildPageCacheTags } from "./implicit-tags.js"; -import { IMAGE_OPTIMIZATION_PATH } from "./image-optimization.js"; +import { isImageOptimizationPath } from "./image-optimization.js"; import { handleMetadataRouteRequest } from "./metadata-route-response.js"; import type { MiddlewareModule } from "./middleware-runtime.js"; import { runWithPrerenderWorkUnit } from "./prerender-work-unit-setup.js"; @@ -445,7 +445,7 @@ async function handleAppRscRequest( if (beforeFilesRewrite instanceof Response) return beforeFilesRewrite; if (beforeFilesRewrite) cleanPathname = beforeFilesRewrite; - if (cleanPathname === IMAGE_OPTIMIZATION_PATH) { + if (isImageOptimizationPath(cleanPathname)) { const imageUrlResult = validateImageUrl(url.searchParams.get("url"), request.url); if (imageUrlResult instanceof Response) return imageUrlResult; return Response.redirect(new URL(imageUrlResult, url.origin).href, 302); diff --git a/packages/vinext/src/server/image-optimization.ts b/packages/vinext/src/server/image-optimization.ts index 85f68936a..7eed30826 100644 --- a/packages/vinext/src/server/image-optimization.ts +++ b/packages/vinext/src/server/image-optimization.ts @@ -19,9 +19,22 @@ import { badRequestResponse } from "./http-error-responses.js"; -/** The pathname that triggers image optimization. */ +/** The pathname that triggers image optimization (matches Next.js). */ export const IMAGE_OPTIMIZATION_PATH = "/_next/image"; +/** + * Vinext-prefixed alias for the image optimization endpoint. Accepted + * alongside IMAGE_OPTIMIZATION_PATH so apps that wire image URLs to the + * vinext-prefixed path continue to work; emit IMAGE_OPTIMIZATION_PATH + * for any newly generated URLs. + */ +export const VINEXT_IMAGE_OPTIMIZATION_PATH = "/_vinext/image"; + +/** Returns true when `pathname` is either supported image optimization endpoint. */ +export function isImageOptimizationPath(pathname: string): boolean { + return pathname === IMAGE_OPTIMIZATION_PATH || pathname === VINEXT_IMAGE_OPTIMIZATION_PATH; +} + /** * Image security configuration from next.config.js `images` section. * Controls SVG handling and security headers for the image endpoint. diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 3d77a8dba..bee7c78f7 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -36,7 +36,7 @@ import { } from "../config/config-matchers.js"; import type { RequestContext } from "../config/config-matchers.js"; import { - IMAGE_OPTIMIZATION_PATH, + isImageOptimizationPath, IMAGE_CONTENT_SECURITY_POLICY, parseImageParams, isSafeImageContentType, @@ -1182,7 +1182,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) { // Image optimization passthrough (Node.js prod server has no Images binding; // serves the original file with cache headers and security headers) - if (pathname === IMAGE_OPTIMIZATION_PATH) { + if (isImageOptimizationPath(pathname)) { const parsedUrl = new URL(rawUrl, "http://localhost"); const defaultAllowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES]; const params = parseImageParams(parsedUrl, defaultAllowedWidths); @@ -1528,7 +1528,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { } // ── Image optimization passthrough ────────────────────────────── - if (pathname === IMAGE_OPTIMIZATION_PATH || staticLookupPath === IMAGE_OPTIMIZATION_PATH) { + if (isImageOptimizationPath(pathname) || isImageOptimizationPath(staticLookupPath)) { const parsedUrl = new URL(rawUrl, "http://localhost"); const params = parseImageParams(parsedUrl, allowedImageWidths); if (!params) { diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index e639c6e21..1a8925ed0 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -453,9 +453,9 @@ describe("generateAppRouterWorkerEntry", () => { expect(content).toContain("Promise"); }); - it("includes /_next/image handler", () => { + it("includes image optimization handler", () => { const content = generateAppRouterWorkerEntry(); - expect(content).toContain("IMAGE_OPTIMIZATION_PATH"); + expect(content).toContain("isImageOptimizationPath"); expect(content).toContain("handleImageOptimization"); }); @@ -666,9 +666,9 @@ describe("generatePagesRouterWorkerEntry", () => { expect(content).toContain("Internal Server Error"); }); - it("includes /_next/image handler", () => { + it("includes image optimization handler", () => { const content = generatePagesRouterWorkerEntry(); - expect(content).toContain("IMAGE_OPTIMIZATION_PATH"); + expect(content).toContain("isImageOptimizationPath"); expect(content).toContain("handleImageOptimization"); }); @@ -920,7 +920,7 @@ describe("generatePagesRouterWorkerEntry", () => { it("checks image optimization after basePath stripping", () => { const content = generatePagesRouterWorkerEntry(); const basePathPos = content.indexOf("const stripped = stripBasePath(pathname, basePath);"); - const imagePos = content.indexOf("pathname === IMAGE_OPTIMIZATION_PATH"); + const imagePos = content.indexOf("isImageOptimizationPath(pathname)"); expect(basePathPos).toBeGreaterThan(-1); expect(imagePos).toBeGreaterThan(-1); expect(basePathPos).toBeLessThan(imagePos); diff --git a/tests/image-optimization-parity.test.ts b/tests/image-optimization-parity.test.ts index ba1cef737..60160e167 100644 --- a/tests/image-optimization-parity.test.ts +++ b/tests/image-optimization-parity.test.ts @@ -144,6 +144,17 @@ function runLocalImageUrlParitySuite(router: "app" | "pages"): void { const res = await fetch(imageUrl); expect(res.status).toBe(200); }); + + // Both /_next/image and /_vinext/image are accepted so apps wired to + // either prefix get images served through the same optimizer pipeline. + it("routes /_vinext/image requests through the optimizer", async () => { + const vinextUrl = new URL("/_vinext/image", baseUrl); + vinextUrl.searchParams.set("url", "/hello world.png"); + vinextUrl.searchParams.set("w", "64"); + vinextUrl.searchParams.set("q", "75"); + const res = await fetch(vinextUrl); + expect(res.status).toBe(200); + }); }); } diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 36348588c..526890bbb 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -13660,6 +13660,23 @@ describe("image optimization request parsing", () => { expect(IMAGE_OPTIMIZATION_PATH).toBe("/_next/image"); }); + it("VINEXT_IMAGE_OPTIMIZATION_PATH is /_vinext/image", async () => { + const { VINEXT_IMAGE_OPTIMIZATION_PATH } = + await import("../packages/vinext/src/server/image-optimization.js"); + expect(VINEXT_IMAGE_OPTIMIZATION_PATH).toBe("/_vinext/image"); + }); + + it("isImageOptimizationPath accepts both supported endpoints", async () => { + const { isImageOptimizationPath } = + await import("../packages/vinext/src/server/image-optimization.js"); + expect(isImageOptimizationPath("/_next/image")).toBe(true); + expect(isImageOptimizationPath("/_vinext/image")).toBe(true); + expect(isImageOptimizationPath("/_next/image/")).toBe(false); + expect(isImageOptimizationPath("/_next/image.png")).toBe(false); + expect(isImageOptimizationPath("/_next/data")).toBe(false); + expect(isImageOptimizationPath("/")).toBe(false); + }); + it("exports DEFAULT_DEVICE_SIZES and DEFAULT_IMAGE_SIZES matching Next.js defaults", async () => { const { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } = await import("../packages/vinext/src/server/image-optimization.js");