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
50 changes: 44 additions & 6 deletions packages/vinext/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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<string, unknown> = {
$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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1080,6 +1113,11 @@ export async function deploy(options: DeployOptions): Promise<void> {
// 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");
Expand Down
37 changes: 37 additions & 0 deletions tests/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────
Expand Down Expand Up @@ -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);
Expand Down