Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 2 additions & 1 deletion apps/web/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 === "/_vinext/image") {
if (isImageOptimizationPath(url.pathname)) {
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
return handleImageOptimization(
request,
Expand Down
4 changes: 2 additions & 2 deletions examples/app-router-cloudflare/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,7 +23,7 @@ export default {
const url = new URL(request.url);

// Image optimization via Cloudflare Images binding
if (url.pathname === "/_vinext/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 }) => {
Expand Down
4 changes: 2 additions & 2 deletions examples/app-router-playground/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,7 +27,7 @@ export default {
const url = new URL(request.url);

// Image optimization via Cloudflare Images binding
if (url.pathname === "/_vinext/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 }) => {
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
10 changes: 5 additions & 5 deletions packages/vinext/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, isImageOptimizationPath } from "vinext/server/image-optimization";
import type { ImageConfig } from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";
${isrImports}
Expand Down 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 (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))),
Expand All @@ -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, isImageOptimizationPath } from "vinext/server/image-optimization";
import type { ImageConfig } from "vinext/server/image-optimization";
import {
matchRedirect,
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 (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))),
Expand Down
3 changes: 2 additions & 1 deletion packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { 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";
Expand Down Expand Up @@ -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] === "/_vinext/image") {
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
Expand Down
3 changes: 2 additions & 1 deletion packages/vinext/src/server/app-rsc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { 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";
Expand Down Expand Up @@ -444,7 +445,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
if (beforeFilesRewrite instanceof Response) return beforeFilesRewrite;
if (beforeFilesRewrite) cleanPathname = beforeFilesRewrite;

if (cleanPathname === "/_vinext/image") {
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);
Expand Down
19 changes: 16 additions & 3 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 @@ -19,8 +19,21 @@

import { badRequestResponse } from "./http-error-responses.js";

/** The pathname that triggers image optimization. */
export const IMAGE_OPTIMIZATION_PATH = "/_vinext/image";
/** 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.
Expand Down
6 changes: 3 additions & 3 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
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 image optimization handler", () => {
const content = generateAppRouterWorkerEntry();
expect(content).toContain("/_vinext/image");
expect(content).toContain("isImageOptimizationPath");
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 image optimization handler", () => {
const content = generatePagesRouterWorkerEntry();
expect(content).toContain("/_vinext/image");
expect(content).toContain("isImageOptimizationPath");
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("isImageOptimizationPath(pathname)");
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
Loading
Loading