diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index fd97e783..047cbd01 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -29,6 +29,7 @@ import { import { getReactUpgradeDeps } from "./init.js"; import { runTPR } from "./cloudflare/tpr.js"; import { loadDotenv } from "./config/dotenv.js"; +import { loadNextConfig } from "./config/next-config.js"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -110,6 +111,8 @@ interface ProjectInfo { hasCodeHike: boolean; /** Native Node modules that need stubbing for Workers */ nativeModulesToStub: string[]; + /** Build output mode: 'export' for static export, 'standalone' for single server */ + output: "" | "export" | "standalone"; } // ─── Detection ─────────────────────────────────────────────────────────────── @@ -215,9 +218,25 @@ export function detectProject(root: string): ProjectInfo { hasMDX, hasCodeHike, nativeModulesToStub, + output: "", }; } +/** + * Read the `output` field from next.config without resolving async + * redirects/rewrites/headers (which resolveNextConfig does unnecessarily). + */ +async function getOutputMode(root: string): Promise<"" | "export" | "standalone"> { + try { + const config = await loadNextConfig(root); + const output = config?.output ?? ""; + if (output === "export" || output === "standalone") return output; + return ""; + } catch { + return ""; + } +} + function detectISR(root: string, isAppRouter: boolean): boolean { if (!isAppRouter) return false; try { @@ -331,6 +350,12 @@ function detectNativeModules(root: string): string[] { } } +/** + * Directory name for static export output. Used by both `generateWranglerConfig` + * (assets.directory) and the static export build step so they stay in sync. + */ +const STATIC_EXPORT_DIR = "export"; + // ─── Project Preparation (pre-build transforms) ───────────────────────────── // // These are delegated to shared utilities in ./utils/project.ts so they can @@ -347,25 +372,32 @@ export const renameCJSConfigs = _renameCJSConfigs; /** Generate wrangler.jsonc content */ export function generateWranglerConfig(info: ProjectInfo): string { const today = new Date().toISOString().split("T")[0]; + const isStaticExport = info.output === "export"; const config: Record = { $schema: "node_modules/wrangler/config-schema.json", name: info.projectName, compatibility_date: today, compatibility_flags: ["nodejs_compat"], - main: "./worker/index.ts", + // Static exports are pure asset deployments — no Worker script needed. + ...(isStaticExport ? {} : { main: "./worker/index.ts" }), assets: { not_found_handling: "none", + // For static export, wrangler serves files directly from the build output directory. + ...(isStaticExport && { directory: STATIC_EXPORT_DIR }), // Expose static assets to the Worker via env.ASSETS so the image // optimization handler can fetch source images programmatically. - binding: "ASSETS", + ...(!isStaticExport && { 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: { - binding: "IMAGES", - }, + // Skip for static export since images are pre-optimized at build time. + ...(!isStaticExport && { + images: { + binding: "IMAGES", + }, + }), }; if (info.hasISR) { @@ -958,7 +990,8 @@ export function getFilesToGenerate(info: ProjectInfo): GeneratedFile[] { }); } - if (!info.hasWorkerEntry) { + // Static exports are pure asset deployments — no Worker script needed. + if (!info.hasWorkerEntry && info.output !== "export") { const workerContent = info.isAppRouter ? generateAppRouterWorkerEntry() : generatePagesRouterWorkerEntry(); @@ -1080,6 +1113,11 @@ export async function deploy(options: DeployOptions): Promise { // Step 1: Detect project structure const info = detectProject(root); + // Detect output mode from next.config (async — reads config file). + // Must run before any code that reads info.output (e.g. getFilesToGenerate, + // generateWranglerConfig). detectProject is sync so this is set separately. + info.output = await getOutputMode(root); + if (!info.isAppRouter && !info.isPagesRouter) { console.error(" Error: No app/ or pages/ directory found."); console.error(" vinext deploy requires a Next.js project with an app/ or pages/ directory"); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index a9f939ca..5c429dec 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -338,6 +338,32 @@ describe("generateWranglerConfig", () => { expect(parsed.images).toBeDefined(); expect(parsed.images.binding).toBe("IMAGES"); }); + + it("adds directory to assets and omits main/images for static export", () => { + mkdir(tmpDir, "app"); + const info = detectProject(tmpDir); + info.output = "export"; + const config = generateWranglerConfig(info); + const parsed = JSON.parse(config); + + expect(parsed.assets.directory).toBe("export"); + expect(parsed.assets.binding).toBeUndefined(); + expect(parsed.main).toBeUndefined(); + expect(parsed.images).toBeUndefined(); + }); + + it("does not add directory to assets for non-export output", () => { + mkdir(tmpDir, "app"); + const info = detectProject(tmpDir); + info.output = ""; + const config = generateWranglerConfig(info); + const parsed = JSON.parse(config); + + expect(parsed.assets.directory).toBeUndefined(); + expect(parsed.assets.binding).toBe("ASSETS"); + expect(parsed.main).toBe("./worker/index.ts"); + expect(parsed.images).toBeDefined(); + }); }); // ─── Worker Entry Generation ───────────────────────────────────────────────── @@ -854,6 +880,17 @@ describe("getFilesToGenerate", () => { expect(workerFile!.content).not.toContain("virtual:vinext-server-entry"); }); + it("skips worker/index.ts for static export", () => { + mkdir(tmpDir, "app"); + const info = detectProject(tmpDir); + info.output = "export"; + const files = getFilesToGenerate(info); + + const descriptions = files.map((f) => f.description); + expect(descriptions).not.toContain("worker/index.ts"); + expect(descriptions).toContain("wrangler.jsonc"); + }); + it("generates Pages Router worker entry for Pages Router project", () => { mkdir(tmpDir, "pages"); const info = detectProject(tmpDir);