Skip to content
Open
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
72 changes: 58 additions & 14 deletions packages/vinext/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ type ProjectInfo = {
projectName: string;
/** Pages that use `revalidate` (ISR) */
hasISR: boolean;
/** next.config.js sets output: 'export' (full static export) */
isStaticExport: boolean;
/** package.json has "type": "module" */
hasTypeModule: boolean;
/** .mdx files detected in app/ or pages/ */
Expand Down Expand Up @@ -222,6 +224,9 @@ export function detectProject(root: string): ProjectInfo {
// Detect ISR usage (rough heuristic: search for `revalidate` exports)
const hasISR = detectISR(root, isAppRouter);

// Detect output: 'export' in next.config
const isStaticExport = detectStaticExport(root);

// Detect "type": "module" in package.json
const hasTypeModule = pkg?.type === "module";

Expand Down Expand Up @@ -250,13 +255,42 @@ export function detectProject(root: string): ProjectInfo {
hasWrangler,
projectName,
hasISR,
isStaticExport,
hasTypeModule,
hasMDX,
hasCodeHike,
nativeModulesToStub,
};
}

/**
* Heuristic: does the next.config file set `output: 'export'`?
*
* Limitations (consistent with detectISR): variable indirection like
* `const o = "export"; { output: o }` is not detected. Template-literal
* syntax (`output: \`export\``) is also not detected (uncommon in configs).
*/
export function detectStaticExport(root: string): boolean {
const configNames = ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.cjs"];
for (const name of configNames) {
const configPath = path.join(root, name);
if (fs.existsSync(configPath)) {
try {
const content = fs.readFileSync(configPath, "utf-8");
// Strip comments to avoid matching commented-out config.
const stripped = content
.replace(/\/\/.*$/gm, "") // single-line comments
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The // comment stripping regex will also match // inside string literals, e.g.:

const baseUrl = "https://example.com"; // this gets partially eaten

becomes const baseUrl = "https: after stripping. If the remaining content accidentally matches the output pattern, you'd get a false positive.

This is a pre-existing limitation (same class of issue as detectISR), and for next.config files it's extremely unlikely to matter in practice. Just documenting for awareness — no action needed unless you want to add a string-preserving pass.

.replace(/\/\*[\s\S]*?\*\//g, ""); // block comments
// Match output: "export" or output: 'export' (enforcing matching quotes).
return /output\s*:\s*(?:"export"|'export')/.test(stripped);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the comment says "enforcing matching quotes" but the regex uses two separate alternations ("export" and 'export'), each with their own matched delimiters. The phrasing is slightly misleading — it implies there's a backreference enforcing that the opening and closing quotes match, but in practice both alternatives are self-consistent so it works correctly. Consider:

Suggested change
return /output\s*:\s*(?:"export"|'export')/.test(stripped);
// Match output: "export" or output: 'export' (double or single quotes).

} catch {
return false;
}
}
}
return false;
}

function detectISR(root: string, isAppRouter: boolean): boolean {
// ISR detection is only implemented for App Router (scans for `export const revalidate`).
// Pages Router ISR (getStaticProps + revalidate) is not detected here — wrangler.jsonc
Expand Down Expand Up @@ -396,31 +430,41 @@ export function generateWranglerConfig(info: ProjectInfo): string {
name: info.projectName,
compatibility_date: today,
compatibility_flags: ["nodejs_compat"],
main: "./worker/index.ts",
assets: {
};

if (info.isStaticExport) {
// Static export: serve pre-rendered files directly — no worker needed.
// Cloudflare's built-in asset serving handles everything.
config.assets = {
directory: "dist/client",
not_found_handling: "404-page",
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When isStaticExport is true and there's no worker entry, nodejs_compat has no effect since there's no JavaScript execution at all — Cloudflare just serves static files. Consider omitting it for static exports to keep the generated config minimal and avoid confusing users who read it:

Suggested change
};
config.assets = {
directory: "dist/client",
not_found_handling: "404-page",
};
} else {

You could conditionally set compatibility_flags only in the non-static branch. Low priority — it's functionally harmless.

} else {
config.main = "./worker/index.ts";
config.assets = {
// Wrangler 4.69+ requires `directory` when `assets` is an object.
// The @cloudflare/vite-plugin always writes static assets to dist/client/.
directory: "dist/client",
not_found_handling: "none",
// Expose static assets to the Worker via env.ASSETS so the image
// optimization handler can fetch source images programmatically.
binding: "ASSETS",
},
};
// Cloudflare Images binding for next/image optimization.
// Enables resize, format negotiation (AVIF/WebP), and quality transforms
// at the edge. No user setup needed — wrangler creates the binding automatically.
images: {
config.images = {
binding: "IMAGES",
},
};
};

if (info.hasISR) {
config.kv_namespaces = [
{
binding: "VINEXT_CACHE",
id: "<your-kv-namespace-id>",
},
];
if (info.hasISR) {
config.kv_namespaces = [
{
binding: "VINEXT_CACHE",
id: "<your-kv-namespace-id>",
},
];
}
}

return JSON.stringify(config, null, 2) + "\n";
Expand Down Expand Up @@ -1112,7 +1156,7 @@ export function getFilesToGenerate(info: ProjectInfo): GeneratedFile[] {
});
}

if (!info.hasWorkerEntry) {
if (!info.hasWorkerEntry && !info.isStaticExport) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — correctly skips worker generation for static exports. But two related guards in the deploy() function are not gated on isStaticExport:

  1. Line 1358: viteConfigHasCloudflarePlugin(root) check will throw for static export projects that have a vite config without @cloudflare/vite-plugin. Should be:

    if (info.hasViteConfig && !info.isStaticExport && !viteConfigHasCloudflarePlugin(root)) {
  2. getMissingDeps(): Still installs @cloudflare/vite-plugin, wrangler, @vitejs/plugin-rsc, react-server-dom-webpack even for static exports where no worker runs. Consider gating the cloudflare/rsc deps behind !info.isStaticExport. (wrangler is still needed for wrangler deploy.)

const workerContent = info.isAppRouter
? generateAppRouterWorkerEntry(info.hasISR)
: generatePagesRouterWorkerEntry();
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 @@ -1808,7 +1808,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
!hasCloudflarePlugin &&
!hasNitroPlugin &&
hasWranglerConfig(root) &&
!options.disableAppRouter
!options.disableAppRouter &&
nextConfig.output !== "export"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This correctly uses the already-loaded nextConfig from the Vite plugin's config phase rather than re-detecting via file heuristic. Clean approach.

) {
throw new Error(
formatMissingCloudflarePluginError({
Expand Down
213 changes: 213 additions & 0 deletions tests/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
viteConfigHasCloudflarePlugin,
hasWranglerConfig,
formatMissingCloudflarePluginError,
detectStaticExport,
} from "../packages/vinext/src/deploy.js";
import {
detectPackageManager,
Expand Down Expand Up @@ -288,6 +289,127 @@ describe("detectProject", () => {
const info = detectProject(tmpDir);
expect(info.hasISR).toBe(false);
});

it("detects output: 'export' in next.config.mjs", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
const info = detectProject(tmpDir);
expect(info.isStaticExport).toBe(true);
});

it('detects output:"export" without spaces', () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.mjs", `export default {output:"export"};`);
const info = detectProject(tmpDir);
expect(info.isStaticExport).toBe(true);
});

it("isStaticExport is false when no export config", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.mjs", `export default {};`);
const info = detectProject(tmpDir);
expect(info.isStaticExport).toBe(false);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice edge case — testing the output:"export" form without spaces ensures the \s* in the regex is doing its job.


it("detects output: 'export' in next.config.ts", () => {
mkdir(tmpDir, "app");
writeFile(
tmpDir,
"next.config.ts",
`const config = { output: 'export' };\nexport default config;`,
);
const info = detectProject(tmpDir);
expect(info.isStaticExport).toBe(true);
});

it("detects output: 'export' in next.config.cjs", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.cjs", `module.exports = { output: "export" };`);
const info = detectProject(tmpDir);
expect(info.isStaticExport).toBe(true);
});

it("does not detect commented-out output: 'export'", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.mjs", `// output: "export"\nexport default {};`);
const info = detectProject(tmpDir);
expect(info.isStaticExport).toBe(false);
});

it("does not detect output: 'standalone' as static export", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.mjs", `export default { output: "standalone" };`);
const info = detectProject(tmpDir);
expect(info.isStaticExport).toBe(false);
});

it("isStaticExport is false when no next.config exists", () => {
mkdir(tmpDir, "app");
const info = detectProject(tmpDir);
expect(info.isStaticExport).toBe(false);
});

it("handles empty next.config.js gracefully", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.js", "");
const info = detectProject(tmpDir);
expect(info.isStaticExport).toBe(false);
});

it("prefers next.config.ts over next.config.mjs for static export detection", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.ts", `export default {};`);
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
const info = detectProject(tmpDir);
// .ts is checked first and has no output: "export", so result is false
expect(info.isStaticExport).toBe(false);
});

it("does not detect output: 'export' inside block comments", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.mjs", `/* output: "export" */\nexport default {};`);
const info = detectProject(tmpDir);
expect(info.isStaticExport).toBe(false);
});

it("does not detect output: 'export' inside multiline block comments", () => {
mkdir(tmpDir, "app");
writeFile(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: this test verifies the config priority order (.ts > .mjs), which matches CONFIG_FILES in next-config.ts. Worth noting in the test name or comment that this is testing first-match-wins semantics, not alphabetical priority.

tmpDir,
"next.config.mjs",
"/*\n * Previously: output: 'export'\n */\nexport default {};",
);
const info = detectProject(tmpDir);
expect(info.isStaticExport).toBe(false);
});
});

// ─── detectStaticExport (direct) ────────────────────────────────────────────

describe("detectStaticExport", () => {
it('returns true for output: "export"', () => {
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
expect(detectStaticExport(tmpDir)).toBe(true);
});

it("returns false when no next.config exists", () => {
expect(detectStaticExport(tmpDir)).toBe(false);
});

it('returns false for output: "standalone"', () => {
writeFile(tmpDir, "next.config.mjs", `export default { output: "standalone" };`);
expect(detectStaticExport(tmpDir)).toBe(false);
});

it("strips block comments before matching", () => {
writeFile(tmpDir, "next.config.mjs", `/* output: "export" */ export default {};`);
expect(detectStaticExport(tmpDir)).toBe(false);
});

it("strips single-line comments before matching", () => {
writeFile(tmpDir, "next.config.mjs", `// output: "export"\nexport default {};`);
expect(detectStaticExport(tmpDir)).toBe(false);
});
});

// ─── generateWranglerConfig ─────────────────────────────────────────────────
Expand Down Expand Up @@ -365,6 +487,67 @@ describe("generateWranglerConfig", () => {
expect(parsed.images).toBeDefined();
expect(parsed.images.binding).toBe("IMAGES");
});

it("generates static-only config when isStaticExport is true", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
const info = detectProject(tmpDir);
const config = generateWranglerConfig(info);
const parsed = JSON.parse(config);

// No worker entry — Cloudflare serves static files directly
expect(parsed.main).toBeUndefined();
// Uses 404-page handling instead of routing all requests to worker
expect(parsed.assets.not_found_handling).toBe("404-page");
// No ASSETS binding needed (no worker to bind to)
expect(parsed.assets.binding).toBeUndefined();
// Still has directory for asset serving
expect(parsed.assets.directory).toBe("dist/client");
// No image optimization binding
expect(parsed.images).toBeUndefined();
});

it("omits KV namespace for static exports even with ISR-like patterns", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
writeFile(
tmpDir,
"app/page.tsx",
"export const revalidate = 30;\nexport default function() { return <div/> }",
);
const info = detectProject(tmpDir);
const config = generateWranglerConfig(info);
const parsed = JSON.parse(config);

expect(parsed.kv_namespaces).toBeUndefined();
});

it("static export config still includes $schema, compatibility_date, and compatibility_flags", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
const info = detectProject(tmpDir);
const config = generateWranglerConfig(info);
const parsed = JSON.parse(config);

expect(parsed.$schema).toBe("node_modules/wrangler/config-schema.json");
const today = new Date().toISOString().split("T")[0];
expect(parsed.compatibility_date).toBe(today);
expect(parsed.compatibility_flags).toContain("nodejs_compat");
});

it("non-static config has main, ASSETS binding, and images (regression guard)", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.mjs", `export default {};`);
const info = detectProject(tmpDir);
const config = generateWranglerConfig(info);
const parsed = JSON.parse(config);

expect(parsed.main).toBe("./worker/index.ts");
expect(parsed.assets.binding).toBe("ASSETS");
expect(parsed.assets.not_found_handling).toBe("none");
expect(parsed.images).toBeDefined();
expect(parsed.images.binding).toBe("IMAGES");
});
});

// ─── Worker Entry Generation ─────────────────────────────────────────────────
Expand Down Expand Up @@ -1096,6 +1279,36 @@ describe("getFilesToGenerate", () => {
expect(viteFile).toBeDefined();
expect(viteFile!.content).not.toContain("plugin-rsc");
});

it("skips worker/index.ts for static exports", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
const info = detectProject(tmpDir);
const files = getFilesToGenerate(info);

const workerFile = files.find((f) => f.description === "worker/index.ts");
expect(workerFile).toBeUndefined();
});

it("still generates wrangler.jsonc for static exports", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
const info = detectProject(tmpDir);
const files = getFilesToGenerate(info);

const wranglerFile = files.find((f) => f.description === "wrangler.jsonc");
expect(wranglerFile).toBeDefined();
});

it("still generates vite.config.ts for static exports", () => {
mkdir(tmpDir, "app");
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
const info = detectProject(tmpDir);
const files = getFilesToGenerate(info);

const viteFile = files.find((f) => f.description === "vite.config.ts");
expect(viteFile).toBeDefined();
});
});

// ─── viteConfigHasCloudflarePlugin ───────────────────────────────────────────
Expand Down
Loading