From 632cdcf8f0a4b8b08ced618313bff2a0f593c9cb Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Sat, 7 Mar 2026 19:11:36 -0600 Subject: [PATCH] Fix and fully implement allowedDevOrigins --- packages/vinext/src/check.ts | 3 +- packages/vinext/src/config/next-config.ts | 10 +++ packages/vinext/src/index.ts | 6 +- packages/vinext/src/server/app-dev-server.ts | 2 +- tests/app-router.test.ts | 66 +++++++++++++++++++- tests/check.test.ts | 12 ++++ tests/pages-router.test.ts | 54 ++++++++++++++++ tests/shims.test.ts | 42 +++++++++++++ 8 files changed, 189 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index 1bb18d0a..8663e06a 100644 --- a/packages/vinext/src/check.ts +++ b/packages/vinext/src/check.ts @@ -72,6 +72,7 @@ const CONFIG_SUPPORT: Record = { i18n: { status: "supported", detail: "path-prefix routing (domains not yet supported)" }, env: { status: "supported" }, images: { status: "partial", detail: "remotePatterns validated, no local optimization" }, + allowedDevOrigins: { status: "supported", detail: "dev server cross-origin allowlist" }, output: { status: "supported", detail: "'export' and 'standalone' modes" }, transpilePackages: { status: "supported", detail: "Vite handles this natively" }, webpack: { status: "unsupported", detail: "Vite replaces webpack — custom webpack configs need migration" }, @@ -215,7 +216,7 @@ export function analyzeConfig(root: string): CheckItem[] { // Check for known config options by searching for property names in the config file const configOptions = [ "basePath", "trailingSlash", "redirects", "rewrites", "headers", - "i18n", "env", "images", "output", "transpilePackages", "webpack", + "i18n", "env", "images", "allowedDevOrigins", "output", "transpilePackages", "webpack", "reactStrictMode", "poweredByHeader", ]; diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 88fb6f2e..9bc217ff 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -115,6 +115,8 @@ export interface NextConfig { output?: "export" | "standalone"; /** File extensions treated as routable pages/routes (Next.js pageExtensions) */ pageExtensions?: string[]; + /** Extra origins allowed to access the dev server. */ + allowedDevOrigins?: string[]; /** * Enable Cache Components (Next.js 16). * When true, enables the "use cache" directive for pages, components, and functions. @@ -150,6 +152,8 @@ export interface ResolvedNextConfig { i18n: NextI18nConfig | null; /** MDX remark/rehype/recma plugins extracted from @next/mdx config */ mdx: MdxOptions | null; + /** Extra allowed origins for dev server access (from allowedDevOrigins). */ + allowedDevOrigins: string[]; /** Extra allowed origins for server action CSRF validation (from experimental.serverActions.allowedOrigins). */ serverActionsAllowedOrigins: string[]; } @@ -259,6 +263,7 @@ export async function resolveNextConfig( images: undefined, i18n: null, mdx: null, + allowedDevOrigins: [], serverActionsAllowedOrigins: [], }; } @@ -294,6 +299,10 @@ export async function resolveNextConfig( // Extract MDX remark/rehype plugins from @next/mdx's webpack wrapper const mdx = extractMdxOptions(config); + const allowedDevOrigins = Array.isArray(config.allowedDevOrigins) + ? config.allowedDevOrigins + : []; + // Resolve serverActions.allowedOrigins from experimental config const experimental = config.experimental as Record | undefined; const serverActionsConfig = experimental?.serverActions as Record | undefined; @@ -342,6 +351,7 @@ export async function resolveNextConfig( images: config.images, i18n, mdx, + allowedDevOrigins, serverActionsAllowedOrigins, }; } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index dcaccea2..bd68740b 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2342,7 +2342,7 @@ hydrate(); rewrites: nextConfig?.rewrites, headers: nextConfig?.headers, allowedOrigins: nextConfig?.serverActionsAllowedOrigins, - allowedDevOrigins: nextConfig?.serverActionsAllowedOrigins, + allowedDevOrigins: nextConfig?.allowedDevOrigins, }); } if (id === RESOLVED_APP_SSR_ENTRY && hasAppDir) { @@ -2506,7 +2506,7 @@ hydrate(); "sec-fetch-site": req.headers["sec-fetch-site"] as string | undefined, "sec-fetch-mode": req.headers["sec-fetch-mode"] as string | undefined, }, - nextConfig?.serverActionsAllowedOrigins, + nextConfig?.allowedDevOrigins, ); if (blockReason) { console.warn(`[vinext] Blocked dev request: ${blockReason} (${req.url})`); @@ -2683,7 +2683,7 @@ hydrate(); "sec-fetch-site": req.headers["sec-fetch-site"] as string | undefined, "sec-fetch-mode": req.headers["sec-fetch-mode"] as string | undefined, }, - nextConfig?.serverActionsAllowedOrigins, + nextConfig?.allowedDevOrigins, ); if (blockReason) { console.warn(`[vinext] Blocked dev request: ${blockReason} (${url})`); diff --git a/packages/vinext/src/server/app-dev-server.ts b/packages/vinext/src/server/app-dev-server.ts index 3210b746..fba9dc78 100644 --- a/packages/vinext/src/server/app-dev-server.ts +++ b/packages/vinext/src/server/app-dev-server.ts @@ -29,7 +29,7 @@ export interface AppRouterConfig { headers?: NextHeader[]; /** Extra origins allowed for server action CSRF checks (from experimental.serverActions.allowedOrigins). */ allowedOrigins?: string[]; - /** Extra origins allowed for dev server access (from serverActionsAllowedOrigins or custom config). */ + /** Extra origins allowed for dev server access (from allowedDevOrigins). */ allowedDevOrigins?: string[]; } diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 3fca677e..a109a365 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; -import { createBuilder, type ViteDevServer } from "vite"; +import { createBuilder, createServer, type ViteDevServer } from "vite"; import path from "node:path"; import fs from "node:fs"; import os from "node:os"; @@ -2330,6 +2330,17 @@ describe("App Router next.config.js features (generateRscEntry)", () => { 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"); + expect(code).toContain("const __allowedOrigins = [\"actions.example.com\"]"); + expect(code).toContain("const __allowedDevOrigins = [\"allowed.example.com\"]"); + }); + it("embeds empty allowedOrigins when none provided", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false); expect(code).toContain("__allowedOrigins = []"); @@ -2372,6 +2383,59 @@ describe("App Router next.config.js features (generateRscEntry)", () => { 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 {children}; }", + ); + fs.writeFileSync( + path.join(tmpDir, "app", "page.tsx"), + "export default function Page() { return
allowed-dev-origins
; }", + ); + 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("rscOnError: non-plain object dev hint", () => { it("includes detection for the 'Only plain objects' RSC serialization error", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false); diff --git a/tests/check.test.ts b/tests/check.test.ts index 1ff056e4..974fe239 100644 --- a/tests/check.test.ts +++ b/tests/check.test.ts @@ -260,6 +260,18 @@ describe("analyzeConfig", () => { expect(items.find((i) => i.name === "experimental.serverActions")?.status).toBe("supported"); }); + it("detects allowedDevOrigins as supported", () => { + writeFile( + "next.config.mjs", + `export default { + allowedDevOrigins: ["staging.example.com"], + };`, + ); + + const items = analyzeConfig(tmpDir); + expect(items.find((i) => i.name === "allowedDevOrigins")?.status).toBe("supported"); + }); + it("detects i18n.domains as unsupported", () => { writeFile( "next.config.js", diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 8223b891..57cb2e71 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -663,6 +663,60 @@ describe("Pages Router dev server origin check", () => { }); }); +// Ported from Next.js: test/development/basic/allowed-dev-origins.test.ts +// https://github.com/vercel/next.js/blob/canary/test/development/basic/allowed-dev-origins.test.ts +describe("Pages Router allowedDevOrigins config", () => { + let server: ViteDevServer; + let baseUrl: string; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-allowed-dev-origins-")); + await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true }); + await fsp.symlink( + path.resolve(import.meta.dirname, "../node_modules"), + path.join(tmpDir, "node_modules"), + "junction", + ); + await fsp.writeFile( + path.join(tmpDir, "pages", "index.tsx"), + `export default function Home() { return
allowed-dev-origins-pages
; }`, + ); + await fsp.writeFile( + path.join(tmpDir, "next.config.mjs"), + `export default { + allowedDevOrigins: ["allowed.example.com"], + experimental: { + serverActions: { + allowedOrigins: ["actions.example.com"], + }, + }, +}; +`, + ); + ({ server, baseUrl } = await startFixtureServer(tmpDir)); + }, 30000); + + afterAll(async () => { + await server?.close(); + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + it("allows cross-origin requests from allowedDevOrigins", async () => { + const res = await fetch(`${baseUrl}/`, { + headers: { Origin: "http://allowed.example.com" }, + }); + expect(res.status).toBe(200); + }); + + it("does not treat serverActions.allowedOrigins as allowedDevOrigins", async () => { + const res = await fetch(`${baseUrl}/`, { + headers: { Origin: "http://actions.example.com" }, + }); + expect(res.status).toBe(403); + }); +}); + describe("Virtual server entry generation", () => { it("generates valid JavaScript for the server entry", async () => { // Create a minimal server just to access the plugin's virtual module diff --git a/tests/shims.test.ts b/tests/shims.test.ts index eb6368e5..9b84bcf4 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -5067,6 +5067,48 @@ describe("cacheComponents config (Next.js 16)", () => { expect(config.serverActionsAllowedOrigins).toEqual(["my-proxy.com", "*.my-domain.com"]); }); + it("resolveNextConfig resolves allowedDevOrigins from top-level config", async () => { + const { resolveNextConfig } = await import( + "../packages/vinext/src/config/next-config.js" + ); + const config = await resolveNextConfig({ + allowedDevOrigins: ["staging.example.com", "*.preview.dev"], + }); + expect(config.allowedDevOrigins).toEqual(["staging.example.com", "*.preview.dev"]); + }); + + it("resolveNextConfig keeps allowedDevOrigins separate from serverActionsAllowedOrigins", async () => { + const { resolveNextConfig } = await import( + "../packages/vinext/src/config/next-config.js" + ); + const config = await resolveNextConfig({ + allowedDevOrigins: ["dev.example.com"], + experimental: { + serverActions: { + allowedOrigins: ["actions.example.com"], + }, + }, + }); + expect(config.allowedDevOrigins).toEqual(["dev.example.com"]); + expect(config.serverActionsAllowedOrigins).toEqual(["actions.example.com"]); + }); + + it("resolveNextConfig defaults allowedDevOrigins to empty array", async () => { + const { resolveNextConfig } = await import( + "../packages/vinext/src/config/next-config.js" + ); + const config = await resolveNextConfig({}); + expect(config.allowedDevOrigins).toEqual([]); + }); + + it("resolveNextConfig handles null input with empty allowedDevOrigins", async () => { + const { resolveNextConfig } = await import( + "../packages/vinext/src/config/next-config.js" + ); + const config = await resolveNextConfig(null); + expect(config.allowedDevOrigins).toEqual([]); + }); + it("resolveNextConfig defaults serverActionsAllowedOrigins to empty array", async () => { const { resolveNextConfig } = await import( "../packages/vinext/src/config/next-config.js"