-
Notifications
You must be signed in to change notification settings - Fork 292
feat: lightweight worker for static exports #733
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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/ */ | ||||||||||||||
|
|
@@ -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"; | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -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 | ||||||||||||||
| .replace(/\/\*[\s\S]*?\*\//g, ""); // block comments | ||||||||||||||
| // Match output: "export" or output: 'export' (enforcing matching quotes). | ||||||||||||||
| return /output\s*:\s*(?:"export"|'export')/.test(stripped); | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 (
Suggested change
|
||||||||||||||
| } 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 | ||||||||||||||
|
|
@@ -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", | ||||||||||||||
| }; | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When
Suggested change
You could conditionally set |
||||||||||||||
| } 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"; | ||||||||||||||
|
|
@@ -1112,7 +1156,7 @@ export function getFilesToGenerate(info: ProjectInfo): GeneratedFile[] { | |||||||||||||
| }); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (!info.hasWorkerEntry) { | ||||||||||||||
| if (!info.hasWorkerEntry && !info.isStaticExport) { | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||||||||||
| const workerContent = info.isAppRouter | ||||||||||||||
| ? generateAppRouterWorkerEntry(info.hasISR) | ||||||||||||||
| : generatePagesRouterWorkerEntry(); | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1808,7 +1808,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |
| !hasCloudflarePlugin && | ||
| !hasNitroPlugin && | ||
| hasWranglerConfig(root) && | ||
| !options.disableAppRouter | ||
| !options.disableAppRouter && | ||
| nextConfig.output !== "export" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This correctly uses the already-loaded |
||
| ) { | ||
| throw new Error( | ||
| formatMissingCloudflarePluginError({ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ import { | |
| viteConfigHasCloudflarePlugin, | ||
| hasWranglerConfig, | ||
| formatMissingCloudflarePluginError, | ||
| detectStaticExport, | ||
| } from "../packages/vinext/src/deploy.js"; | ||
| import { | ||
| detectPackageManager, | ||
|
|
@@ -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); | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice edge case — testing the |
||
|
|
||
| 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good: this test verifies the config priority order ( |
||
| 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 ───────────────────────────────────────────────── | ||
|
|
@@ -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 ───────────────────────────────────────────────── | ||
|
|
@@ -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 ─────────────────────────────────────────── | ||
|
|
||
There was a problem hiding this comment.
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.: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.