Skip to content
Draft
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
168 changes: 168 additions & 0 deletions packages/vinext/src/config/server-action-warmup.ts
Original file line number Diff line number Diff line change
@@ -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")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The segment-based exclusion works, but note that segments[0] checks the first path component relative to cwd (which is root). This means directories like .git, dist, build, coverage, out are excluded only when they're direct children of root.

A nested build/ or dist/ directory (e.g., packages/foo/dist/actions.ts in a monorepo) would NOT be excluded. The node_modules check covers the most important case since it checks segments.includes("node_modules"), but the other excluded names only match at the root level.

This is probably fine for the initial implementation — most projects have these directories at root level. Just flagging the asymmetry between the node_modules check (any depth) and the other exclusions (root only).

);
}

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;
Comment on lines +104 to +110
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The escape handling here treats \ss, \nn (literal), etc. That's fine for the warmup scanner's purposes (nobody writes "use \x73erver" in practice), but it does mean the parsed value diverges from what a JS engine would produce for escape sequences like \n, \t, \u0073, or \x73.

Not a correctness issue for the "use server" check today — just noting it so we don't accidentally reuse this parser for general string literal extraction later.

index += 2;
continue;
}
value += char;
index++;
}

return null;
}

export function hasModuleUseServerDirective(source: string): boolean {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this function has a subtle correctness assumption worth documenting. The directive prologue loop correctly handles:

  • BOM prefix
  • Whitespace and comments between directives
  • Multiple directives before "use server" (e.g., "use strict"; "use server")
  • Semicolons between directives (optional)
  • Both single and double quote styles

It correctly rejects:

  • Inline "use server" inside function bodies (the export keyword hits the non-quote branch)
  • import statements before the directive (the i is not a quote character)
  • Bare semicolons before the directive (the ; is not a quote character, so it returns false — this matches the test at line 56)

The bare-semicolon case is interesting: ; "use server" is rejected because the semicolon at position 0 is not preceded by a directive literal. In the loop, after skipWhitespaceAndComments, we try readDirectiveLiteral which sees ; (not a quote) and returns null, terminating the function with false. This matches ECMAScript directive prologue semantics where a bare semicolon is an empty statement, not a directive — good.

No change needed, just confirming the behavior is correct.

let index = source.charCodeAt(0) === 0xfeff ? 1 : 0;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The BOM skip is good, but the parser doesn't handle hashbangs (#!/usr/bin/env node). If a file starts with #!, readDirectiveLiteral sees # (not a quote) and returns null, so the function returns false — the file would not be warmed up.

In practice nobody writes #!/usr/bin/env node followed by "use server", so this is purely theoretical. Just flagging it for completeness since the Next.js reference parser (getDirectiveFromByteCode) also doesn't handle hashbangs.


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<string[]> {
const extensions = normalizeServerActionExtensions(options.pageExtensions);
const pattern = `**/*.${buildExtensionGlob(extensions)}`;
const entries: string[] = [];

for await (const relativeFile of glob(pattern, {
cwd: options.root,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scan runs from options.root (the project root), not from appDir. This means it scans the entire project treesrc/, lib/, scripts/, tools/, etc. — reading every matching source file to check for the directive prologue.

The test at line 121 confirms this is intentional (it expects src/lib/actions.ts to be collected), and it makes sense given the issue's workaround example ('./lib/actions/**/*.ts'). But the PR summary says "discover module-level "use server" files under app/" which is misleading — it actually discovers them under the entire project root.

Two things to consider:

  1. Accuracy: Update the PR description to say "under the project root" instead of "under app/".

  2. Performance: For monorepo layouts where root contains many non-app directories (e.g., packages/, docs/, scripts/), this reads every source file in the entire tree. The excluded-roots set covers common build outputs, but not arbitrary sibling directories. In practice this is probably fine (the files are small, the directive check is a fast prefix scan, and it only runs once at dev startup), but it's worth being aware of. If it ever needs scoping, the appDir + any user-configured action directories would be a tighter default.

Not blocking — the broader scan is arguably more correct since server actions can live anywhere — but the description should match the implementation.

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])];
}
22 changes: 22 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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, {
Expand Down
Loading
Loading