Files
cloudflare__vinext/tests/app-router-next-config-codegen.test.ts
T
James Anderson b4843d75c1 feat(images): configure image optimization via vinext({ images }) adapter (#1873)
* feat(images): configure image optimization via vinext({ images }) adapter

Move server-side image optimization from a hand-wired custom worker entry to a
declarative `vinext({ images: { optimizer } })` option, mirroring the cache
adapter pattern. The default entries now handle `/_next/image` through a
registered optimizer, so no custom worker is required, and the same config
works across all targets — optimizing on Cloudflare, gracefully serving images
unoptimized on Node/dev where the binding is unavailable (like KV cache
degrading to in-memory).

- add an ImageOptimizer registry (set/getImageOptimizer +
  handleConfiguredImageOptimization) in server/image-optimization.ts
- generate `virtual:vinext-image-adapters` (registerConfiguredImageOptimizer)
  from the new `images` plugin option
- add @vinext/cloudflare/image/image-adapter: imageAdapter() builder + runtime
  factory reading the env.IMAGES binding
- handle /_next/image in the default app-router entry and the generated Pages
  worker via the registry; inline next.config `images` (allowed widths +
  security headers) into the RSC entry
- vinext deploy points App Router `main` at vinext/server/app-router-entry
  (no generated worker) and prints a hint to enable the optimizer

next.config `images` (remotePatterns, deviceSizes, dangerouslyAllowSVG, etc.)
continues to drive the standard Next.js options; the vite-config
`images.optimizer` only selects the runtime transform backend.

* fix(images): cover deploy image-hint helpers and preserve optimizer this-binding

The Check CI job failed because knip flagged viteConfigHasImageAdapter and
formatImageOptimizationHint as unused exports — they were only called inside
deploy.ts. Cover both with unit tests in tests/deploy.test.ts (mirroring the
existing viteConfigHasCacheAdapter / formatMissingCacheAdapterError suites),
which also closes the coverage gap for the new deploy hint path.

Also wrap the registered optimizer's transformImage in
handleConfiguredImageOptimization instead of detaching the method, so an
optimizer implemented as a class instance keeps its this binding.

* fix(images): honor configured deviceSizes/imageSizes on the App Router Node prod server

Review follow-up (ask-bonk):

- The App Router prod server (vinext start) validated /_next/image widths
  against the hardcoded Next.js defaults, rejecting valid optimizer URLs with
  400 when the app configures custom images.deviceSizes/imageSizes — while the
  Cloudflare worker entry and the Pages prod path already honored them. Read
  the __imageAllowedWidths constant inlined into the RSC entry (falling back to
  the defaults for older builds), matching how __assetPrefix/__basePath are read.
- Lock in the this-binding behavior of handleConfiguredImageOptimization with a
  class-instance optimizer test.

* fix(images): pass an explicit empty allowed-widths config through on vinext start

Review follow-up (ask-bonk, awareness note): the old-build fallback guard
conflated a missing __imageAllowedWidths export with an explicit empty
deviceSizes/imageSizes config, mapping the latter to the Next.js defaults on
the Node App Router path while the Cloudflare worker passes the empty array
straight through. Only fall back to the defaults when the export is absent.

* refactor(images): read App Router image config from the RSC entry, retire the JSON sidecar

Review follow-up (ask-bonk): the App Router had two parallel build-time sources
for next.config images security/header settings — the __imageConfig constant
inlined into the RSC entry (read by the Cloudflare worker entry) and the
image-config.json sidecar written by the vinext:image-config plugin (read by
vinext start). Unify on the RSC entry export: prod-server now reads
rscModule.__imageConfig, keeping image-config.json only as a read-side fallback
for dist outputs built by older vinext versions, and the sidecar writer plugin
is removed.

* fix(deploy): keep wrangler main on a user-authored worker entry for App Router

Review follow-up (ask-bonk): an App Router app with a custom worker/index.ts
but no wrangler.jsonc would have had its custom worker silently dropped —
generateWranglerConfig unconditionally pointed main at the default
vinext/server/app-router-entry. Respect hasWorkerEntry so a user-authored
worker keeps winning for both routers, with a regression test.

* fix(images): expose Cloudflare optimizer under images path

* fix(deploy): install Cloudflare image adapter package

* fix(examples): declare Cloudflare image adapter package

* test(images): update App Router image config codegen assertions

* fix(examples): configure image optimizer adapters
2026-06-23 20:20:36 +01:00

448 lines
18 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createServer } from "vite";
import { describe, expect, it } from "vitest";
import { generateRscEntry } from "../packages/vinext/src/entries/app-rsc-entry.js";
import { generateSsrEntry } from "../packages/vinext/src/entries/app-ssr-entry.js";
import vinext from "../packages/vinext/src/index.js";
describe("App Router next.config.js features (generateRscEntry)", () => {
// Use a minimal route list for testing — we only care about the generated config handling code
const minimalRoutes = [
{
pattern: "/",
pagePath: "/tmp/test/app/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null],
notFoundPath: null,
forbiddenPaths: [],
forbiddenPath: null,
unauthorizedPaths: [],
unauthorizedPath: null,
routeSegments: [],
layoutTreePositions: [0],
isDynamic: false,
params: [],
},
{
pattern: "/about",
pagePath: "/tmp/test/app/about/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null],
notFoundPath: null,
forbiddenPaths: [],
forbiddenPath: null,
unauthorizedPaths: [],
unauthorizedPath: null,
routeSegments: [],
layoutTreePositions: [0],
isDynamic: false,
params: [],
},
{
pattern: "/blog/:slug",
pagePath: "/tmp/test/app/blog/[slug]/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null],
notFoundPath: null,
forbiddenPaths: [],
forbiddenPath: null,
unauthorizedPaths: [],
unauthorizedPath: null,
routeSegments: [],
layoutTreePositions: [0],
isDynamic: true,
params: ["slug"],
},
] as any[];
it("generates redirect handling code when redirects are provided", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
redirects: [
{ source: "/old-about", destination: "/about", permanent: true },
{ source: "/old-blog/:slug", destination: "/blog/:slug", permanent: false },
],
});
expect(code).toContain("createAppRscHandler");
expect(code).toContain("__configRedirects");
expect(code).toContain("configRedirects: __configRedirects");
expect(code).toContain("/old-about");
expect(code).toContain("/old-blog/:slug");
expect(code).toContain("permanent");
});
it("generates rewrite handling code when rewrites are provided", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [{ source: "/before-rewrite", destination: "/about" }],
afterFiles: [{ source: "/after-rewrite", destination: "/about" }],
fallback: [{ source: "/fallback-rewrite", destination: "/about" }],
},
});
expect(code).toContain("__configRewrites");
expect(code).toContain("configRewrites: __configRewrites");
expect(code).toContain("beforeFiles");
expect(code).toContain("afterFiles");
expect(code).toContain("fallback");
expect(code).toContain("/before-rewrite");
expect(code).toContain("/after-rewrite");
expect(code).toContain("/fallback-rewrite");
});
it("generates custom header handling code when headers are provided", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
headers: [{ source: "/api/(.*)", headers: [{ key: "X-Custom-Header", value: "vinext" }] }],
});
expect(code).toContain("__configHeaders");
expect(code).toContain("configHeaders: __configHeaders");
expect(code).toContain("X-Custom-Header");
expect(code).toContain("vinext");
});
it("embeds image validation config in the pure App Router RSC handler", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
imageConfig: {
deviceSizes: [320, 640],
imageSizes: [16],
qualities: [60, 75],
},
});
expect(code).toContain("const __runtimeImageConfig");
expect(code).toContain("export const __imageConfig");
expect(code).toContain('"deviceSizes":[320,640]');
expect(code).toContain('"qualities":[60,75]');
expect(code).toContain("imageConfig: __runtimeImageConfig");
expect(code).toContain('isDev: process.env.NODE_ENV !== "production"');
});
it("routes hybrid Pages API misses through the Pages server entry", () => {
// Ported from Next.js: test/e2e/og-api/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/og-api/index.test.ts
//
// In a mixed app/ + pages/ project, a Pages Router API route such as
// pages/api/og.js remains a real route even though the production server
// enters through the App Router handler.
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
hasPagesDir: true,
});
expect(code).toContain("renderPagesFallback as __renderPagesFallback");
expect(code).toContain("server/app-pages-bridge.js");
expect(code).toContain("return __renderPagesFallback(");
expect(code).toContain("pagesDataRequest");
expect(code).toContain('return import.meta.viteRsc.loadModule("ssr", "index");');
expect(code).toContain("buildId: process.env.__VINEXT_BUILD_ID ?? null");
expect(code).toContain("buildRequestHeaders: __buildRequestHeadersFromMiddlewareResponse");
expect(code).toContain(
"applyRouteHandlerMiddlewareContext: __applyRouteHandlerMiddlewareContext",
);
});
it("exports whether the App Router build includes Pages Router routes", () => {
const appOnlyCode = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false);
const hybridCode = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
hasPagesDir: true,
});
expect(appOnlyCode).toContain("export const __hasPagesDir = false;");
expect(hybridCode).toContain("export const __hasPagesDir = true;");
});
it("re-exports Pages API handling from the hybrid SSR entry", () => {
// Ported from Next.js: test/e2e/og-api/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/og-api/index.test.ts
//
// The production App Router entry loads the SSR environment's "index"
// module for Pages fallbacks. That bridge must expose the Pages API
// dispatcher as well as page rendering.
const code = generateSsrEntry(true);
expect(code).toContain("handleApiRoute, matchApiRoute, matchPageRoute, pageRoutes, renderPage");
});
it("embeds basePath and trailingSlash alongside config", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "/app", true, {
redirects: [{ source: "/old", destination: "/new", permanent: true }],
});
// User-provided basePath and trailingSlash must flow through into the
// generated entry alongside redirect config.
expect(code).toContain('"/app"');
expect(code).toContain("/old");
});
it("includes config pattern matching function for regex patterns", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
redirects: [{ source: "/docs/:path*", destination: "/wiki/:path*", permanent: false }],
});
expect(code).toContain("configRedirects: __configRedirects");
expect(code).toContain(":path*");
});
it("delegates request lifecycle to the typed App RSC handler", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
redirects: [{ source: "/old", destination: "/new", permanent: true }],
});
expect(code).toContain("export default createAppRscHandler({");
expect(code).toContain("configRedirects: __configRedirects");
expect(code).toContain("dispatchMatchedPage({");
expect(code).toContain(" clientReuseManifest,");
expect(code).toContain(" rootParams,\n request,");
expect(code).toContain(" clientReuseManifest,");
expect(code).toContain(" rootParams,\n probeLayoutAt");
expect(code).toContain("dispatchMatchedRouteHandler({");
expect(code).toContain("matchRoute,");
});
it("describes beforeFiles rewrites in the generated app shape", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [{ source: "/old", destination: "/new" }],
afterFiles: [],
fallback: [],
},
});
expect(code).toContain("configRewrites: __configRewrites");
expect(code).toContain('"beforeFiles":[{"source":"/old","destination":"/new"}]');
});
it("passes the typed handler the generated route matcher and config", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [{ source: "/old", destination: "/new" }],
afterFiles: [],
fallback: [],
},
});
expect(code).toContain("const __routeMatcher = __createAppRscRouteMatcher(routes);");
expect(code).toContain("matchRoute,");
expect(code).toContain("configRewrites: __configRewrites");
});
it("describes afterFiles rewrites in the generated app shape", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [],
afterFiles: [{ source: "/old", destination: "/new" }],
fallback: [],
},
});
expect(code).toContain('"afterFiles":[{"source":"/old","destination":"/new"}]');
expect(code).toContain("configRewrites: __configRewrites");
});
it("applies fallback rewrites when no route matches", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [{ source: "/fallback", destination: "/about" }],
},
});
expect(code).toContain("configRewrites: __configRewrites");
expect(code).toContain('"fallback":[{"source":"/fallback","destination":"/about"}]');
});
it("describes external beforeFiles rewrites in the generated config", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [{ source: "/ph/:path*", destination: "https://us.i.posthog.com/:path*" }],
afterFiles: [],
fallback: [],
},
});
expect(code).toContain("configRewrites: __configRewrites");
expect(code).toContain("https://us.i.posthog.com/:path*");
});
it("describes external afterFiles rewrites in the generated config", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [],
afterFiles: [{ source: "/api/:path*", destination: "https://api.example.com/:path*" }],
fallback: [],
},
});
expect(code).toContain("configRewrites: __configRewrites");
expect(code).toContain("https://api.example.com/:path*");
});
it("describes external fallback rewrites in the generated config", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [
{ source: "/fallback/:path*", destination: "https://fallback.example.com/:path*" },
],
},
});
expect(code).toContain("configRewrites: __configRewrites");
expect(code).toContain("https://fallback.example.com/:path*");
});
it("passes basePath and redirect config to the generated handler", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "/app", false, {
redirects: [{ source: "/old", destination: "/new", permanent: true }],
});
expect(code).toContain('const __basePath = "/app"');
expect(code).toContain("basePath: __basePath");
expect(code).toContain("configRedirects: __configRedirects");
});
it("passes server action handlers and afterFiles rewrites to the typed handler", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [],
afterFiles: [{ source: "/x", destination: "/y" }],
fallback: [],
},
});
expect(code).toContain("handleServerActionRequest({");
expect(code).toContain("loadServerAction");
expect(code).toContain('"afterFiles":[{"source":"/x","destination":"/y"}]');
expect(code).toContain("configRewrites: __configRewrites");
});
it("embeds allowedOrigins when provided", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
allowedOrigins: ["my-proxy.com", "*.my-domain.com"],
});
expect(code).toContain("__allowedOrigins");
expect(code).toContain("my-proxy.com");
expect(code).toContain("*.my-domain.com");
});
it("keeps allowedDevOrigins separate from allowedOrigins", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
allowedOrigins: ["actions.example.com"],
allowedDevOrigins: ["allowed.example.com"],
});
expect(code).toContain("actions.example.com");
expect(code).toContain("allowed.example.com");
});
it("origin validation does not use x-forwarded-host", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false);
const actionStart = code.indexOf("handleServerActionRequest({");
const actionEnd = code.indexOf("i18nConfig: __i18nConfig", actionStart);
const actionOptions = code.slice(actionStart, actionEnd);
// CSRF behavior belongs to the shared action helper. The generated entry
// should only pass the original Request and configured origins through.
expect(actionOptions).toContain("request,");
expect(actionOptions).toContain("allowedOrigins: __allowedOrigins");
expect(actionOptions).not.toContain("x-forwarded-host");
expect(code).not.toContain("validateCsrfOrigin(request, __allowedOrigins)");
expect(code).not.toContain("function __validateCsrfOrigin");
});
// ── Dev origin check code generation ────────────────────────────────
it("generates dev origin validation code in RSC entry", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false);
// Should include the dev origin validation function definition
expect(code).toContain("__validateDevRequestOrigin");
expect(code).toContain("__safeDevHosts");
expect(code).toContain("validateDevRequestOrigin: __validateDevRequestOrigin");
});
it("embeds allowedDevOrigins in dev origin check code", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
allowedDevOrigins: ["staging.example.com", "*.preview.dev"],
});
expect(code).toContain("staging.example.com");
expect(code).toContain("*.preview.dev");
expect(code).toContain("__allowedDevOrigins");
});
it("loads allowedDevOrigins from next.config into the virtual RSC entry", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-app-rsc-allowed-dev-origins-"));
try {
fs.mkdirSync(path.join(tmpDir, "app"), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, "app", "layout.tsx"),
"export default function Layout({ children }) { return <html><body>{children}</body></html>; }",
);
fs.writeFileSync(
path.join(tmpDir, "app", "page.tsx"),
"export default function Page() { return <div>allowed-dev-origins</div>; }",
);
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`export default {
allowedDevOrigins: ["allowed.example.com"],
experimental: {
serverActions: {
allowedOrigins: ["actions.example.com"],
},
},
};`,
);
fs.symlinkSync(
path.resolve(__dirname, "..", "node_modules"),
path.join(tmpDir, "node_modules"),
"junction",
);
const testServer = await createServer({
root: tmpDir,
configFile: false,
plugins: [vinext({ appDir: tmpDir })],
server: { port: 0 },
logLevel: "silent",
});
try {
const resolved = await testServer.pluginContainer.resolveId("virtual:vinext-rsc-entry");
expect(resolved).toBeTruthy();
const loaded = await testServer.pluginContainer.load(resolved!.id);
const code = typeof loaded === "string" ? loaded : ((loaded as any)?.code ?? "");
expect(code).toContain('const __allowedDevOrigins = ["allowed.example.com"]');
expect(code).toContain('const __allowedOrigins = ["actions.example.com"]');
} finally {
await testServer.close();
}
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
describe("RSC error runtime delegation", () => {
it("imports RSC error helpers from a normal server module", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false);
expect(code).toContain("sanitizeErrorForClient as __sanitizeErrorForClient");
expect(code).toContain("server/app-rsc-errors.js");
expect(code).toContain("createAppRscOnErrorHandler");
expect(code).toContain("server/app-rsc-error-handler.js");
});
it("keeps request-specific onError wiring in the generated entry", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false);
expect(code).toContain("createRscOnErrorHandler(pathname, routePath)");
expect(code).toContain(
"createAppRscOnErrorHandler(_reportRequestError, request, pathname, routePath)",
);
expect(code).not.toContain("function createRscOnErrorHandler(request, pathname, routePath)");
expect(code).not.toContain("return __createRscOnErrorHandler({");
});
});
});