diff --git a/packages/vinext/src/config/server-action-warmup.ts b/packages/vinext/src/config/server-action-warmup.ts new file mode 100644 index 000000000..b01c51ee0 --- /dev/null +++ b/packages/vinext/src/config/server-action-warmup.ts @@ -0,0 +1,168 @@ +import { glob, readFile } from "node:fs/promises"; +import path from "node:path"; +import { normalizePageExtensions } from "../routing/file-matcher.js"; + +const SERVER_ACTION_SOURCE_EXTENSIONS = ["js", "jsx", "ts", "tsx", "mjs", "mts", "cjs", "cts"]; +const SERVER_ACTION_SCAN_EXCLUDED_ROOTS = new Set([ + ".git", + ".next", + ".output", + ".refs", + ".turbo", + ".vinext", + ".worktrees", + "build", + "coverage", + "dist", + "node_modules", + "out", +]); + +type CollectServerActionWarmupEntriesOptions = { + root: string; + pageExtensions?: readonly string[] | null; +}; + +function buildExtensionGlob(extensions: readonly string[]): string { + return extensions.length === 1 ? extensions[0] : `{${extensions.join(",")}}`; +} + +function toViteEntry(root: string, filePath: string): string { + return path.relative(root, filePath).split(path.sep).join("/"); +} + +function normalizeServerActionExtensions(pageExtensions?: readonly string[] | null): string[] { + return [ + ...new Set([...SERVER_ACTION_SOURCE_EXTENSIONS, ...normalizePageExtensions(pageExtensions)]), + ]; +} + +function shouldExcludeServerActionScanPath(name: string): boolean { + const segments = name.split(/[\\/]+/).filter(Boolean); + return ( + SERVER_ACTION_SCAN_EXCLUDED_ROOTS.has(segments[0] ?? "") || segments.includes("node_modules") + ); +} + +function skipWhitespaceAndComments(source: string, start: number): number { + let index = start; + while (index < source.length) { + const char = source[index]; + const next = source[index + 1]; + + if ( + char === " " || + char === "\t" || + char === "\n" || + char === "\r" || + char === "\f" || + char === "\v" + ) { + index++; + continue; + } + + if (char === "/" && next === "/") { + index += 2; + while (index < source.length && source[index] !== "\n" && source[index] !== "\r") { + index++; + } + continue; + } + + if (char === "/" && next === "*") { + index += 2; + while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) { + index++; + } + index = Math.min(index + 2, source.length); + continue; + } + + return index; + } + + return index; +} + +function readDirectiveLiteral( + source: string, + start: number, +): { value: string; end: number } | null { + const quote = source[start]; + if (quote !== '"' && quote !== "'") { + return null; + } + + let value = ""; + let index = start + 1; + while (index < source.length) { + const char = source[index]; + if (char === quote) { + return { value, end: index + 1 }; + } + if (char === "\\") { + const escaped = source[index + 1]; + if (escaped === undefined) { + return null; + } + // This scanner only needs directive equality, not full JavaScript string semantics. + value += escaped; + index += 2; + continue; + } + value += char; + index++; + } + + return null; +} + +export function hasModuleUseServerDirective(source: string): boolean { + let index = source.charCodeAt(0) === 0xfeff ? 1 : 0; + + while (index < source.length) { + index = skipWhitespaceAndComments(source, index); + const directive = readDirectiveLiteral(source, index); + if (!directive) { + return false; + } + if (directive.value === "use server") { + return true; + } + index = skipWhitespaceAndComments(source, directive.end); + if (source[index] === ";") { + index++; + } + } + + return false; +} + +export async function collectServerActionWarmupEntries( + options: CollectServerActionWarmupEntriesOptions, +): Promise { + const extensions = normalizeServerActionExtensions(options.pageExtensions); + const pattern = `**/*.${buildExtensionGlob(extensions)}`; + const entries: string[] = []; + + for await (const relativeFile of glob(pattern, { + cwd: options.root, + exclude: shouldExcludeServerActionScanPath, + })) { + const filePath = path.join(options.root, relativeFile); + const source = await readFile(filePath, "utf8"); + if (hasModuleUseServerDirective(source)) { + entries.push(toViteEntry(options.root, filePath)); + } + } + + return entries.sort(); +} + +export function mergeServerActionWarmupEntries( + userWarmup: readonly string[] | undefined, + actionWarmup: readonly string[], +): string[] { + return [...new Set([...(userWarmup ?? []), ...actionWarmup])]; +} diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 260641312..13a635ce3 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -43,6 +43,10 @@ import { type NextRewrite, type NextHeader, } from "./config/next-config.js"; +import { + collectServerActionWarmupEntries, + mergeServerActionWarmupEntries, +} from "./config/server-action-warmup.js"; import { findMiddlewareFile, runMiddleware } from "./server/middleware.js"; import { logRequest, now } from "./server/request-log.js"; @@ -1298,6 +1302,17 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { instrumentationClientPath, ].flatMap((entry) => (entry ? [toRelativeFileEntry(root, entry)] : [])); const optimizeEntries = [...new Set([...appEntries, ...explicitInstrumentationEntries])]; + const actionWarmupEntries = + env?.command === "build" + ? [] + : await collectServerActionWarmupEntries({ + root, + pageExtensions: nextConfig?.pageExtensions, + }); + const rscDevWarmup = mergeServerActionWarmupEntries( + config.environments?.rsc?.dev?.warmup, + actionWarmupEntries, + ); viteConfig.environments = { rsc: { @@ -1328,6 +1343,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { exclude: [...new Set([...incomingExclude, "vinext", "@vercel/og"])], entries: optimizeEntries, }, + ...(rscDevWarmup.length > 0 + ? { + dev: { + warmup: rscDevWarmup, + }, + } + : {}), build: { outDir: options.rscOutDir ?? "dist/server", ...withBuildBundlerOptions(viteMajorVersion, { diff --git a/tests/server-action-warmup.test.ts b/tests/server-action-warmup.test.ts new file mode 100644 index 000000000..fda8f6e84 --- /dev/null +++ b/tests/server-action-warmup.test.ts @@ -0,0 +1,213 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createServer, type InlineConfig, type Plugin, type ViteDevServer } from "vite"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { + collectServerActionWarmupEntries, + hasModuleUseServerDirective, + mergeServerActionWarmupEntries, +} from "../packages/vinext/src/config/server-action-warmup.js"; +import vinext from "../packages/vinext/src/index.js"; + +let server: ViteDevServer | null = null; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +async function writeFile(root: string, relativePath: string, content: string): Promise { + const filePath = path.join(root, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); +} + +type ConfigHookPlugin = Plugin & { + config: ( + config: InlineConfig, + env: { command: "build" | "serve"; mode: string }, + ) => InlineConfig | null | void | Promise; +}; + +function findNamedConfigPlugin(plugins: ReturnType, name: string): ConfigHookPlugin { + const flatPlugins = plugins.flat().filter((plugin): plugin is Plugin => Boolean(plugin)); + const plugin = flatPlugins.find((candidate) => candidate.name === name); + if (!plugin || typeof plugin.config !== "function") { + throw new Error(`${name} plugin not found`); + } + return plugin as ConfigHookPlugin; +} + +afterEach(async () => { + await server?.close(); + server = null; +}); + +describe("server action dev warmup", () => { + it("detects only module-level use server directives", () => { + expect(hasModuleUseServerDirective('"use server";\nexport async function save() {}')).toBe( + true, + ); + expect( + hasModuleUseServerDirective( + '/* header */\n"use strict";\n"use server";\nexport async function save() {}', + ), + ).toBe(true); + expect(hasModuleUseServerDirective(';\n"use server";\nexport async function save() {}')).toBe( + false, + ); + expect( + hasModuleUseServerDirective( + 'export async function save() {\n "use server";\n return "inline";\n}', + ), + ).toBe(false); + expect(hasModuleUseServerDirective('import "server-only";\n"use server";')).toBe(false); + }); + + it("collects warmup entries for server action files under app", async () => { + const root = await makeTempDir("vinext-server-action-warmup-"); + try { + await writeFile( + root, + "app/page.tsx", + "export default function Page() { return ; }", + ); + await writeFile( + root, + "app/actions.ts", + '"use server";\nexport async function save() { return "saved"; }\n', + ); + await writeFile( + root, + "app/_actions/more.ts", + "'use server';\nexport async function more() { return \"more\"; }\n", + ); + await writeFile( + root, + "app/_actions/module.mts", + "'use server';\nexport async function moduleAction() { return \"module\"; }\n", + ); + await writeFile( + root, + "src/lib/actions.ts", + '"use server";\nexport async function fromLib() { return "lib"; }\n', + ); + await writeFile( + root, + "app/inline.tsx", + 'export async function inline() {\n "use server";\n return "inline";\n}\n', + ); + await writeFile(root, "app/other.mdx", '"use server";\n\n# MDX action module\n'); + await writeFile( + root, + "node_modules/pkg/actions.ts", + '"use server";\nexport async function dependencyAction() { return "dependency"; }\n', + ); + await writeFile( + root, + "dist/actions.ts", + '"use server";\nexport async function builtAction() { return "built"; }\n', + ); + + await expect( + collectServerActionWarmupEntries({ + root, + pageExtensions: ["tsx", "jsx"], + }), + ).resolves.toEqual([ + "app/_actions/module.mts", + "app/_actions/more.ts", + "app/actions.ts", + "src/lib/actions.ts", + ]); + + await expect( + collectServerActionWarmupEntries({ + root, + pageExtensions: ["tsx", "jsx", "mdx"], + }), + ).resolves.toEqual([ + "app/_actions/module.mts", + "app/_actions/more.ts", + "app/actions.ts", + "app/other.mdx", + "src/lib/actions.ts", + ]); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("merges action warmup entries after valid user warmup entries", () => { + expect(mergeServerActionWarmupEntries(["./manual-action.ts"], ["app/actions.ts"])).toEqual([ + "./manual-action.ts", + "app/actions.ts", + ]); + expect(mergeServerActionWarmupEntries(["app/actions.ts"], ["app/actions.ts"])).toEqual([ + "app/actions.ts", + ]); + }); + + it("wires discovered action files into the RSC dev warmup config", async () => { + const root = await makeTempDir("vinext-server-action-warmup-config-"); + try { + await writeFile( + root, + "app/page.tsx", + "export default function Page() { return ; }", + ); + await writeFile( + root, + "app/actions.ts", + '"use server";\nexport async function save() { return "saved"; }\n', + ); + + server = await createServer({ + root, + configFile: false, + environments: { + rsc: { + dev: { + warmup: ["./manual-action.ts"], + }, + }, + }, + plugins: [vinext()], + server: { port: 0, cors: false }, + logLevel: "silent", + }); + + const warmup = server.config.environments.rsc?.dev?.warmup; + expect(warmup).toContain("./manual-action.ts"); + expect(warmup).toContain("app/actions.ts"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("skips discovered action warmup entries during production builds", async () => { + const root = await makeTempDir("vinext-server-action-warmup-build-"); + try { + await writeFile( + root, + "app/page.tsx", + "export default function Page() { return ; }", + ); + await writeFile( + root, + "app/actions.ts", + '"use server";\nexport async function save() { return "saved"; }\n', + ); + + const configPlugin = findNamedConfigPlugin(vinext(), "vinext:config"); + const result = await configPlugin.config( + { root, configFile: false, plugins: [] }, + { command: "build", mode: "production" }, + ); + + expect(result?.environments?.rsc?.dev?.warmup).toBeUndefined(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +});