Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion examples/app-router-cloudflare/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
2 changes: 1 addition & 1 deletion examples/app-router-playground/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/cloudflare/tpr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions packages/vinext/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))),
Expand Down Expand Up @@ -637,8 +637,8 @@ export default {
const basePathState = { basePath, hadBasePath };

// ── Image optimization via Cloudflare Images binding ──────────
// Checked after basePath stripping so /<basePath>/_vinext/image works.
if (pathname === "/_vinext/image") {
// Checked after basePath stripping so /<basePath>/_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))),
Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/server/app-rsc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
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);
Expand Down
4 changes: 2 additions & 2 deletions packages/vinext/src/server/image-optimization.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down
16 changes: 8 additions & 8 deletions packages/vinext/src/shims/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand All @@ -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.
*/
Expand Down Expand Up @@ -589,7 +589,7 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(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).
Expand All @@ -600,7 +600,7 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(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)
Expand Down Expand Up @@ -641,7 +641,7 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
});

// For local images, render a standard <img> 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 (
<img
ref={mergedRef}
Expand Down Expand Up @@ -740,7 +740,7 @@ export function getImageProps(props: ImageProps): {
? imageOptimizationUrl(resolvedSrc, imgWidth, imgQuality)
: imageOptimizationUrl(resolvedSrc, RESPONSIVE_WIDTHS[0], imgQuality);

// Build srcSet for local images — each width points to /_vinext/image
// Build srcSet for local images — each width points to /_next/image
const srcSet =
imgWidth && !fill && !isRemoteUrl(resolvedSrc) && !loader && !skipOpt
? generateSrcSet(resolvedSrc, imgWidth, imgQuality)
Expand Down
10 changes: 5 additions & 5 deletions tests/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,9 +453,9 @@ describe("generateAppRouterWorkerEntry", () => {
expect(content).toContain("Promise<Response>");
});

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");
});

Expand Down Expand Up @@ -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");
});

Expand Down Expand Up @@ -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);
Expand Down
34 changes: 32 additions & 2 deletions tests/image-component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "&amp;");
}

// ─── 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", () => {
Expand Down Expand Up @@ -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");
});
Expand Down
4 changes: 2 additions & 2 deletions tests/image-optimization-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand Down
6 changes: 3 additions & 3 deletions tests/pages-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1518,22 +1518,22 @@ 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);
});

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",
},
Expand Down
Loading
Loading