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
3 changes: 2 additions & 1 deletion apps/web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
],
"types": ["@cloudflare/workers-types"],
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"@vinext/cloudflare/cache/*": ["../../packages/cloudflare/src/cache/*"]
}
},
"include": [
Expand Down
16 changes: 16 additions & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@
],
"type": "module",
"exports": {
"./cache/cdn-adapter": {
"types": "./dist/cache/cdn-adapter.d.ts",
"import": "./dist/cache/cdn-adapter.js"
},
"./cache/cdn-adapter.runtime": {
"types": "./dist/cache/cdn-adapter.runtime.d.ts",
"import": "./dist/cache/cdn-adapter.runtime.js"
},
"./cache/kv-data-adapter": {
"types": "./dist/cache/kv-data-adapter.d.ts",
"import": "./dist/cache/kv-data-adapter.js"
},
"./cache/kv-data-adapter.runtime": {
"types": "./dist/cache/kv-data-adapter.runtime.d.ts",
"import": "./dist/cache/kv-data-adapter.runtime.js"
},
"./cache/*": {
"types": "./dist/cache/*.d.ts",
"import": "./dist/cache/*.js"
Expand Down
53 changes: 53 additions & 0 deletions packages/vinext/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ import { runPrerender } from "./build/run-prerender.js";
import { loadDotenv } from "./config/dotenv.js";
import { loadNextConfig, resolveNextConfig } from "./config/next-config.js";
import { parsePositiveIntegerArg } from "./cli-args.js";
import {
readPrerenderManifest,
buildPregeneratedConcretePathTable,
} from "./server/prerender-manifest.js";
import { escapeRegExp } from "./utils/regex.js";

// ─── Types ───────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -1473,6 +1478,50 @@ function runWranglerDeploy(root: string, options: Pick<DeployOptions, "preview"
return deployedUrl ?? "(URL not detected in wrangler output)";
}

// ─── Pregenerated Concrete Paths Injection ────────────────────────────────────

const VINEXT_PREGEN_START = "/* __VINEXT_PREGENERATED_CONCRETE_PATHS_START__ */";
const VINEXT_PREGEN_END = "/* __VINEXT_PREGENERATED_CONCRETE_PATHS_END__ */";
const VINEXT_PREGEN_RE = new RegExp(
`${escapeRegExp(VINEXT_PREGEN_START)}[\\s\\S]*?${escapeRegExp(VINEXT_PREGEN_END)}\\n?`,
"g",
);

/**
* Read the prerender manifest and inject pregenerated concrete paths into the
* App Router Worker bundle so the PPR fallback-shell guard is populated at
* module init time without calling `seedMemoryCacheFromPrerender`.
*
* The paths are injected as `globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS`
* wrapped in replaceable marker comments, and consumed by
* `initPregeneratedPathsFromGlobals` in the generated RSC entry.
*
* Idempotent: repeated calls strip the previous injection before writing the
* new one. If the manifest is missing, corrupt, or empty, any prior injection
* is stripped and nothing new is written — failing closed to empty.
*/
export function injectPregeneratedConcretePaths(root: string): void {
const workerEntry = path.resolve(root, "dist", "server", "index.js");
if (!fs.existsSync(workerEntry)) return;

let code = fs.readFileSync(workerEntry, "utf-8");
code = code.replace(VINEXT_PREGEN_RE, "");

const manifestPath = path.join(root, "dist", "server", "vinext-prerender.json");
const manifest = readPrerenderManifest(manifestPath);
const table = buildPregeneratedConcretePathTable(manifest ?? {});

if (table.length > 0) {
const injection =
`${VINEXT_PREGEN_START}\n` +
`globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = ${JSON.stringify(table)};\n` +
`${VINEXT_PREGEN_END}\n`;
code = injection + code;
}

fs.writeFileSync(workerEntry, code);
}

// ─── Main Entry ──────────────────────────────────────────────────────────────

export async function deploy(options: DeployOptions): Promise<void> {
Expand Down Expand Up @@ -1600,6 +1649,10 @@ export async function deploy(options: DeployOptions): Promise<void> {
}
await runPrerender({ root: info.root, concurrency: options.prerenderConcurrency });
}

// Inject pregenerated concrete paths into the Worker bundle so the PPR
// fallback-shell guard is populated without calling seedMemoryCacheFromPrerender.
injectPregeneratedConcretePaths(root);
}

// Step 6b: TPR — pre-render hot pages into KV cache (experimental, opt-in)
Expand Down
22 changes: 22 additions & 0 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ const appPrerenderStaticParamsPath = resolveEntryPath(
import.meta.url,
);
const seedCachePath = resolveEntryPath("../server/seed-cache.js", import.meta.url);
const pregeneratedConcretePathsPath = resolveEntryPath(
"../server/pregenerated-concrete-paths.js",
import.meta.url,
);
const appHookWarningSuppressionPath = resolveEntryPath(
"../server/app-hook-warning-suppression.js",
import.meta.url,
Expand Down Expand Up @@ -206,6 +210,7 @@ export function generateRscEntry(
const reactMaxHeadersLength = config?.reactMaxHeadersLength ?? DEFAULT_REACT_MAX_HEADERS_LENGTH;
const cacheMaxMemorySize = config?.cacheMaxMemorySize;
const inlineCss = config?.inlineCss === true;
const cacheComponents = config?.cacheComponents === true;
const i18nConfig = config?.i18n ?? null;
const hasPagesDir = config?.hasPagesDir ?? false;
const publicFiles = config?.publicFiles ?? [];
Expand Down Expand Up @@ -349,9 +354,15 @@ __configureMemoryCacheHandler({ cacheMaxMemorySize: ${JSON.stringify(cacheMaxMem
import { createAppPrerenderStaticParamsResolver as __createAppPrerenderStaticParamsResolver } from ${JSON.stringify(appPrerenderStaticParamsPath)};
import { ensureAppRouteModulesLoaded as __ensureRouteLoaded } from ${JSON.stringify(appRouteModuleLoaderPath)};
import { seedMemoryCacheFromPrerender as __seedMemoryCacheFromPrerender } from ${JSON.stringify(seedCachePath)};
import {
getRenderedConcreteUrlPathsForRoute as __getRenderedConcreteUrlPathsForRoute,
initPregeneratedPathsFromGlobals as __initPregeneratedPathsFromGlobals,
} from ${JSON.stringify(pregeneratedConcretePathsPath)};

const __draftModeSecret = ${JSON.stringify(draftModeSecret)};

__initPregeneratedPathsFromGlobals();

// Note: cache entries are written with \`headers: undefined\`. Next.js stores
// response headers (e.g. set-cookie from cookies().set() during render) in the
// cache entry so they can be replayed on HIT. We don't do this because:
Expand Down Expand Up @@ -541,6 +552,8 @@ const __reactMaxHeadersLength = ${JSON.stringify(reactMaxHeadersLength)};
// \`vinextConfig\` export). Empty string when unset.
export const __assetPrefix = ${JSON.stringify(assetPrefix)};
export const __inlineCss = ${JSON.stringify(inlineCss)};
export const getRenderedConcreteUrlPathsForRoute = __getRenderedConcreteUrlPathsForRoute;
const __cacheComponents = ${JSON.stringify(cacheComponents)};

export function seedMemoryCacheFromPrerender(serverDir) {
return __seedMemoryCacheFromPrerender(serverDir, {
Expand Down Expand Up @@ -592,6 +605,7 @@ export default __createAppRscHandler({
},
registerCacheAdapters: __registerConfiguredCacheAdapters,
configHeaders: __configHeaders,
cacheComponents: __cacheComponents,
configRedirects: __configRedirects,
configRewrites: __configRewrites,
draftModeSecret: __draftModeSecret,
Expand All @@ -609,6 +623,10 @@ export default __createAppRscHandler({
middlewareContext,
mountedSlotsHeader,
params,
pprFallbackCacheShells,
pprFallbackShell,
renderedConcreteUrlPaths,
skipStaticParamsValidation,
staticParamsValidationParams,
rootParams,
request,
Expand Down Expand Up @@ -695,6 +713,10 @@ export default __createAppRscHandler({
middlewareContext,
mountedSlotsHeader,
params,
pprFallbackCacheShells,
pprFallbackShell,
renderedConcreteUrlPaths,
skipStaticParamsValidation,
staticParamsValidationParams,
rootParams,
probeLayoutAt(li, layoutParamAccess) {
Expand Down
62 changes: 18 additions & 44 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,6 @@ import { createImportMetaUrlPlugin } from "./plugins/import-meta-url.js";
import { createRequireContextPlugin } from "./plugins/require-context.js";
import { hasMdxFiles } from "./utils/mdx-scan.js";
import { scanPublicFileRoutes } from "./utils/public-routes.js";
import { getViteMajorVersion } from "./utils/vite-version.js";
import tsconfigPaths from "vite-tsconfig-paths";
import type { Options as VitePluginReactOptions } from "@vitejs/plugin-react";
import MagicString from "magic-string";
Expand All @@ -175,6 +174,7 @@ import fs from "node:fs";
import { randomBytes, randomUUID } from "node:crypto";
import commonjs from "vite-plugin-commonjs";
import { normalizePathSeparators } from "./utils/path.js";
import { getViteMajorVersion } from "./utils/vite-version.js";

// Install the process-level peer-disconnect backstop at module load.
// Vite plugin lifecycle hooks (config / configureServer) proved
Expand All @@ -196,29 +196,6 @@ function isInsideDirectory(dir: string, filePath: string): boolean {
return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
}

// Detect a module-level `"use server"` directive (a Server Actions module).
// Directives form the leading prologue of string-literal ExpressionStatements,
// so we only scan until the first non-directive statement.
function hasModuleLevelUseServerDirective(body: ReturnType<typeof parseAst>["body"]): boolean {
for (const node of body) {
if (node.type !== "ExpressionStatement") break;
const directive = (node as ASTNode & { directive?: unknown }).directive;
const expression = (node as ASTNode & { expression?: { type?: string; value?: unknown } })
.expression;
const value =
typeof directive === "string"
? directive
: expression?.type === "Literal"
? expression.value
: undefined;
if (value === "use server") return true;
// Keep scanning through other directives (e.g. "use strict"); a
// non-string-literal statement ends the directive prologue.
if (typeof value !== "string") break;
}
return false;
}

function hasServerOnlyMarkerImport(code: string): boolean {
if (!code.includes("server-only")) return false;

Expand All @@ -229,15 +206,6 @@ function hasServerOnlyMarkerImport(code: string): boolean {
return false;
}

// Server Actions modules (`"use server"`) live in the server/action layer,
// not the client layer. Next.js allows them to `import "server-only"` even
// though the client bundle holds references used to invoke the actions
// (see test/e2e/app-dir/actions/app/client/actions.js, which does exactly
// this). Treating them as client-reachable here is a false positive that
// breaks the entire client build, so exempt them while still rejecting
// genuine client modules below.
if (hasModuleLevelUseServerDirective(ast.body)) return false;

function walk(node: ASTNode | ASTNode[] | null | undefined): boolean {
if (!node) return false;
if (Array.isArray(node)) return node.some((child) => walk(child));
Expand Down Expand Up @@ -544,58 +512,62 @@ function isValidExportIdentifier(name: string): boolean {
}

/**
* Returns true when `code` starts with a React `"use client"` or `"use server"`
* directive (after stripping leading comments, hashbang, and whitespace).
* Returns the leading React `"use client"` or `"use server"` directive after
* stripping leading comments, hashbang, and whitespace.
*
* Used by `vinext:jsx-in-js` to opt `.js` files inside `node_modules` into the
* JSX transform. We mirror `@vitejs/plugin-rsc`'s detection by looking at the
* directive prologue rather than scanning the whole file — `code.includes`
* alone would match incidental occurrences in template literals or comments.
*/
function hasReactDirective(code: string): boolean {
function getLeadingReactDirective(code: string): "use client" | "use server" | null {
let i = 0;
const len = code.length;
// Strip BOM.
if (code.charCodeAt(0) === 0xfeff) i = 1;
// Strip hashbang.
if (code[i] === "#" && code[i + 1] === "!") {
const nl = code.indexOf("\n", i);
if (nl === -1) return false;
if (nl === -1) return null;
i = nl + 1;
}
while (i < len) {
// Skip whitespace.
while (i < len && /\s/.test(code[i] ?? "")) i++;
if (i >= len) return false;
if (i >= len) return null;
// Skip line comments.
if (code[i] === "/" && code[i + 1] === "/") {
const nl = code.indexOf("\n", i + 2);
if (nl === -1) return false;
if (nl === -1) return null;
i = nl + 1;
continue;
}
// Skip block comments.
if (code[i] === "/" && code[i + 1] === "*") {
const end = code.indexOf("*/", i + 2);
if (end === -1) return false;
if (end === -1) return null;
i = end + 2;
continue;
}
// At first non-comment, non-whitespace token. Must be a string literal
// directive to qualify (per ECMA-262 Directive Prologue grammar).
const quote = code[i];
if (quote !== '"' && quote !== "'") return false;
if (quote !== '"' && quote !== "'") return null;
const closing = code.indexOf(quote, i + 1);
if (closing === -1) return false;
if (closing === -1) return null;
const directive = code.slice(i + 1, closing);
if (directive === "use client" || directive === "use server") return true;
if (directive === "use client" || directive === "use server") return directive;
// Other directives (e.g., "use strict") may precede the React directive.
// Continue scanning past the statement-terminating `;` or newline.
i = closing + 1;
while (i < len && (code[i] === ";" || code[i] === " " || code[i] === "\t")) i++;
if (code[i] === "\n") i++;
}
return false;
return null;
}

function hasReactDirective(code: string): boolean {
return getLeadingReactDirective(code) !== null;
}

function generateRootParamsModule(rootParamNames: Iterable<string>): string {
Expand Down Expand Up @@ -2527,6 +2499,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
reactMaxHeadersLength: nextConfig?.reactMaxHeadersLength,
cacheMaxMemorySize: nextConfig?.cacheMaxMemorySize,
inlineCss: nextConfig?.inlineCss,
cacheComponents: nextConfig?.cacheComponents,
i18n: nextConfig?.i18n,
hasPagesDir,
publicFiles: scanPublicFileRoutes(root),
Expand Down Expand Up @@ -3816,6 +3789,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
handler(code, id) {
if (this.environment?.name !== "client") return null;
if (id.startsWith("\0")) return null;
if (getLeadingReactDirective(code) === "use server") return null;
if (!hasServerOnlyMarkerImport(code)) return null;

throw new Error(
Expand Down
Loading
Loading