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
3 changes: 2 additions & 1 deletion packages/vinext/src/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const CONFIG_SUPPORT: Record<string, { status: Status; detail?: string }> = {
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" },
Expand Down Expand Up @@ -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",
];

Expand Down
10 changes: 10 additions & 0 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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[];
}
Expand Down Expand Up @@ -259,6 +263,7 @@ export async function resolveNextConfig(
images: undefined,
i18n: null,
mdx: null,
allowedDevOrigins: [],
serverActionsAllowedOrigins: [],
};
}
Expand Down Expand Up @@ -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<string, unknown> | undefined;
const serverActionsConfig = experimental?.serverActions as Record<string, unknown> | undefined;
Expand Down Expand Up @@ -342,6 +351,7 @@ export async function resolveNextConfig(
images: config.images,
i18n,
mdx,
allowedDevOrigins,
serverActionsAllowedOrigins,
};
}
Expand Down
6 changes: 3 additions & 3 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2342,7 +2342,7 @@ hydrate();
rewrites: nextConfig?.rewrites,
headers: nextConfig?.headers,
allowedOrigins: nextConfig?.serverActionsAllowedOrigins,
allowedDevOrigins: nextConfig?.serverActionsAllowedOrigins,
allowedDevOrigins: nextConfig?.allowedDevOrigins,
}, instrumentationPath);
}
if (id === RESOLVED_APP_SSR_ENTRY && hasAppDir) {
Expand Down Expand Up @@ -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})`);
Expand Down Expand Up @@ -2689,7 +2689,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})`);
Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/server/app-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

Expand Down
66 changes: 65 additions & 1 deletion tests/app-router.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 = []");
Expand Down Expand Up @@ -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 <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("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);
Expand Down
12 changes: 12 additions & 0 deletions tests/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 54 additions & 0 deletions tests/pages-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div>allowed-dev-origins-pages</div>; }`,
);
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
Expand Down
42 changes: 42 additions & 0 deletions tests/shims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5068,6 +5068,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"
Expand Down
Loading