diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index f94444361..57d9a2e97 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -88,6 +88,9 @@ import { relativeWithinRoot, type BundleBackfillChunk, } from "./build/ssr-manifest.js"; +import { stripServerExports } from "./plugins/strip-server-exports.js"; +import { hasMdxFiles } from "./utils/mdx-scan.js"; +import { scanPublicFileRoutes } from "./utils/public-routes.js"; import tsconfigPaths from "vite-tsconfig-paths"; import type { Options as VitePluginReactOptions } from "@vitejs/plugin-react"; import MagicString from "magic-string"; @@ -3382,96 +3385,6 @@ function getNextPublicEnvDefines(): Record { // which uses a single-pass tokenizer (fixing the chained .replace() // divergence that CodeQL flagged as incomplete sanitization). -/** - * Strip server-only data-fetching exports (getServerSideProps, - * getStaticProps, getStaticPaths) from page modules for the client - * bundle. Uses Vite's parseAst (Rollup/acorn) for correct handling - * of all export patterns including function expressions, arrow - * functions with TS return types, and re-exports. - * - * Modeled after Next.js's SWC `next-ssg-transform`. - */ -function stripServerExports(code: string): string | null { - const SERVER_EXPORTS = new Set(["getServerSideProps", "getStaticProps", "getStaticPaths"]); - if (![...SERVER_EXPORTS].some((name) => code.includes(name))) return null; - - let ast: ReturnType; - try { - ast = parseAst(code); - } catch { - // If parsing fails (shouldn't happen post-JSX/TS transform), bail out - return null; - } - - const s = new MagicString(code); - let changed = false; - - for (const node of ast.body) { - if (node.type !== "ExportNamedDeclaration") continue; - - // Case 1: export function name() {} / export async function name() {} - // Case 2: export const/let/var name = ... - if (node.declaration) { - const decl = node.declaration; - if (decl.type === "FunctionDeclaration" && decl.id && SERVER_EXPORTS.has(decl.id.name)) { - s.overwrite( - node.start, - node.end, - `export function ${decl.id.name}() { return { props: {} }; }`, - ); - changed = true; - } else if (decl.type === "VariableDeclaration") { - for (const declarator of decl.declarations) { - if (declarator.id?.type === "Identifier" && SERVER_EXPORTS.has(declarator.id.name)) { - s.overwrite(node.start, node.end, `export const ${declarator.id.name} = undefined;`); - changed = true; - } - } - } - continue; - } - - // Case 3: export { getServerSideProps } or export { getServerSideProps as gSSP } - if (node.specifiers && node.specifiers.length > 0 && !node.source) { - const kept: Extract[] = []; - const stripped: string[] = []; - for (const spec of node.specifiers) { - // spec.local.name is the binding name, spec.exported.name is the export name - // oxlint-disable-next-line typescript/no-explicit-any - const exportedName = (spec.exported as any)?.name ?? (spec.exported as any)?.value; - if (SERVER_EXPORTS.has(exportedName)) { - stripped.push(exportedName); - } else { - kept.push(spec); - } - } - if (stripped.length > 0) { - // Build replacement: keep non-server specifiers, add stubs for stripped ones - const parts: string[] = []; - if (kept.length > 0) { - const keptStr = kept - // oxlint-disable-next-line typescript/no-explicit-any - .map((sp: any) => { - const local = sp.local.name; - const exported = sp.exported?.name ?? sp.exported?.value; - return local === exported ? local : `${local} as ${exported}`; - }) - .join(", "); - parts.push(`export { ${keptStr} };`); - } - for (const name of stripped) { - parts.push(`export const ${name} = undefined;`); - } - s.overwrite(node.start, node.end, parts.join("\n")); - changed = true; - } - } - } - - if (!changed) return null; - return s.toString(); -} - /** * Apply redirect rules from next.config.js. * Returns true if a redirect was applied. @@ -3641,97 +3554,6 @@ function findFileWithExts( return null; } -/** Module-level cache for hasMdxFiles — avoids re-scanning per Vite environment. */ -const _mdxScanCache = new Map(); -/** - * Check if the project has .mdx files in app/ or pages/ directories. - */ -function hasMdxFiles(root: string, appDir: string | null, pagesDir: string | null): boolean { - const cacheKey = `${root}\0${appDir ?? ""}\0${pagesDir ?? ""}`; - if (_mdxScanCache.has(cacheKey)) return _mdxScanCache.get(cacheKey)!; - const dirs = [appDir, pagesDir].filter(Boolean) as string[]; - for (const dir of dirs) { - if (fs.existsSync(dir) && scanDirForMdx(dir)) { - _mdxScanCache.set(cacheKey, true); - return true; - } - } - _mdxScanCache.set(cacheKey, false); - return false; -} - -function scanDirForMdx(dir: string): boolean { - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.name.startsWith(".") || entry.name === "node_modules") continue; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (scanDirForMdx(full)) return true; - } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".mdx")) { - return true; - } - } - } catch { - // ignore unreadable dirs - } - return false; -} - -function scanPublicFileRoutes(root: string): string[] { - const publicDir = path.join(root, "public"); - const routes: string[] = []; - const visitedDirs = new Set(); - - function walk(dir: string): void { - let realDir: string; - try { - realDir = fs.realpathSync(dir); - } catch { - return; - } - if (visitedDirs.has(realDir)) return; - visitedDirs.add(realDir); - - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(fullPath); - continue; - } - if (entry.isSymbolicLink()) { - let stat: fs.Stats; - try { - stat = fs.statSync(fullPath); - } catch { - continue; - } - if (stat.isDirectory()) { - walk(fullPath); - continue; - } - if (!stat.isFile()) continue; - } else if (!entry.isFile()) { - continue; - } - const relativePath = path.relative(publicDir, fullPath).split(path.sep).join("/"); - routes.push("/" + relativePath); - } - } - - if (fs.existsSync(publicDir)) { - try { - walk(publicDir); - } catch { - // ignore unreadable dirs - } - } - - routes.sort(); - return routes; -} - // Public exports for static export export { staticExportPages, staticExportApp } from "./build/static-export.js"; export type { @@ -3743,9 +3565,3 @@ export type { // Export NextConfig type so next.config.ts files can import it from "vinext" // instead of "next". export type { NextConfig } from "./config/next-config.js"; - -// Exported for CLI and testing -export { hasMdxFiles as _hasMdxFiles }; -export { _mdxScanCache }; -export { scanPublicFileRoutes as _scanPublicFileRoutes }; -export { stripServerExports as _stripServerExports }; diff --git a/packages/vinext/src/plugins/strip-server-exports.ts b/packages/vinext/src/plugins/strip-server-exports.ts new file mode 100644 index 000000000..fbcad33bf --- /dev/null +++ b/packages/vinext/src/plugins/strip-server-exports.ts @@ -0,0 +1,94 @@ +import { parseAst } from "vite"; +import MagicString from "magic-string"; + +type ASTNode = ReturnType["body"][number]["parent"]; + +/** + * Strip server-only data-fetching exports (getServerSideProps, + * getStaticProps, getStaticPaths) from page modules for the client + * bundle. Uses Vite's parseAst (Rollup/acorn) for correct handling + * of all export patterns including function expressions, arrow + * functions with TS return types, and re-exports. + * + * Modeled after Next.js's SWC `next-ssg-transform`. + */ +export function stripServerExports(code: string): string | null { + const SERVER_EXPORTS = new Set(["getServerSideProps", "getStaticProps", "getStaticPaths"]); + if (![...SERVER_EXPORTS].some((name) => code.includes(name))) return null; + + let ast: ReturnType; + try { + ast = parseAst(code); + } catch { + // If parsing fails (shouldn't happen post-JSX/TS transform), bail out + return null; + } + + const s = new MagicString(code); + let changed = false; + + for (const node of ast.body) { + if (node.type !== "ExportNamedDeclaration") continue; + + // Case 1: export function name() {} / export async function name() {} + // Case 2: export const/let/var name = ... + if (node.declaration) { + const decl = node.declaration; + if (decl.type === "FunctionDeclaration" && decl.id && SERVER_EXPORTS.has(decl.id.name)) { + s.overwrite( + node.start, + node.end, + `export function ${decl.id.name}() { return { props: {} }; }`, + ); + changed = true; + } else if (decl.type === "VariableDeclaration") { + for (const declarator of decl.declarations) { + if (declarator.id?.type === "Identifier" && SERVER_EXPORTS.has(declarator.id.name)) { + s.overwrite(node.start, node.end, `export const ${declarator.id.name} = undefined;`); + changed = true; + } + } + } + continue; + } + + // Case 3: export { getServerSideProps } or export { getServerSideProps as gSSP } + if (node.specifiers && node.specifiers.length > 0 && !node.source) { + const kept: Extract[] = []; + const stripped: string[] = []; + for (const spec of node.specifiers) { + // spec.local.name is the binding name, spec.exported.name is the export name + // oxlint-disable-next-line typescript/no-explicit-any + const exportedName = (spec.exported as any)?.name ?? (spec.exported as any)?.value; + if (SERVER_EXPORTS.has(exportedName)) { + stripped.push(exportedName); + } else { + kept.push(spec); + } + } + if (stripped.length > 0) { + // Build replacement: keep non-server specifiers, add stubs for stripped ones + const parts: string[] = []; + if (kept.length > 0) { + const keptStr = kept + // oxlint-disable-next-line typescript/no-explicit-any + .map((sp: any) => { + const local = sp.local.name; + const exported = sp.exported?.name ?? sp.exported?.value; + return local === exported ? local : `${local} as ${exported}`; + }) + .join(", "); + parts.push(`export { ${keptStr} };`); + } + for (const name of stripped) { + parts.push(`export const ${name} = undefined;`); + } + s.overwrite(node.start, node.end, parts.join("\n")); + changed = true; + } + } + } + + if (!changed) return null; + return s.toString(); +} diff --git a/packages/vinext/src/utils/mdx-scan.ts b/packages/vinext/src/utils/mdx-scan.ts new file mode 100644 index 000000000..d783a6e30 --- /dev/null +++ b/packages/vinext/src/utils/mdx-scan.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** Module-level cache for hasMdxFiles — avoids re-scanning per Vite environment. */ +export const mdxScanCache = new Map(); + +/** + * Check if the project has .mdx files in app/ or pages/ directories. + */ +export function hasMdxFiles(root: string, appDir: string | null, pagesDir: string | null): boolean { + const cacheKey = `${root}\0${appDir ?? ""}\0${pagesDir ?? ""}`; + if (mdxScanCache.has(cacheKey)) return mdxScanCache.get(cacheKey)!; + const dirs = [appDir, pagesDir].filter(Boolean) as string[]; + for (const dir of dirs) { + if (fs.existsSync(dir) && scanDirForMdx(dir)) { + mdxScanCache.set(cacheKey, true); + return true; + } + } + mdxScanCache.set(cacheKey, false); + return false; +} + +function scanDirForMdx(dir: string): boolean { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".") || entry.name === "node_modules") continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (scanDirForMdx(full)) return true; + } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".mdx")) { + return true; + } + } + } catch { + // ignore unreadable dirs + } + return false; +} diff --git a/packages/vinext/src/utils/public-routes.ts b/packages/vinext/src/utils/public-routes.ts new file mode 100644 index 000000000..876871634 --- /dev/null +++ b/packages/vinext/src/utils/public-routes.ts @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function scanPublicFileRoutes(root: string): string[] { + const publicDir = path.join(root, "public"); + const routes: string[] = []; + const visitedDirs = new Set(); + + function walk(dir: string): void { + let realDir: string; + try { + realDir = fs.realpathSync(dir); + } catch { + return; + } + if (visitedDirs.has(realDir)) return; + visitedDirs.add(realDir); + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + continue; + } + if (entry.isSymbolicLink()) { + let stat: fs.Stats; + try { + stat = fs.statSync(fullPath); + } catch { + continue; + } + if (stat.isDirectory()) { + walk(fullPath); + continue; + } + if (!stat.isFile()) continue; + } else if (!entry.isFile()) { + continue; + } + const relativePath = path.relative(publicDir, fullPath).split(path.sep).join("/"); + routes.push("/" + relativePath); + } + } + + if (fs.existsSync(publicDir)) { + try { + walk(publicDir); + } catch { + // ignore unreadable dirs + } + } + + routes.sort(); + return routes; +} diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 20b974eaf..88a4b3619 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -9,8 +9,8 @@ import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vite-plus/test"; -import { _stripServerExports } from "../packages/vinext/src/index.js"; import { augmentSsrManifestFromBundle as _augmentSsrManifestFromBundle } from "../packages/vinext/src/build/ssr-manifest.js"; +import { stripServerExports as _stripServerExports } from "../packages/vinext/src/plugins/strip-server-exports.js"; import { createClientManualChunks, clientTreeshakeConfig, diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 791d41a46..09f98634d 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -27,7 +27,7 @@ import { ensureViteConfigCompatibility, } from "../packages/vinext/src/utils/project.js"; import { manifestFileWithBase } from "../packages/vinext/src/utils/manifest-paths.js"; -import { _scanPublicFileRoutes as scanPublicFileRoutes } from "../packages/vinext/src/index.js"; +import { scanPublicFileRoutes } from "../packages/vinext/src/utils/public-routes.js"; import { computeLazyChunks } from "../packages/vinext/src/utils/lazy-chunks.js"; import { mergeHeaders, diff --git a/tests/startup-cache.test.ts b/tests/startup-cache.test.ts index 1ce393983..a2bee7442 100644 --- a/tests/startup-cache.test.ts +++ b/tests/startup-cache.test.ts @@ -14,13 +14,13 @@ import os from "node:os"; // --------------------------------------------------------------------------- describe("hasMdxFiles caching", () => { - let hasMdxFiles: (typeof import("../packages/vinext/src/index.js"))["_hasMdxFiles"]; - let mdxScanCache: (typeof import("../packages/vinext/src/index.js"))["_mdxScanCache"]; + let hasMdxFiles: (typeof import("../packages/vinext/src/utils/mdx-scan.js"))["hasMdxFiles"]; + let mdxScanCache: (typeof import("../packages/vinext/src/utils/mdx-scan.js"))["mdxScanCache"]; beforeAll(async () => { - const mod = await import("../packages/vinext/src/index.js"); - hasMdxFiles = mod._hasMdxFiles; - mdxScanCache = mod._mdxScanCache; + const mod = await import("../packages/vinext/src/utils/mdx-scan.js"); + hasMdxFiles = mod.hasMdxFiles; + mdxScanCache = mod.mdxScanCache; }); beforeEach(() => {