From 57d6f40e56ffab1fdb3ec685d0fe20d2cfe49df1 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 4 Jul 2026 13:12:30 +0100 Subject: [PATCH 01/17] fix(app-router): align server action routing --- .../vinext/src/build/action-owner-manifest.ts | 410 ++++++++++++++++++ packages/vinext/src/entries/app-rsc-entry.ts | 30 +- packages/vinext/src/index.ts | 68 ++- .../src/server/app-action-forwarding.ts | 142 ++++++ packages/vinext/src/server/app-rsc-handler.ts | 8 + .../src/server/app-server-action-execution.ts | 65 +++ tests/action-owner-manifest.test.ts | 386 +++++++++++++++++ tests/action-runtime-security.test.ts | 359 +++++++++++++++ tests/app-action-forwarding.test.ts | 194 +++++++++ tests/app-server-action-execution.test.ts | 45 +- tests/entry-templates.test.ts | 13 +- 11 files changed, 1701 insertions(+), 19 deletions(-) create mode 100644 packages/vinext/src/build/action-owner-manifest.ts create mode 100644 packages/vinext/src/server/app-action-forwarding.ts create mode 100644 tests/action-owner-manifest.test.ts create mode 100644 tests/action-runtime-security.test.ts create mode 100644 tests/app-action-forwarding.test.ts diff --git a/packages/vinext/src/build/action-owner-manifest.ts b/packages/vinext/src/build/action-owner-manifest.ts new file mode 100644 index 0000000000..0efdd0469f --- /dev/null +++ b/packages/vinext/src/build/action-owner-manifest.ts @@ -0,0 +1,410 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { parseAstAsync, transformWithOxc, type ConfigEnv } from "vite"; +import type { AppRoute } from "../routing/app-route-graph.js"; + +type ActionOwnerRoute = Pick< + AppRoute, + | "errorPath" + | "errorPaths" + | "forbiddenPath" + | "forbiddenPaths" + | "layoutErrorPaths" + | "layouts" + | "loadingPath" + | "notFoundPath" + | "notFoundPaths" + | "pagePath" + | "parallelSlots" + | "pattern" + | "siblingIntercepts" + | "templates" + | "unauthorizedPath" + | "unauthorizedPaths" +>; + +type ServerReferenceMeta = { + exportNames: readonly string[]; + importId: string; + referenceKey: string; +}; + +type ModuleInfoProvider = { + getModuleInfo(id: string): { + dynamicImportedIds?: readonly string[]; + importedIds: readonly string[]; + } | null; +}; + +type AstNode = { + type: string; + [key: string]: unknown; +}; + +type SourceDependency = { + names: string[] | null; + specifier: string; +}; + +export function actionOwnerManifestMode( + env: Pick, +): "development" | "production" { + return env.command === "serve" && env.isPreview !== true ? "development" : "production"; +} + +export function actionOwnerRouteEntryIds(route: ActionOwnerRoute): string[] { + return [ + route.pagePath, + ...route.layouts, + ...route.templates, + route.loadingPath, + route.errorPath, + ...(route.layoutErrorPaths ?? []), + ...(route.errorPaths ?? []), + route.notFoundPath, + ...(route.notFoundPaths ?? []), + route.forbiddenPath, + ...(route.forbiddenPaths ?? []), + route.unauthorizedPath, + ...(route.unauthorizedPaths ?? []), + ...route.parallelSlots.flatMap((slot) => [ + slot.pagePath, + slot.defaultPath, + slot.layoutPath, + ...(slot.configLayoutPaths ?? []), + slot.loadingPath, + slot.errorPath, + ...slot.interceptingRoutes.flatMap((intercept) => [ + intercept.pagePath, + ...intercept.layoutPaths, + ]), + ]), + ...route.siblingIntercepts.flatMap((intercept) => [ + intercept.pagePath, + ...intercept.layoutPaths, + ]), + ].filter((value): value is string => typeof value === "string"); +} + +function referenceKey(root: string, id: string, mode: "development" | "production"): string { + const relative = path.relative(root, id).replaceAll(path.sep, "/"); + if (mode === "production") { + return createHash("sha256").update(relative).digest("hex").slice(0, 12); + } + return relative.startsWith("../") ? `/@fs/${id.replaceAll(path.sep, "/")}` : `/${relative}`; +} + +function identifierName(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const name = (value as { name?: unknown }).name; + return typeof name === "string" ? name : null; +} + +function literalString(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const literal = value as { type?: unknown; value?: unknown }; + return literal.type === "Literal" && typeof literal.value === "string" ? literal.value : null; +} + +function declaredNames(pattern: unknown, names: Set): void { + if (!pattern || typeof pattern !== "object") return; + const node = pattern as AstNode; + if (node.type === "Identifier") { + const name = identifierName(node); + if (name) names.add(name); + return; + } + if (node.type === "RestElement") { + declaredNames(node.argument, names); + return; + } + if (node.type === "AssignmentPattern") { + declaredNames(node.left, names); + return; + } + if (node.type === "ArrayPattern") { + for (const element of (node.elements as unknown[] | undefined) ?? []) { + declaredNames(element, names); + } + return; + } + if (node.type === "ObjectPattern") { + for (const property of (node.properties as AstNode[] | undefined) ?? []) { + declaredNames(property.type === "Property" ? property.value : property.argument, names); + } + } +} + +function exportedNames(program: AstNode): string[] { + const names = new Set(); + for (const statement of (program.body as AstNode[] | undefined) ?? []) { + if (statement.type === "ExportDefaultDeclaration") { + names.add("default"); + continue; + } + if (statement.type !== "ExportNamedDeclaration") continue; + const declaration = statement.declaration as AstNode | null | undefined; + if (declaration?.type === "VariableDeclaration") { + for (const declarator of (declaration.declarations as AstNode[] | undefined) ?? []) { + declaredNames(declarator.id, names); + } + } else if (declaration) { + const name = identifierName(declaration.id); + if (name) names.add(name); + } + for (const specifier of (statement.specifiers as AstNode[] | undefined) ?? []) { + const name = identifierName(specifier.exported); + if (name) names.add(name); + } + } + return [...names]; +} + +function hasModuleUseServerDirective(program: AstNode): boolean { + for (const statement of (program.body as AstNode[] | undefined) ?? []) { + if (statement.type !== "ExpressionStatement") return false; + const directive = statement.directive; + if (directive === "use server") return true; + if (typeof directive !== "string") return false; + } + return false; +} + +function importedNames(specifiers: readonly AstNode[]): string[] | null { + const names = new Set(); + for (const specifier of specifiers) { + if (specifier.type === "ImportNamespaceSpecifier") return null; + if (specifier.type === "ImportDefaultSpecifier") names.add("default"); + if (specifier.type === "ImportSpecifier") { + const name = identifierName(specifier.imported); + if (name) names.add(name); + } + } + return [...names]; +} + +function reexportedNames( + specifiers: readonly AstNode[], + requestedNames: string[] | null, +): string[] { + const requested = requestedNames ? new Set(requestedNames) : null; + const names = new Set(); + for (const specifier of specifiers) { + const exportedName = identifierName(specifier.exported); + const sourceName = identifierName(specifier.local); + if (sourceName && (!requested || (exportedName && requested.has(exportedName)))) { + names.add(sourceName); + } + } + return [...names]; +} + +function collectImportExpressions(node: unknown, dependencies: SourceDependency[]): void { + if (!node || typeof node !== "object") return; + if (Array.isArray(node)) { + for (const value of node) collectImportExpressions(value, dependencies); + return; + } + const astNode = node as AstNode; + if (astNode.type === "ImportExpression") { + const specifier = literalString(astNode.source); + if (specifier) dependencies.push({ names: null, specifier }); + return; + } + for (const [key, value] of Object.entries(astNode)) { + if (key === "start" || key === "end" || key === "loc") continue; + collectImportExpressions(value, dependencies); + } +} + +function sourceDependencies( + program: AstNode, + requestedNames: string[] | null, + includeImports: boolean, +): SourceDependency[] { + const dependencies: SourceDependency[] = []; + for (const statement of (program.body as AstNode[] | undefined) ?? []) { + if (includeImports && statement.type === "ImportDeclaration") { + const specifier = literalString(statement.source); + if (specifier) { + dependencies.push({ + names: importedNames((statement.specifiers as AstNode[] | undefined) ?? []), + specifier, + }); + } + continue; + } + if (statement.type === "ExportNamedDeclaration" && statement.source) { + const specifier = literalString(statement.source); + if (specifier) { + dependencies.push({ + names: reexportedNames( + (statement.specifiers as AstNode[] | undefined) ?? [], + requestedNames, + ), + specifier, + }); + } + continue; + } + if (statement.type === "ExportAllDeclaration") { + const specifier = literalString(statement.source); + if (specifier) { + const namespaceName = identifierName(statement.exported); + const names = namespaceName + ? requestedNames === null || requestedNames.includes(namespaceName) + ? null + : [] + : requestedNames; + dependencies.push({ names, specifier }); + } + continue; + } + if (includeImports) collectImportExpressions(statement, dependencies); + } + return dependencies; +} + +function addOwner(manifest: Record, key: string, pattern: string): void { + const owners = (manifest[key] ??= []); + if (!owners.includes(pattern)) owners.push(pattern); +} + +export function buildActionOwnerManifest(options: { + canonicalizeModuleId?: (id: string) => string; + moduleInfo: ModuleInfoProvider; + routes: readonly ActionOwnerRoute[]; + serverReferences: readonly ServerReferenceMeta[]; + staticOwners: Readonly>; +}): Record { + const manifest: Record = {}; + const canonicalizeModuleId = options.canonicalizeModuleId ?? ((id: string) => id); + + for (const route of options.routes) { + const entryIds = actionOwnerRouteEntryIds(route); + const routeComponentIds = new Set(entryIds.map(canonicalizeModuleId)); + const reachableIds = new Set(routeComponentIds); + const queue = [...routeComponentIds]; + for (let index = 0; index < queue.length; index++) { + const moduleInfo = options.moduleInfo.getModuleInfo(queue[index]!); + for (const importedId of [ + ...(moduleInfo?.importedIds ?? []), + ...(moduleInfo?.dynamicImportedIds ?? []), + ]) { + const canonicalImportedId = canonicalizeModuleId(importedId); + if (reachableIds.has(canonicalImportedId)) continue; + reachableIds.add(canonicalImportedId); + queue.push(canonicalImportedId); + } + } + + for (const reference of options.serverReferences) { + const referenceId = canonicalizeModuleId(reference.importId); + if (!reachableIds.has(referenceId)) continue; + if (routeComponentIds.has(referenceId)) { + addOwner(manifest, reference.referenceKey, route.pattern); + } + for (const exportName of reference.exportNames) { + const actionId = `${reference.referenceKey}#${exportName}`; + if ( + routeComponentIds.has(referenceId) || + options.staticOwners[actionId]?.includes(route.pattern) + ) { + addOwner(manifest, actionId, route.pattern); + } + } + } + } + + return manifest; +} + +export function injectActionOwnerManifest( + code: string, + manifest: Record, +): string | null { + const marker = + /function\s+([\w$]+)\(\)\s*\{\s*return\s*["'`]__VINEXT_ACTION_OWNERS_STUB__["'`];?\s*\}/; + const match = code.match(marker); + if (!match) return null; + return code.replace( + marker, + () => `function ${match[1]}() { return ${JSON.stringify(manifest)}; }`, + ); +} + +export async function buildStaticActionOwnerManifest(options: { + mode: "development" | "production"; + root: string; + routes: readonly ActionOwnerRoute[]; + resolve: (source: string, importer?: string) => Promise<{ id: string } | null>; +}): Promise> { + const manifest: Record = {}; + + for (const route of options.routes) { + const entryIds = actionOwnerRouteEntryIds(route); + const entrySet = new Set(entryIds); + const queue = entryIds.map((id) => ({ id, names: null as string[] | null })); + const visited = new Set(); + + for (let index = 0; index < queue.length; index++) { + const current = queue[index]!; + const visitKey = `${current.id}\0${current.names?.join(",") ?? "*"}`; + if (visited.has(visitKey)) continue; + visited.add(visitKey); + + let source: string; + try { + source = await fs.readFile(current.id, "utf8"); + } catch { + continue; + } + + const extension = path.extname(current.id).toLowerCase(); + try { + source = ( + await transformWithOxc(source, current.id, { + lang: extension === ".ts" ? "ts" : extension === ".tsx" ? "tsx" : "jsx", + jsx: { runtime: "automatic" }, + sourcemap: false, + }) + ).code; + } catch { + continue; + } + + let program: AstNode; + try { + program = (await parseAstAsync(source)) as unknown as AstNode; + } catch { + continue; + } + + const moduleReferenceKey = referenceKey(options.root, current.id, options.mode); + if (entrySet.has(current.id)) addOwner(manifest, moduleReferenceKey, route.pattern); + + if (hasModuleUseServerDirective(program)) { + for (const name of current.names ?? exportedNames(program)) { + addOwner(manifest, `${moduleReferenceKey}#${name}`, route.pattern); + } + continue; + } + + const includeImports = !current.id.includes(`${path.sep}node_modules${path.sep}`); + for (const dependency of sourceDependencies(program, current.names, includeImports)) { + if (dependency.names?.length === 0) continue; + let resolved: { id: string } | null; + try { + resolved = await options.resolve(dependency.specifier, current.id); + } catch { + continue; + } + if (!resolved || resolved.id.includes("?")) continue; + queue.push({ id: resolved.id, names: dependency.names }); + } + } + } + + return manifest; +} diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index bfcabea919..b342f9f2af 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -53,6 +53,10 @@ const appServerActionExecutionPath = resolveEntryPath( "../server/app-server-action-execution.js", import.meta.url, ); +const appActionForwardingPath = resolveEntryPath( + "../server/app-action-forwarding.js", + import.meta.url, +); const appRscErrorsPath = resolveEntryPath("../server/app-rsc-errors.js", import.meta.url); const appPageExecutionPath = resolveEntryPath("../server/app-page-execution.js", import.meta.url); const appFallbackRendererPath = resolveEntryPath( @@ -118,6 +122,7 @@ const appPagesBridgePath = resolveEntryPath("../server/app-pages-bridge.js", imp * Passed from the Vite plugin where the full next.config.js is loaded. */ type AppRouterConfig = { + actionOwners?: Record; redirects?: NextRedirect[]; rewrites?: { beforeFiles: NextRewrite[]; @@ -232,6 +237,7 @@ export function generateRscEntry( const cacheComponents = config?.cacheComponents === true; const prefetchInlining = config?.prefetchInlining ?? false; const hasServerActions = config?.hasServerActions !== false; + const actionOwners = config?.actionOwners; const i18nConfig = config?.i18n ?? null; const hasPagesDir = config?.hasPagesDir ?? false; const publicFiles = config?.publicFiles ?? []; @@ -318,6 +324,7 @@ import { ensureInstrumentationRegistered as __ensureInstrumentationRegistered } : "" } import { createAppRscHandler } from "vinext/server/app-rsc-handler"; +import { forwardServerActionIfNeeded as __forwardServerActionIfNeeded } from ${JSON.stringify(appActionForwardingPath)}; import { registerConfiguredCacheAdapters as __registerConfiguredCacheAdapters } from "virtual:vinext-cache-adapters"; import __pagesClientAssets from "virtual:vinext-pages-client-assets"; import { setPagesClientAssets as __setPagesClientAssets } from "vinext/server/pages-client-assets"; @@ -423,7 +430,6 @@ import { renderPagesFallback as __renderPagesFallback } from ${JSON.stringify(ap // so per-route dispatch can opt into suppression via .run(true, ...). import { suppressHookWarningAls } from ${JSON.stringify(appHookWarningSuppressionPath)}; import { clearAppRequestContext as __clearRequestContext, setAppNavigationContext as setNavigationContext } from ${JSON.stringify(appRequestContextPath)}; - __configureMemoryCacheHandler({ cacheMaxMemorySize: ${JSON.stringify(cacheMaxMemorySize)} }); import { createAppPrerenderStaticParamsResolver as __createAppPrerenderStaticParamsResolver } from ${JSON.stringify(appPrerenderStaticParamsPath)}; import { ensureAppRouteModulesLoaded as __ensureRouteLoaded } from ${JSON.stringify(appRouteModuleLoaderPath)}; @@ -706,7 +712,8 @@ ${rootParamNameEntries.join("\n")} }; __setPagesClientAssets(__pagesClientAssets); -export default createAppRscHandler({ +function __VINEXT_ACTION_OWNERS() { return ${actionOwners ? JSON.stringify(actionOwners) : '"__VINEXT_ACTION_OWNERS_STUB__"'}; } +const __appRscHandler = createAppRscHandler({ basePath: __basePath, buildId: process.env.__VINEXT_BUILD_ID ?? null, ensureRouteLoaded: __ensureRouteLoaded, @@ -1050,6 +1057,20 @@ export default createAppRscHandler({ request, searchParams, }) { + const __currentActionMatch = matchRoute(cleanPathname); + const __forwardResponse = await __forwardServerActionIfNeeded({ + actionId, + actionOwners: __VINEXT_ACTION_OWNERS(), + basePath: __basePath, + clearRequestContext: __clearRequestContext, + currentRoutePattern: __currentActionMatch?.route.pattern ?? null, + dispatch(__forwardRequest) { + return __appRscHandler(__forwardRequest, { actionForwarded: true }); + }, + middlewareContext, + request, + }); + if (__forwardResponse) return __forwardResponse; const { handleServerActionRscRequest: __handleServerActionRscRequest, readActionBodyWithLimit: __readBodyWithLimit, @@ -1113,6 +1134,9 @@ export default createAppRscHandler({ }, createTemporaryReferenceSet, decodeReply, + dispatchRedirectRequest(redirectRequest) { + return __appRscHandler(redirectRequest, undefined); + }, draftModeSecret: __draftModeSecret, findIntercept(pathnameToMatch) { return findIntercept(pathnameToMatch, interceptionContext); @@ -1237,6 +1261,8 @@ export default createAppRscHandler({ validateDevRequestOrigin: __validateDevRequestOrigin, }); +export default __appRscHandler; + if (import.meta.hot) { import.meta.hot.accept(); } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index c9c040d761..4ae200f58f 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -68,6 +68,12 @@ import { } from "./build/route-classification-manifest.js"; import { extractMiddlewareMatcherConfig, hasExportedName } from "./build/report.js"; import { planRouteClassificationInjection } from "./build/route-classification-injector.js"; +import { + actionOwnerManifestMode, + buildActionOwnerManifest, + buildStaticActionOwnerManifest, + injectActionOwnerManifest, +} from "./build/action-owner-manifest.js"; import { normalizePathnameForRouteMatchStrict } from "./routing/utils.js"; import { createRscCompatibilityId, @@ -1295,6 +1301,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let warnedInlineNextConfigOverride = false; let hasNitroPlugin = false; let isServeCommand = false; + let isDevelopmentServe = false; let pagesOptimizeEntries: string[] = []; const pagesClientAssetsOutputDirs = new Set(); let pagesClientAssetsModule: string | null = null; @@ -1315,6 +1322,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // module's load hook and consumed in renderChunk to patch the generated // `__VINEXT_CLASS` stub with a real dispatch table. let rscClassificationManifest: RouteClassificationManifest | null = null; + let rscActionOwnerRoutes: Awaited> | null = null; + let rscStaticActionOwners: Record | null = null; // Resolve shim paths - works both from source (.ts) and built (.js). // Normalize to forward slashes so every downstream `path.posix.join` keeps @@ -1736,6 +1745,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { async config(config, env) { isServeCommand = env.command === "serve"; + isDevelopmentServe = actionOwnerManifestMode(env) === "development"; root = normalizePathSeparators(config.root ?? process.cwd()); const userResolve = config.resolve as UserResolveConfigWithTsconfigPaths | undefined; const shouldEnableNativeTsconfigPaths = userResolve?.tsconfigPaths === undefined; @@ -3483,6 +3493,17 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // indices in the manifest correspond 1:1 to the route.layouts arrays // used during codegen. renderChunk clears this after patching. rscClassificationManifest = collectRouteClassificationManifest(routes); + const actionOwners = await buildStaticActionOwnerManifest({ + mode: isDevelopmentServe ? "development" : "production", + root, + routes, + resolve: async (source, importer) => { + const resolved = await this.resolve(source, importer); + return resolved ? { id: resolved.id } : null; + }, + }); + rscActionOwnerRoutes = isDevelopmentServe ? null : routes; + rscStaticActionOwners = isDevelopmentServe ? null : actionOwners; return generateRscEntry( appDir, routes, @@ -3510,6 +3531,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { cacheComponents: nextConfig?.cacheComponents, prefetchInlining: nextConfig?.prefetchInlining, hasServerActions, + actionOwners: isDevelopmentServe ? actionOwners : undefined, i18n: nextConfig?.i18n, imageConfig: { deviceSizes: nextConfig?.images?.deviceSizes, @@ -3616,7 +3638,7 @@ export const loadServerActionClient = ${ // by reference rather than by name. renderChunk: { order: "pre", - handler(code, chunk) { + async handler(code, chunk) { // Only run in the RSC environment. SSR/client builds never contain // the __VINEXT_CLASS stub so there is nothing to patch there, and // pulling ModuleInfo from the wrong graph would give nonsense @@ -3625,7 +3647,9 @@ export const loadServerActionClient = ${ if (!rscClassificationManifest) return null; // Cheap pre-filter: skip chunks that don't mention the stub at all // (e.g. the scan-phase chunk and every non-entry chunk). - if (!code.includes("__VINEXT_CLASS")) return null; + if (!code.includes("__VINEXT_CLASS")) { + return null; + } // Patching per-chunk (rather than scanning the whole bundle in // generateBundle) assumes the stub body and its per-route call sites @@ -3659,15 +3683,35 @@ export const loadServerActionClient = ${ }, }; - const patchPlan = planRouteClassificationInjection({ - canonicalizeLayoutPath: canonicalize, - chunks: [{ code, fileName: chunk.fileName }], - dynamicShimPaths, - enableDebugReasons: enableClassificationDebug, - manifest: rscClassificationManifest, - moduleInfo, - }); - if (patchPlan.kind === "skip") return null; + const patchPlan = rscClassificationManifest + ? planRouteClassificationInjection({ + canonicalizeLayoutPath: canonicalize, + chunks: [{ code, fileName: chunk.fileName }], + dynamicShimPaths, + enableDebugReasons: enableClassificationDebug, + manifest: rscClassificationManifest, + moduleInfo, + }) + : { kind: "skip" as const }; + let nextCode = patchPlan.kind === "skip" ? code : patchPlan.code; + + if (rscActionOwnerRoutes && nextCode.includes("__VINEXT_ACTION_OWNERS_STUB__")) { + const rscModule = await rscPluginModulePromise; + const pluginApi = rscModule?.getPluginApi(this.environment.config); + const serverReferences = Object.values(pluginApi?.manager.serverReferenceMetaMap ?? {}); + const discoveredActionOwners = buildActionOwnerManifest({ + canonicalizeModuleId: canonicalize, + moduleInfo, + routes: rscActionOwnerRoutes, + serverReferences, + staticOwners: rscStaticActionOwners ?? {}, + }); + const injected = injectActionOwnerManifest(nextCode, discoveredActionOwners); + if (!injected) this.error("vinext: failed to inject the server action owner manifest"); + nextCode = injected; + rscActionOwnerRoutes = null; + rscStaticActionOwners = null; + } // Consume the manifest exactly once per RSC entry. Clearing here // prevents a stale manifest from leaking into a subsequent build pass @@ -3679,7 +3723,7 @@ export const loadServerActionClient = ${ // map would be stale. RSC entry source maps are not served or // consumed, so nulling the map is safe and prevents stale-map // confusion in tooling. - return { code: patchPlan.code, map: patchPlan.map }; + return { code: nextCode, map: null }; }, }, }, diff --git a/packages/vinext/src/server/app-action-forwarding.ts b/packages/vinext/src/server/app-action-forwarding.ts new file mode 100644 index 0000000000..9f809ac264 --- /dev/null +++ b/packages/vinext/src/server/app-action-forwarding.ts @@ -0,0 +1,142 @@ +import { RequestCookies, ResponseCookies } from "vinext/shims/internal/cookies"; +import { patternToNextFormat } from "../routing/route-validation.js"; +import { buildRequestHeadersFromMiddlewareResponse } from "../utils/middleware-request-headers.js"; +import type { AppMiddlewareContext } from "./app-middleware.js"; +import { ACTION_FORWARDED_HEADER } from "./headers.js"; +import { + createServerActionNotFoundResponse, + getServerActionNotFoundMessage, +} from "./server-action-not-found.js"; + +const ACTION_FORWARD_FORBIDDEN_HEADERS = new Set([ + "accept-encoding", + "keepalive", + "keep-alive", + "content-encoding", + "transfer-encoding", + "connection", + "expect", + "content-length", + "set-cookie", +]); + +type ActionOwnerManifest = Readonly>; + +type ForwardServerActionOptions = { + actionId: string; + actionOwners: ActionOwnerManifest | null; + basePath: string; + clearRequestContext: () => void; + currentRoutePattern: string | null; + dispatch: (request: Request) => Promise; + middlewareContext: AppMiddlewareContext; + request: Request; +}; + +function mergeActionForwardCookies(requestHeaders: Headers, responseHeaders: Headers): void { + const requestCookies = new RequestCookies(requestHeaders); + const responseCookies = new ResponseCookies(responseHeaders); + for (const cookie of responseCookies.getAll()) { + if (cookie.value === undefined) requestCookies.delete(cookie.name); + else requestCookies.set(cookie); + } + requestHeaders.set("cookie", requestCookies.toString()); +} + +function buildActionForwardHeaders( + request: Request, + middlewareContext: AppMiddlewareContext, +): Headers { + const middlewareRequestHeaders = middlewareContext.requestHeaders ?? middlewareContext.headers; + const headers = middlewareRequestHeaders + ? (buildRequestHeadersFromMiddlewareResponse(request.headers, middlewareRequestHeaders, { + preserveCredentialHeaders: true, + }) ?? new Headers(request.headers)) + : new Headers(request.headers); + + for (const [key, value] of middlewareContext.headers ?? []) { + headers.set(key, value); + } + if (middlewareContext.headers) { + mergeActionForwardCookies(headers, middlewareContext.headers); + } + for (const key of ACTION_FORWARD_FORBIDDEN_HEADERS) { + headers.delete(key); + } + return headers; +} + +function filterActionForwardResponse(response: Response): Response { + const headers = new Headers(); + for (const [key, value] of response.headers) { + if (!ACTION_FORWARD_FORBIDDEN_HEADERS.has(key.toLowerCase())) { + headers.set(key, value); + } + } + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + +function emptyActionForwardResponse(): Response { + return new Response("{}", { headers: { "content-type": "application/json" } }); +} + +function actionOwnerPatterns( + actionOwners: ActionOwnerManifest, + actionId: string, +): readonly string[] | undefined { + if (Object.hasOwn(actionOwners, actionId)) return actionOwners[actionId]; + const moduleId = actionId.split("#", 1)[0]!; + return Object.hasOwn(actionOwners, moduleId) ? actionOwners[moduleId] : undefined; +} + +export async function forwardServerActionIfNeeded( + options: ForwardServerActionOptions, +): Promise { + if (!options.actionOwners) return null; + + const ownerPatterns = actionOwnerPatterns(options.actionOwners, options.actionId); + if ( + ownerPatterns && + options.currentRoutePattern && + ownerPatterns.includes(options.currentRoutePattern) + ) { + return null; + } + + const ownerPath = ownerPatterns?.[0]; + if (!ownerPath || options.request.headers.get(ACTION_FORWARDED_HEADER)) { + console.warn(getServerActionNotFoundMessage(options.actionId)); + options.clearRequestContext(); + return createServerActionNotFoundResponse(); + } + + const forwardUrl = new URL(options.request.url); + forwardUrl.pathname = + options.basePath + (ownerPath === "/" ? "" : patternToNextFormat(ownerPath)); + forwardUrl.search = ""; + const forwardRequest = new Request(forwardUrl, { + body: options.request.body, + duplex: "half", + headers: buildActionForwardHeaders(options.request, options.middlewareContext), + method: "POST", + } as RequestInit & { duplex: "half" }); + + let forwardResponse: Response; + try { + forwardResponse = await options.dispatch(forwardRequest); + } catch (error) { + console.error("[vinext] Failed to forward server action:", error); + options.clearRequestContext(); + return emptyActionForwardResponse(); + } + + if (forwardResponse.headers.get("content-type")?.startsWith("text/x-component")) { + return filterActionForwardResponse(forwardResponse); + } + await forwardResponse.body?.cancel(); + return emptyActionForwardResponse(); +} diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index c4270b8062..383a92e18c 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -1303,6 +1303,14 @@ export function createAppRscHandler( rawRequest.headers.get(VINEXT_PRERENDER_SECRET_HEADER) !== null && rawRequest.headers.get(VINEXT_PRERENDER_SPECULATIVE_HEADER) === "1"; const filteredHeaders = filterInternalHeaders(rawRequest.headers); + if ( + ctx !== null && + typeof ctx === "object" && + "actionForwarded" in ctx && + ctx.actionForwarded === true + ) { + filteredHeaders.set("x-action-forwarded", "1"); + } if (mwCtx !== null) { filteredHeaders.set(VINEXT_MW_CTX_HEADER, mwCtx); } diff --git a/packages/vinext/src/server/app-server-action-execution.ts b/packages/vinext/src/server/app-server-action-execution.ts index 908f179f5f..aefacdd392 100644 --- a/packages/vinext/src/server/app-server-action-execution.ts +++ b/packages/vinext/src/server/app-server-action-execution.ts @@ -258,6 +258,7 @@ export type HandleServerActionRscRequestOptions< body: string | FormData, options: DecodeServerActionReplyOptions, ) => Promise | unknown[]; + dispatchRedirectRequest?: (request: Request) => Promise; draftModeSecret: string; /** * Hydrate a route's lazy page/route-handler modules before reading @@ -459,6 +460,7 @@ function createActionRedirectRenderRequest(options: { } else { headers.set("cookie", cookieHeader); } + headers.delete("next-router-state-tree"); return new Request(options.url, { headers, @@ -473,6 +475,26 @@ function withoutRscBodyHeaders(headers: Headers): Headers { return nextHeaders; } +const ACTIONS_FORBIDDEN_RESPONSE_HEADERS = new Set([ + "accept-encoding", + "keepalive", + "keep-alive", + "content-encoding", + "transfer-encoding", + "connection", + "expect", + "content-length", + "set-cookie", +]); + +function mergeForwardedActionResponseHeaders(target: Headers, source: Headers): void { + for (const [key, value] of source) { + if (!ACTIONS_FORBIDDEN_RESPONSE_HEADERS.has(key.toLowerCase())) { + target.set(key, value); + } + } +} + function isReadableStreamBody(body: BodyInit | null): body is ReadableStream { return typeof ReadableStream !== "undefined" && body instanceof ReadableStream; } @@ -1296,6 +1318,49 @@ export async function handleServerActionRscRequest< request: options.request, url: redirectTarget, }); + if (options.dispatchRedirectRequest) { + redirectRenderRequest.headers.set("rsc", "1"); + let forwardedResponse: Response; + try { + forwardedResponse = await options.dispatchRedirectRequest(redirectRenderRequest); + } catch (error) { + console.error("[vinext] Failed to render action redirect target:", error); + options.clearRequestContext(); + return new Response(null, { + status: 303, + headers: withoutRscBodyHeaders(redirectHeaders), + }); + } + + if ( + forwardedResponse.headers.get("content-type")?.startsWith(VINEXT_RSC_CONTENT_TYPE) && + forwardedResponse.body + ) { + mergeForwardedActionResponseHeaders(redirectHeaders, forwardedResponse.headers); + const redirectResponseStatus = shouldUseForwardedActionRedirectStatus({ + actionWasForwarded, + currentPathname: options.cleanPathname, + currentRoute: currentMatch?.route ?? null, + resolveRouteRuntime: options.resolveRouteRuntime, + targetPathname, + targetRoute: targetMatch.route, + }) + ? 200 + : 303; + return createServerActionRscResponse( + forwardedResponse.body, + { status: redirectResponseStatus, headers: redirectHeaders }, + options.clearRequestContext, + ); + } + + await forwardedResponse.body?.cancel(); + options.clearRequestContext(); + return new Response(null, { + status: 303, + headers: withoutRscBodyHeaders(redirectHeaders), + }); + } setHeadersContext( headersContextFromRequest(redirectRenderRequest, { draftModeSecret: options.draftModeSecret, diff --git a/tests/action-owner-manifest.test.ts b/tests/action-owner-manifest.test.ts new file mode 100644 index 0000000000..c7d9f9d1f0 --- /dev/null +++ b/tests/action-owner-manifest.test.ts @@ -0,0 +1,386 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import type { AppRoute } from "../packages/vinext/src/routing/app-route-graph.js"; +import { + actionOwnerManifestMode, + actionOwnerRouteEntryIds, + buildActionOwnerManifest, + buildStaticActionOwnerManifest, +} from "../packages/vinext/src/build/action-owner-manifest.js"; + +let roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { force: true, recursive: true }))); +}); + +function route(pattern: string, pagePath: string) { + return { + errorPath: null, + errorPaths: [], + forbiddenPath: null, + forbiddenPaths: [], + layoutErrorPaths: [], + layouts: [], + loadingPath: null, + notFoundPath: null, + notFoundPaths: [], + pagePath, + parallelSlots: [], + pattern, + templates: [], + unauthorizedPath: null, + unauthorizedPaths: [], + siblingIntercepts: [], + }; +} + +async function fixture(files: Record) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-action-owner-")); + roots.push(root); + for (const [relativePath, source] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, source); + } + return { + root, + resolve: async (specifier: string, importer?: string) => { + const candidate = path.resolve(importer ? path.dirname(importer) : root, specifier); + for (const filePath of [candidate, `${candidate}.ts`, `${candidate}.tsx`]) { + try { + if ((await fs.stat(filePath)).isFile()) return { id: filePath }; + } catch {} + } + return null; + }, + }; +} + +function productionKey(relativePath: string): string { + return createHash("sha256").update(relativePath).digest("hex").slice(0, 12); +} + +describe("server action owner manifest", () => { + it("treats Vite preview as production despite command serve", () => { + expect(actionOwnerManifestMode({ command: "serve", isPreview: false })).toBe("development"); + expect(actionOwnerManifestMode({ command: "serve", isPreview: true })).toBe("production"); + expect(actionOwnerManifestMode({ command: "build" })).toBe("production"); + }); + it("maps only imported action exports to each owning route", async () => { + const app = await fixture({ + "app/actions.ts": `'use server';\nexport async function publicOnly() {}\nexport async function adminOnly() {}\n`, + "app/admin/page.tsx": `import { adminOnly } from "../actions"; export default function Page() { return adminOnly; }`, + "app/page.tsx": `import { publicOnly } from "./actions"; export default function Page() { return publicOnly; }`, + }); + + const manifest = await buildStaticActionOwnerManifest({ + mode: "production", + root: app.root, + routes: [ + route("/", path.join(app.root, "app/page.tsx")), + route("/admin", path.join(app.root, "app/admin/page.tsx")), + ], + resolve: app.resolve, + }); + + const actionKey = productionKey("app/actions.ts"); + expect(manifest[`${actionKey}#publicOnly`]).toEqual(["/"]); + expect(manifest[`${actionKey}#adminOnly`]).toEqual(["/admin"]); + }); + + it("uses the unchanged plugin-rsc production and development reference keys", async () => { + const app = await fixture({ + "app/actions.ts": `'use server'; export default async function action() {}`, + "app/page.tsx": `import action from "./actions"; export default function Page() { return action; }`, + }); + const pagePath = path.join(app.root, "app/page.tsx"); + + const production = await buildStaticActionOwnerManifest({ + mode: "production", + root: app.root, + routes: [route("/", pagePath)], + resolve: app.resolve, + }); + const development = await buildStaticActionOwnerManifest({ + mode: "development", + root: app.root, + routes: [route("/", pagePath)], + resolve: app.resolve, + }); + + expect(production[`${productionKey("app/actions.ts")}#default`]).toEqual(["/"]); + expect(development["/app/actions.ts#default"]).toEqual(["/"]); + }); + + it("traces aliased re-exports to the original server action export", async () => { + const app = await fixture({ + "app/actions.ts": `'use server'; export async function original() {}`, + "app/barrel.ts": `export { original as exposed } from "./actions";`, + "app/page.tsx": `import { exposed } from "./barrel"; export default function Page() { return exposed; }`, + }); + + const manifest = await buildStaticActionOwnerManifest({ + mode: "production", + root: app.root, + routes: [route("/", path.join(app.root, "app/page.tsx"))], + resolve: app.resolve, + }); + + expect(manifest[`${productionKey("app/actions.ts")}#original`]).toEqual(["/"]); + }); + + it("does not authorize sibling exports from a shared barrel", async () => { + const app = await fixture({ + "app/actions.ts": `'use server'; export async function publicOnly() {} export async function adminOnly() {}`, + "app/barrel.ts": `export { publicOnly, adminOnly } from "./actions";`, + "app/page.tsx": `import { publicOnly } from "./barrel"; export default function Page() { return publicOnly; }`, + }); + + const manifest = await buildStaticActionOwnerManifest({ + mode: "production", + root: app.root, + routes: [route("/", path.join(app.root, "app/page.tsx"))], + resolve: app.resolve, + }); + + const actionKey = productionKey("app/actions.ts"); + expect(manifest[`${actionKey}#publicOnly`]).toEqual(["/"]); + expect(manifest[`${actionKey}#adminOnly`]).toBeUndefined(); + }); + + it("follows namespace re-exports only when the namespace is consumed", async () => { + const app = await fixture({ + "app/actions.ts": `'use server'; export async function first() {} export async function second() {}`, + "app/barrel.ts": `export * as actions from "./actions";`, + "app/page.tsx": `import { actions } from "./barrel"; export default function Page() { return actions.first; }`, + }); + + const manifest = await buildStaticActionOwnerManifest({ + mode: "production", + root: app.root, + routes: [route("/", path.join(app.root, "app/page.tsx"))], + resolve: app.resolve, + }); + + const actionKey = productionKey("app/actions.ts"); + expect(manifest[`${actionKey}#first`]).toEqual(["/"]); + expect(manifest[`${actionKey}#second`]).toEqual(["/"]); + }); + + // Ported from Next.js: test/e2e/app-dir/app-external/app/action/client/page.js + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-external/app/action/client/page.js + it("maps server actions imported from packages", async () => { + const app = await fixture({ + "app/page.tsx": `import { action1 } from "server-action-mod"; export default function Page() { return action1; }`, + "node_modules/server-action-mod/index.js": `'use server'; export async function action1() {}`, + "node_modules/server-action-mod/package.json": `{"name":"server-action-mod","type":"module"}`, + }); + const packageEntry = path.join(app.root, "node_modules/server-action-mod/index.js"); + const resolve = async (specifier: string, importer?: string) => { + if (specifier === "server-action-mod") return { id: packageEntry }; + return app.resolve(specifier, importer); + }; + + const manifest = await buildStaticActionOwnerManifest({ + mode: "production", + root: app.root, + routes: [route("/", path.join(app.root, "app/page.tsx"))], + resolve, + }); + + expect(manifest[`${productionKey("node_modules/server-action-mod/index.js")}#action1`]).toEqual( + ["/"], + ); + }); + + it("does not mistake inline action directives for module-level directives", async () => { + const app = await fixture({ + "app/page.tsx": `export default function Page() {\n async function action() {\n "use server";\n }\n return action;\n}`, + }); + const pagePath = path.join(app.root, "app/page.tsx"); + + const manifest = await buildStaticActionOwnerManifest({ + mode: "production", + root: app.root, + routes: [route("/", pagePath)], + resolve: app.resolve, + }); + const pageKey = productionKey("app/page.tsx"); + + expect(manifest[pageKey]).toEqual(["/"]); + expect(manifest[`${pageKey}#default`]).toBeUndefined(); + }); + + it("ignores type-only and commented imports when assigning action ownership", async () => { + const app = await fixture({ + "app/admin-actions.ts": `'use server'; export async function adminOnly() {}`, + "app/page.tsx": `import type { adminOnly } from "./admin-actions"; +// import { adminOnly } from "./admin-actions"; +const example = 'import { adminOnly } from "./admin-actions"'; +const template = \` +import { adminOnly } from "./admin-actions"; +\`; +export default function Page() { return example + template; }`, + }); + + const manifest = await buildStaticActionOwnerManifest({ + mode: "production", + root: app.root, + routes: [route("/", path.join(app.root, "app/page.tsx"))], + resolve: app.resolve, + }); + + expect(manifest[`${productionKey("app/admin-actions.ts")}#adminOnly`]).toBeUndefined(); + }); + + it("uses final plugin metadata only for actions defined in route components", () => { + expect( + buildActionOwnerManifest({ + moduleInfo: { + getModuleInfo() { + return null; + }, + }, + routes: [route("/", "/app/page.tsx")], + serverReferences: [ + { + exportNames: ["$$hoist_0_inline"], + importId: "/app/page.tsx", + referenceKey: "inline-key", + }, + { + exportNames: ["importedAction"], + importId: "/app/actions.ts", + referenceKey: "imported-key", + }, + ], + staticOwners: {}, + }), + ).toEqual({ + "inline-key": ["/"], + "inline-key#$$hoist_0_inline": ["/"], + }); + }); + + it("requires imported actions to exist in the final runtime graph", () => { + expect( + buildActionOwnerManifest({ + moduleInfo: { + getModuleInfo(id) { + return id === "/app/page.tsx" ? { importedIds: [] } : null; + }, + }, + routes: [route("/", "/app/page.tsx")], + serverReferences: [ + { + exportNames: ["adminOnly"], + importId: "/app/admin-actions.ts", + referenceKey: "admin-key", + }, + ], + staticOwners: { "admin-key#adminOnly": ["/"] }, + }), + ).toEqual({}); + }); + + it("accepts actions reachable through the final dynamic import graph", () => { + expect( + buildActionOwnerManifest({ + moduleInfo: { + getModuleInfo(id) { + return id === "/app/page.tsx" + ? { dynamicImportedIds: ["/app/actions.ts"], importedIds: [] } + : { importedIds: [] }; + }, + }, + routes: [route("/", "/app/page.tsx")], + serverReferences: [ + { + exportNames: ["submit"], + importId: "/app/actions.ts", + referenceKey: "action-key", + }, + ], + staticOwners: { "action-key#submit": ["/"] }, + }), + ).toEqual({ "action-key#submit": ["/"] }); + }); + + it("includes every route component that can own an inline action", () => { + const base = route("/", "/app/page.tsx"); + const entryIds = actionOwnerRouteEntryIds({ + ...base, + errorPath: "/app/error.tsx", + errorPaths: ["/app/nested-error.tsx"], + forbiddenPath: "/app/forbidden.tsx", + forbiddenPaths: ["/app/nested-forbidden.tsx"], + layoutErrorPaths: ["/app/layout-error.tsx"], + layouts: ["/app/layout.tsx"], + loadingPath: "/app/loading.tsx", + notFoundPath: "/app/not-found.tsx", + notFoundPaths: ["/app/nested-not-found.tsx"], + parallelSlots: [ + { + configLayoutPaths: ["/app/@slot/config-layout.tsx"], + defaultPath: "/app/@slot/default.tsx", + errorPath: "/app/@slot/error.tsx", + interceptingRoutes: [ + { + convention: "(.)", + layoutPaths: [], + pagePath: "/app/@slot/(.)modal/page.tsx", + params: [], + sourceMatchPattern: "/modal", + targetPattern: "/modal", + }, + ], + layoutPath: "/app/@slot/layout.tsx", + loadingPath: "/app/@slot/loading.tsx", + pagePath: "/app/@slot/page.tsx", + } as unknown as AppRoute["parallelSlots"][number], + ], + templates: ["/app/template.tsx"], + unauthorizedPath: "/app/unauthorized.tsx", + unauthorizedPaths: ["/app/nested-unauthorized.tsx"], + siblingIntercepts: [ + { + convention: "(.)", + layoutPaths: ["/app/(.)modal/layout.tsx"], + pagePath: "/app/(.)modal/page.tsx", + params: [], + sourceMatchPattern: "/", + targetPattern: "/modal", + }, + ], + }); + + expect(entryIds).toEqual([ + "/app/page.tsx", + "/app/layout.tsx", + "/app/template.tsx", + "/app/loading.tsx", + "/app/error.tsx", + "/app/layout-error.tsx", + "/app/nested-error.tsx", + "/app/not-found.tsx", + "/app/nested-not-found.tsx", + "/app/forbidden.tsx", + "/app/nested-forbidden.tsx", + "/app/unauthorized.tsx", + "/app/nested-unauthorized.tsx", + "/app/@slot/page.tsx", + "/app/@slot/default.tsx", + "/app/@slot/layout.tsx", + "/app/@slot/config-layout.tsx", + "/app/@slot/loading.tsx", + "/app/@slot/error.tsx", + "/app/@slot/(.)modal/page.tsx", + "/app/(.)modal/page.tsx", + "/app/(.)modal/layout.tsx", + ]); + }); +}); diff --git a/tests/action-runtime-security.test.ts b/tests/action-runtime-security.test.ts new file mode 100644 index 0000000000..1baa8c14b2 --- /dev/null +++ b/tests/action-runtime-security.test.ts @@ -0,0 +1,359 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; +import { createBuilder } from "vite"; +import vinext from "../packages/vinext/src/index.js"; + +type BuiltHandler = (request: Request, context?: unknown) => Promise; + +let fixtureRoot = ""; +let handler: BuiltHandler; +let actionIds: Record; +let builtSource = ""; +let encryptionKey: CryptoKey; + +async function write(relativePath: string, contents: string): Promise { + const filePath = path.join(fixtureRoot, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents); +} + +async function readBuiltJavaScript(directory: string): Promise { + let output = ""; + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) output += await readBuiltJavaScript(entryPath); + else if (entry.name.endsWith(".js")) output += await fs.readFile(entryPath, "utf8"); + } + return output; +} + +function findActionId(source: string, exportName: string): string { + const escapedExportName = exportName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = source.match( + new RegExp(`["'\`]([0-9a-f]{12})["'\`]\\s*,\\s*["'\`]${escapedExportName}["'\`]`), + ); + if (!match) throw new Error(`Missing built action id for ${exportName}`); + return `${match[1]}#${exportName}`; +} + +function findActionIds(source: string, exportName: string): string[] { + const escapedExportName = exportName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return [ + ...source.matchAll( + new RegExp(`["'\`]([0-9a-f]{12})["'\`]\\s*,\\s*["'\`]${escapedExportName}["'\`]`, "g"), + ), + ].map((match) => `${match[1]}#${exportName}`); +} + +function actionRequest(pathname: string, actionId: string, args: unknown[]): Request { + return new Request(`http://example.com${pathname}`, { + body: JSON.stringify(args), + headers: { + "content-type": "text/plain;charset=UTF-8", + host: "example.com", + "next-action": actionId, + origin: "http://example.com", + }, + method: "POST", + }); +} + +async function encryptBoundArgsFixture(serializedFlightValue: string): Promise { + const iv = crypto.getRandomValues(new Uint8Array(16)); + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + encryptionKey, + new TextEncoder().encode(serializedFlightValue), + ); + return Buffer.concat([Buffer.from(iv), Buffer.from(encrypted)]).toString("base64"); +} + +describe("production server action runtime security", () => { + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-action-runtime-security-")); + await fs.symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules")); + await write("package.json", '{"type":"module"}\n'); + await write( + "middleware.ts", + `import { NextResponse } from "next/server"; +export function middleware(request) { + if (request.nextUrl.pathname === "/admin/[id]") { + return new NextResponse("dynamic worker denied", { status: 401 }); + } + if (request.nextUrl.pathname === "/cookie-source") { + const headers = new Headers(request.headers); + headers.set("x-forwarded-auth", "trusted"); + const response = NextResponse.next({ request: { headers } }); + response.cookies.set("forwarded-cookie", "present"); + return response; + } + if (request.nextUrl.pathname.startsWith("/admin")) { + return new NextResponse("unauthorized", { status: 401 }); + } + if (request.nextUrl.pathname === "/loop") { + return NextResponse.rewrite(new URL("/", request.url)); + } + return NextResponse.next(); +} +export const config = { matcher: ["/admin/:path*", "/cookie-source", "/loop"] }; +`, + ); + await write( + "app/layout.tsx", + `export default function Layout({ children }) { + return {children}; +} +`, + ); + await write( + "app/actions.ts", + `'use server'; +import { redirect } from "next/navigation"; +export async function goTo(target) { redirect(target); } +`, + ); + await write( + "app/page.tsx", + `import { goTo } from "./actions"; +export default function Page() { + return
; +} +`, + ); + await write( + "app/admin/actions.ts", + `'use server'; +export async function deleteEverything() { return "ADMIN_ACTION_EXECUTED"; } +`, + ); + await write( + "app/admin/page.tsx", + `import { deleteEverything } from "./actions"; +export default function Page() { + return
; +} +`, + ); + await write( + "app/shared-actions.ts", + `'use server'; +export async function publicOnly() { return "PUBLIC_ACTION_EXECUTED"; } +export async function adminOnly() { return "ADMIN_SHARED_ACTION_EXECUTED"; } +`, + ); + await write( + "app/shared/page.tsx", + `import { publicOnly } from "../shared-actions"; +export default function Page() { return
; } +`, + ); + await write( + "app/admin/shared/page.tsx", + `import { adminOnly } from "../../shared-actions"; +export default function Page() { return
; } +`, + ); + await write( + "app/admin/[id]/page.tsx", + `export default function Page() { + const dynamicAdmin = async () => { "use server"; return "DYNAMIC_ADMIN_ACTION_EXECUTED"; }; + return
; +} +`, + ); + await write( + "app/loop/page.tsx", + `export default function Page() { + const loopAction = async () => { "use server"; return "LOOP_ACTION_EXECUTED"; }; + return
; +} +`, + ); + await write( + "app/cookie-owner/page.tsx", + `import { cookies, headers } from "next/headers"; +export default function Page() { + const readForwardedCookie = async () => { + "use server"; + return { + auth: (await headers()).get("x-forwarded-auth"), + cookie: (await cookies()).get("forwarded-cookie")?.value ?? "missing", + }; + }; + return
; +} +`, + ); + await write( + "app/admin/secret/page.tsx", + `export default function Page() { return
ADMIN_SECRET_MARKER_42
; } +`, + ); + await write( + "app/oracle/page.tsx", + `export default async function Page({ searchParams }) { + const value = (await searchParams).value ?? "public"; + const oracle = async () => { "use server"; return "ORACLE:" + value; }; + return
; +} +`, + ); + await write( + "app/admin/replay/page.tsx", + `import { redirect } from "next/navigation"; +export default function Page() { + const accountToDelete = "alice"; + const deleteAccount = async () => { + "use server"; + redirect("/admin/secret?deleted=" + accountToDelete); + }; + return
; +} +`, + ); + + const builder = await createBuilder({ + root: fixtureRoot, + configFile: false, + plugins: [vinext({ appDir: fixtureRoot })], + logLevel: "silent", + }); + await builder.buildApp(); + + builtSource = await readBuiltJavaScript(path.join(fixtureRoot, "dist", "server")); + actionIds = { + deleteEverything: findActionId(builtSource, "deleteEverything"), + goTo: findActionId(builtSource, "goTo"), + }; + const encryptionKeySource = await fs.readFile( + path.join(fixtureRoot, "dist", "server", "__vite_rsc_encryption_key.js"), + "utf8", + ); + const encryptionKeyBase64 = encryptionKeySource.match( + /export default ["'`]([^"'`]+)["'`]/, + )?.[1]; + if (!encryptionKeyBase64) throw new Error("Missing built RSC encryption key"); + encryptionKey = await crypto.subtle.importKey( + "raw", + Buffer.from(encryptionKeyBase64, "base64"), + { name: "AES-GCM" }, + true, + ["encrypt", "decrypt"], + ); + const module = await import( + `${pathToFileURL(path.join(fixtureRoot, "dist", "server", "index.js")).href}?security` + ); + handler = module.default as BuiltHandler; + }, 120_000); + + afterAll(async () => { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + }); + + it("re-enters protected route middleware before dispatching an action from a public path", async () => { + const direct = await handler(new Request("http://example.com/admin")); + expect(direct.status).toBe(401); + + const response = await handler(actionRequest("/", actionIds.deleteEverything, [])); + expect(response.status).toBe(200); + expect(await response.text()).toBe("{}"); + }); + + it("keeps offline-computable action ids harmless by enforcing route ownership", async () => { + const normalizedActionPath = path.relative( + fixtureRoot, + await fs.realpath(path.join(fixtureRoot, "app/admin/actions.ts")), + ); + const offlineActionId = `${createHash("sha256").update(normalizedActionPath).digest("hex").slice(0, 12)}#deleteEverything`; + + expect(actionIds.deleteEverything).toBe(offlineActionId); + const response = await handler(actionRequest("/", offlineActionId, [])); + expect(response.status).toBe(200); + expect(await response.text()).toBe("{}"); + }); + + it("blocks cross-action closure replay and the composed redirect chain before action decode", async () => { + const encryptedBoundArgs = await encryptBoundArgsFixture('0:["victim-carol"]\n'); + const encryptedBytes = Buffer.from(encryptedBoundArgs, "base64"); + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: encryptedBytes.subarray(0, 16) }, + encryptionKey, + encryptedBytes.subarray(16), + ); + expect(new TextDecoder().decode(decrypted)).toBe('0:["victim-carol"]\n'); + + const targetActionIds = findActionIds(builtSource, "$$hoist_0_deleteAccount"); + + const responses = await Promise.all( + targetActionIds.map(async (targetActionId) => { + const response = await handler(actionRequest("/", targetActionId, [encryptedBoundArgs])); + return { + body: await response.text(), + redirect: response.headers.get("x-action-redirect"), + status: response.status, + }; + }), + ); + expect(responses.some((response) => response.status === 200)).toBe(true); + expect(responses.every((response) => response.status === 200 || response.status === 404)).toBe( + true, + ); + for (const response of responses) { + expect(response.redirect).toBeNull(); + expect(response.body).not.toContain("ADMIN_SECRET_MARKER_42"); + expect(response.body).not.toContain("victim-carol"); + } + }); + + it("does not inline middleware-gated redirect targets into action responses", async () => { + const direct = await handler(new Request("http://example.com/admin/secret")); + expect(direct.status).toBe(401); + + const response = await handler(actionRequest("/", actionIds.goTo, ["/admin/secret"])); + const body = await response.text(); + + expect(response.status).toBe(303); + expect(response.headers.get("x-action-redirect")).toBe("/admin/secret"); + expect(body).not.toContain("ADMIN_SECRET_MARKER_42"); + expect(body).toBe(""); + }); + + it("does not authorize unrelated exports from a shared action module", async () => { + const adminOnlyId = findActionId(builtSource, "adminOnly"); + const response = await handler(actionRequest("/shared", adminOnlyId, [])); + expect(response.status).toBe(200); + expect(await response.text()).toBe("{}"); + }); + + it("fails closed for unknown action ids", async () => { + const response = await handler(actionRequest("/", "000000000000#missing", [])); + expect(response.status).toBe(404); + }); + + it("forwards dynamic-route owners through middleware using a concrete pathname", async () => { + const dynamicActionId = findActionId(builtSource, "$$hoist_0_dynamicAdmin"); + const response = await handler(actionRequest("/", dynamicActionId, [])); + expect(response.status).toBe(200); + expect(await response.text()).toBe("{}"); + }); + + it("merges middleware response cookies into the forwarded action request", async () => { + const cookieActionId = findActionId(builtSource, "$$hoist_0_readForwardedCookie"); + const response = await handler(actionRequest("/cookie-source", cookieActionId, [])); + expect(response.status).toBe(200); + const body = await response.text(); + expect(body).toContain("present"); + expect(body).toContain("trusted"); + }); + + it("stops recursive forwarding after an owner-route middleware rewrite", async () => { + const loopActionId = findActionId(builtSource, "$$hoist_0_loopAction"); + const response = await handler(actionRequest("/", loopActionId, [])); + expect([200, 404]).toContain(response.status); + expect(await response.text()).not.toContain("LOOP_ACTION_EXECUTED"); + }); +}); diff --git a/tests/app-action-forwarding.test.ts b/tests/app-action-forwarding.test.ts new file mode 100644 index 0000000000..28c9628f5b --- /dev/null +++ b/tests/app-action-forwarding.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { forwardServerActionIfNeeded } from "../packages/vinext/src/server/app-action-forwarding.js"; +import { ACTION_FORWARDED_HEADER } from "../packages/vinext/src/server/headers.js"; +import { + MIDDLEWARE_OVERRIDE_HEADERS, + MIDDLEWARE_REQUEST_HEADER_PREFIX, +} from "../packages/vinext/src/utils/protocol-headers.js"; + +function request(headers?: HeadersInit): Request { + return new Request("https://example.com/source?query=discarded", { + body: "payload", + headers, + method: "POST", + }); +} + +function options(overrides: Record = {}) { + return { + actionId: "action-key#submit", + actionOwners: { "action-key#submit": ["/teams/:team/actions"] }, + basePath: "/base", + clearRequestContext() {}, + currentRoutePattern: "/source", + async dispatch() { + return new Response("flight", { + headers: { "content-type": "text/x-component" }, + }); + }, + middlewareContext: { headers: null, requestHeaders: null, status: null }, + request: request(), + ...overrides, + }; +} + +describe("server action forwarding", () => { + it("does nothing when ownership is unavailable or the current route owns the action", async () => { + expect(await forwardServerActionIfNeeded(options({ actionOwners: null }))).toBeNull(); + expect( + await forwardServerActionIfNeeded(options({ currentRoutePattern: "/teams/:team/actions" })), + ).toBeNull(); + }); + + it("forwards to the first owner through the recursive app handler", async () => { + let forwardedRequest: Request | null = null; + const response = await forwardServerActionIfNeeded( + options({ + async dispatch(nextRequest: Request) { + forwardedRequest = nextRequest; + return new Response("flight", { + headers: { + "content-encoding": "gzip", + "content-type": "text/x-component", + "set-cookie": "ignored=1", + "x-result": "ok", + }, + }); + }, + }), + ); + + expect(forwardedRequest).not.toBeNull(); + const capturedRequest = forwardedRequest as unknown as Request; + expect(capturedRequest.url).toBe("https://example.com/base/teams/[team]/actions"); + expect(await capturedRequest.text()).toBe("payload"); + expect(response?.headers.get("x-result")).toBe("ok"); + expect(response?.headers.has("content-encoding")).toBe(false); + expect(response?.headers.has("set-cookie")).toBe(false); + expect(await response?.text()).toBe("flight"); + }); + + it.each([ + ["/:variant.id/actions", "/base/[variant.id]/actions"], + ["/:repo:name/actions", "/base/[repo:name]/actions"], + ["/:c++lang/actions", "/base/[c++lang]/actions"], + ["/docs/:slug+", "/base/docs/[...slug]"], + ["/docs/:slug*", "/base/docs/[[...slug]]"], + ])("preserves supported dynamic owner pattern %s", async (ownerPattern, expectedPathname) => { + let forwardedPathname = ""; + await forwardServerActionIfNeeded( + options({ + actionOwners: { "action-key#submit": [ownerPattern] }, + async dispatch(nextRequest: Request) { + forwardedPathname = new URL(nextRequest.url).pathname; + return new Response("flight", { + headers: { "content-type": "text/x-component" }, + }); + }, + }), + ); + + expect(forwardedPathname).toBe(expectedPathname); + }); + + it("applies middleware request overrides and response cookies", async () => { + const middlewareRequestHeaders = new Headers({ + [MIDDLEWARE_OVERRIDE_HEADERS]: "x-role", + [`${MIDDLEWARE_REQUEST_HEADER_PREFIX}x-role`]: "admin", + }); + const middlewareHeaders = new Headers({ + "set-cookie": "session=rotated; Path=/", + "x-response": "kept", + }); + let forwardedHeaders: Headers | null = null; + + await forwardServerActionIfNeeded( + options({ + async dispatch(nextRequest: Request) { + forwardedHeaders = nextRequest.headers; + return new Response("flight", { + headers: { "content-type": "text/x-component" }, + }); + }, + middlewareContext: { + headers: middlewareHeaders, + requestHeaders: middlewareRequestHeaders, + status: null, + }, + request: request({ authorization: "Bearer token", cookie: "session=old; theme=dark" }), + }), + ); + + expect(forwardedHeaders).not.toBeNull(); + const capturedHeaders = forwardedHeaders as unknown as Headers; + expect(capturedHeaders.get("authorization")).toBe("Bearer token"); + expect(capturedHeaders.get("cookie")).toContain("session=rotated"); + expect(capturedHeaders.get("cookie")).toContain("theme=dark"); + expect(capturedHeaders.get("x-role")).toBe("admin"); + expect(capturedHeaders.get("x-response")).toBe("kept"); + expect(capturedHeaders.has("set-cookie")).toBe(false); + }); + + it("fails closed for unknown actions and forwarding loops", async () => { + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + const clearRequestContext = vi.fn(); + const unknown = await forwardServerActionIfNeeded( + options({ actionId: "unknown#action", clearRequestContext }), + ); + const loop = await forwardServerActionIfNeeded( + options({ + clearRequestContext, + request: request({ [ACTION_FORWARDED_HEADER]: "1" }), + }), + ); + + expect(unknown?.status).toBe(404); + expect(loop?.status).toBe(404); + expect(clearRequestContext).toHaveBeenCalledTimes(2); + expect(warning).toHaveBeenCalledTimes(2); + warning.mockRestore(); + }); + + it.each(["constructor#x", "__proto__#x", "prototype#x"])( + "fails closed for prototype-shaped action id %s", + async (actionId) => { + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + const response = await forwardServerActionIfNeeded(options({ actionId })); + + expect(response?.status).toBe(404); + warning.mockRestore(); + }, + ); + + it("normalizes failed and non-flight forwards to an empty action response", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const clearRequestContext = vi.fn(); + const failed = await forwardServerActionIfNeeded( + options({ + clearRequestContext, + async dispatch() { + throw new Error("offline"); + }, + }), + ); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("html")); + }, + cancel() {}, + }); + const nonFlight = await forwardServerActionIfNeeded( + options({ + async dispatch() { + return new Response(body, { headers: { "content-type": "text/html" } }); + }, + }), + ); + + expect(await failed?.json()).toEqual({}); + expect(await nonFlight?.json()).toEqual({}); + expect(clearRequestContext).toHaveBeenCalledOnce(); + expect(error).toHaveBeenCalledOnce(); + error.mockRestore(); + }); +}); diff --git a/tests/app-server-action-execution.test.ts b/tests/app-server-action-execution.test.ts index cb969a2fa3..3c6e2dd5cb 100644 --- a/tests/app-server-action-execution.test.ts +++ b/tests/app-server-action-execution.test.ts @@ -1459,8 +1459,20 @@ describe("app server action execution helpers", () => { it("renders same-origin action redirects as a single-pass Flight response", async () => { // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + const buildPageElement = vi.fn(); + const dispatchRedirectRequest = vi.fn(async (request: Request) => { + expect(request.method).toBe("GET"); + expect(new URL(request.url).pathname).toBe("/redirect-target"); + expect(request.headers.get("x-rsc-action")).toBeNull(); + expect(request.headers.get("rsc")).toBe("1"); + return new Response(JSON.stringify({ root: "redirect-target:{}:none" }), { + headers: { "content-type": "text/x-component" }, + }); + }); const response = await handleServerActionRscRequest( createRscOptions({ + buildPageElement, + dispatchRedirectRequest, loadServerAction() { return Promise.resolve(() => redirect("/redirect-target")); }, @@ -1481,10 +1493,35 @@ describe("app server action execution helpers", () => { expect(response?.status).toBe(303); expect(response?.headers.get("x-action-redirect")).toBe("/redirect-target"); - expect(JSON.parse(await response!.text())).toEqual({ - root: "redirect-target:{}:none", - returnValue: { ok: true }, - }); + expect(await response!.text()).toBe(JSON.stringify({ root: "redirect-target:{}:none" })); + expect(dispatchRedirectRequest).toHaveBeenCalledOnce(); + expect(buildPageElement).not.toHaveBeenCalled(); + }); + + it("does not inline middleware-blocked action redirect targets", async () => { + // Ported from Next.js: packages/next/src/server/app-render/action-handler.ts + // createRedirectRenderResult() re-enters the full request pipeline for the target URL. + const buildPageElement = vi.fn(); + const response = await handleServerActionRscRequest( + createRscOptions({ + buildPageElement, + dispatchRedirectRequest: vi.fn(async () => new Response("unauthorized", { status: 401 })), + loadServerAction() { + return Promise.resolve(() => redirect("/admin/secret")); + }, + matchRoute(pathname) { + return { + params: {}, + route: { id: pathname, page: {}, params: [], pattern: pathname }, + }; + }, + }), + ); + + expect(response?.status).toBe(303); + expect(response?.headers.get("x-action-redirect")).toBe("/admin/secret"); + expect(await response!.text()).toBe(""); + expect(buildPageElement).not.toHaveBeenCalled(); }); it.each(["rerender", "redirect"] as const)( diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 3832411954..1820a5e458 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -851,7 +851,8 @@ describe("App Router entry templates", () => { const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false); expect(code).toContain('import { createAppRscHandler } from "vinext/server/app-rsc-handler";'); - expect(code).toContain("export default createAppRscHandler({"); + expect(code).toContain("const __appRscHandler = createAppRscHandler({"); + expect(code).toContain("export default __appRscHandler;"); expect(code).not.toContain("computeRscCacheBustingSearchParam("); }); @@ -972,6 +973,16 @@ describe("App Router entry templates", () => { ); }); + it("generateRscEntry delegates action forwarding to the typed runtime", () => { + const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false); + + expect(code).toContain("forwardServerActionIfNeeded as __forwardServerActionIfNeeded"); + expect(code).toContain("await __forwardServerActionIfNeeded({"); + expect(code).not.toContain("next/dist/server/web/spec-extension/cookies"); + expect(code).not.toContain("function __mergeActionForwardCookies"); + expect(code).not.toContain("const __ACTION_FORWARD_FORBIDDEN_HEADERS"); + }); + it("generateRscEntry omits server action imports when no server references were found", () => { const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false, { hasServerActions: false, From 0c0705502f72a57178a5f79237e2e12257625b4c Mon Sep 17 00:00:00 2001 From: James Date: Sat, 4 Jul 2026 13:19:25 +0100 Subject: [PATCH 02/17] perf(app-router): cache action ownership discovery --- .../vinext/src/build/action-owner-manifest.ts | 69 +++++++++++-------- packages/vinext/src/entries/app-rsc-entry.ts | 6 +- packages/vinext/src/index.ts | 24 ++++--- tests/entry-templates.test.ts | 4 +- 4 files changed, 60 insertions(+), 43 deletions(-) diff --git a/packages/vinext/src/build/action-owner-manifest.ts b/packages/vinext/src/build/action-owner-manifest.ts index 0efdd0469f..b1da03e16b 100644 --- a/packages/vinext/src/build/action-owner-manifest.ts +++ b/packages/vinext/src/build/action-owner-manifest.ts @@ -341,45 +341,61 @@ export async function buildStaticActionOwnerManifest(options: { resolve: (source: string, importer?: string) => Promise<{ id: string } | null>; }): Promise> { const manifest: Record = {}; + const modulePrograms = new Map>(); + const resolvedDependencies = new Map>(); - for (const route of options.routes) { - const entryIds = actionOwnerRouteEntryIds(route); - const entrySet = new Set(entryIds); - const queue = entryIds.map((id) => ({ id, names: null as string[] | null })); - const visited = new Set(); - - for (let index = 0; index < queue.length; index++) { - const current = queue[index]!; - const visitKey = `${current.id}\0${current.names?.join(",") ?? "*"}`; - if (visited.has(visitKey)) continue; - visited.add(visitKey); - + function loadProgram(id: string): Promise { + let pending = modulePrograms.get(id); + if (pending) return pending; + pending = (async () => { let source: string; try { - source = await fs.readFile(current.id, "utf8"); + source = await fs.readFile(id, "utf8"); } catch { - continue; + return null; } - const extension = path.extname(current.id).toLowerCase(); + const extension = path.extname(id).toLowerCase(); try { source = ( - await transformWithOxc(source, current.id, { + await transformWithOxc(source, id, { lang: extension === ".ts" ? "ts" : extension === ".tsx" ? "tsx" : "jsx", jsx: { runtime: "automatic" }, sourcemap: false, }) ).code; + return (await parseAstAsync(source)) as unknown as AstNode; } catch { - continue; + return null; } + })(); + modulePrograms.set(id, pending); + return pending; + } - let program: AstNode; - try { - program = (await parseAstAsync(source)) as unknown as AstNode; - } catch { - continue; - } + function resolveDependency(specifier: string, importer: string): Promise<{ id: string } | null> { + const key = `${importer}\0${specifier}`; + let pending = resolvedDependencies.get(key); + if (pending) return pending; + pending = options.resolve(specifier, importer).catch(() => null); + resolvedDependencies.set(key, pending); + return pending; + } + + for (const route of options.routes) { + const entryIds = actionOwnerRouteEntryIds(route); + const entrySet = new Set(entryIds); + const queue = entryIds.map((id) => ({ id, names: null as string[] | null })); + const visited = new Set(); + + for (let index = 0; index < queue.length; index++) { + const current = queue[index]!; + const visitKey = `${current.id}\0${current.names?.join(",") ?? "*"}`; + if (visited.has(visitKey)) continue; + visited.add(visitKey); + + const program = await loadProgram(current.id); + if (!program) continue; const moduleReferenceKey = referenceKey(options.root, current.id, options.mode); if (entrySet.has(current.id)) addOwner(manifest, moduleReferenceKey, route.pattern); @@ -394,12 +410,7 @@ export async function buildStaticActionOwnerManifest(options: { const includeImports = !current.id.includes(`${path.sep}node_modules${path.sep}`); for (const dependency of sourceDependencies(program, current.names, includeImports)) { if (dependency.names?.length === 0) continue; - let resolved: { id: string } | null; - try { - resolved = await options.resolve(dependency.specifier, current.id); - } catch { - continue; - } + const resolved = await resolveDependency(dependency.specifier, current.id); if (!resolved || resolved.id.includes("?")) continue; queue.push({ id: resolved.id, names: dependency.names }); } diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index b342f9f2af..92f63ef12d 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -324,7 +324,6 @@ import { ensureInstrumentationRegistered as __ensureInstrumentationRegistered } : "" } import { createAppRscHandler } from "vinext/server/app-rsc-handler"; -import { forwardServerActionIfNeeded as __forwardServerActionIfNeeded } from ${JSON.stringify(appActionForwardingPath)}; import { registerConfiguredCacheAdapters as __registerConfiguredCacheAdapters } from "virtual:vinext-cache-adapters"; import __pagesClientAssets from "virtual:vinext-pages-client-assets"; import { setPagesClientAssets as __setPagesClientAssets } from "vinext/server/pages-client-assets"; @@ -340,7 +339,8 @@ ${ const __loadAppRouteHandlerDispatch = () => import(${JSON.stringify(appRouteHandlerDispatchPath)}); ${ hasServerActions - ? `const __loadAppServerActionExecution = () => import(${JSON.stringify(appServerActionExecutionPath)});` + ? `const __loadAppServerActionExecution = () => import(${JSON.stringify(appServerActionExecutionPath)}); +const __loadAppActionForwarding = () => import(${JSON.stringify(appActionForwardingPath)});` : "" } ${ @@ -1057,6 +1057,8 @@ const __appRscHandler = createAppRscHandler({ request, searchParams, }) { + const { forwardServerActionIfNeeded: __forwardServerActionIfNeeded } = + await __loadAppActionForwarding(); const __currentActionMatch = matchRoute(cleanPathname); const __forwardResponse = await __forwardServerActionIfNeeded({ actionId, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 4ae200f58f..84e12842f6 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3493,17 +3493,19 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // indices in the manifest correspond 1:1 to the route.layouts arrays // used during codegen. renderChunk clears this after patching. rscClassificationManifest = collectRouteClassificationManifest(routes); - const actionOwners = await buildStaticActionOwnerManifest({ - mode: isDevelopmentServe ? "development" : "production", - root, - routes, - resolve: async (source, importer) => { - const resolved = await this.resolve(source, importer); - return resolved ? { id: resolved.id } : null; - }, - }); - rscActionOwnerRoutes = isDevelopmentServe ? null : routes; - rscStaticActionOwners = isDevelopmentServe ? null : actionOwners; + const actionOwners = hasServerActions + ? await buildStaticActionOwnerManifest({ + mode: isDevelopmentServe ? "development" : "production", + root, + routes, + resolve: async (source, importer) => { + const resolved = await this.resolve(source, importer); + return resolved ? { id: resolved.id } : null; + }, + }) + : {}; + rscActionOwnerRoutes = isDevelopmentServe || !hasServerActions ? null : routes; + rscStaticActionOwners = isDevelopmentServe || !hasServerActions ? null : actionOwners; return generateRscEntry( appDir, routes, diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 1820a5e458..932db7205d 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -976,7 +976,8 @@ describe("App Router entry templates", () => { it("generateRscEntry delegates action forwarding to the typed runtime", () => { const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false); - expect(code).toContain("forwardServerActionIfNeeded as __forwardServerActionIfNeeded"); + expect(code).toContain("const __loadAppActionForwarding = () => import("); + expect(code).toContain("forwardServerActionIfNeeded: __forwardServerActionIfNeeded"); expect(code).toContain("await __forwardServerActionIfNeeded({"); expect(code).not.toContain("next/dist/server/web/spec-extension/cookies"); expect(code).not.toContain("function __mergeActionForwardCookies"); @@ -997,6 +998,7 @@ describe("App Router entry templates", () => { expect(code).not.toContain("createTemporaryReferenceSet,"); expect(code).not.toContain("handleProgressiveActionRequest({"); expect(code).not.toContain("handleServerActionRequest({"); + expect(code).not.toContain("app-action-forwarding.js"); }); it("generateRscEntry passes parallel route segment config into App page dispatch", () => { From c90145a7997feacecef693108ce398d33796c420 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 4 Jul 2026 13:23:20 +0100 Subject: [PATCH 03/17] fix(app-router): retain client-bound action owners --- packages/vinext/src/build/action-owner-manifest.ts | 9 +++------ tests/action-owner-manifest.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/vinext/src/build/action-owner-manifest.ts b/packages/vinext/src/build/action-owner-manifest.ts index b1da03e16b..3218a2e033 100644 --- a/packages/vinext/src/build/action-owner-manifest.ts +++ b/packages/vinext/src/build/action-owner-manifest.ts @@ -301,16 +301,13 @@ export function buildActionOwnerManifest(options: { for (const reference of options.serverReferences) { const referenceId = canonicalizeModuleId(reference.importId); - if (!reachableIds.has(referenceId)) continue; - if (routeComponentIds.has(referenceId)) { + const isRouteComponent = routeComponentIds.has(referenceId); + if (isRouteComponent) { addOwner(manifest, reference.referenceKey, route.pattern); } for (const exportName of reference.exportNames) { const actionId = `${reference.referenceKey}#${exportName}`; - if ( - routeComponentIds.has(referenceId) || - options.staticOwners[actionId]?.includes(route.pattern) - ) { + if (isRouteComponent || options.staticOwners[actionId]?.includes(route.pattern)) { addOwner(manifest, actionId, route.pattern); } } diff --git a/tests/action-owner-manifest.test.ts b/tests/action-owner-manifest.test.ts index c7d9f9d1f0..be968f0de1 100644 --- a/tests/action-owner-manifest.test.ts +++ b/tests/action-owner-manifest.test.ts @@ -266,7 +266,7 @@ export default function Page() { return example + template; }`, }); }); - it("requires imported actions to exist in the final runtime graph", () => { + it("accepts exact plugin references imported through client component boundaries", () => { expect( buildActionOwnerManifest({ moduleInfo: { @@ -284,7 +284,7 @@ export default function Page() { return example + template; }`, ], staticOwners: { "admin-key#adminOnly": ["/"] }, }), - ).toEqual({}); + ).toEqual({ "admin-key#adminOnly": ["/"] }); }); it("accepts actions reachable through the final dynamic import graph", () => { From f698182ca677d05538b954773c9249647851a92e Mon Sep 17 00:00:00 2001 From: James Date: Sat, 4 Jul 2026 14:06:44 +0100 Subject: [PATCH 04/17] fix(app-router): harden action owner forwarding --- packages/vinext/package.json | 8 ++ .../vinext/src/build/action-owner-manifest.ts | 89 ++++++++++++---- .../src/client/app-nav-failure-handler.ts | 3 + packages/vinext/src/entries/app-rsc-entry.ts | 29 ++--- packages/vinext/src/index.ts | 57 ++++++---- .../src/server/app-action-forwarding.ts | 43 +++++++- .../vinext/src/server/app-browser-entry.ts | 5 + .../app-browser-server-action-client.ts | 5 +- packages/vinext/src/server/app-rsc-handler.ts | 18 ++-- .../src/server/app-server-action-execution.ts | 16 ++- tests/action-owner-manifest.test.ts | 100 ++++++++++++++++++ tests/app-action-forwarding.test.ts | 61 ++++++++++- .../app-browser-server-action-client.test.ts | 77 ++++++++++++++ tests/app-router-isr-codegen.test.ts | 3 +- tests/app-router-next-config-codegen.test.ts | 3 +- tests/app-server-action-execution.test.ts | 5 +- tests/e2e/app-router/server-actions.spec.ts | 13 +-- tests/fixtures/app-basic/app/actions/page.tsx | 4 + 18 files changed, 459 insertions(+), 80 deletions(-) diff --git a/packages/vinext/package.json b/packages/vinext/package.json index 396d9769e4..e7324e91bd 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -109,6 +109,14 @@ "types": "./dist/server/app-rsc-handler.d.ts", "import": "./dist/server/app-rsc-handler.js" }, + "./internal/server/app-action-forwarding": { + "types": "./dist/server/app-action-forwarding.d.ts", + "import": "./dist/server/app-action-forwarding.js" + }, + "./internal/server/app-server-action-execution": { + "types": "./dist/server/app-server-action-execution.d.ts", + "import": "./dist/server/app-server-action-execution.js" + }, "./config/config-matchers": { "types": "./dist/config/config-matchers.d.ts", "import": "./dist/config/config-matchers.js" diff --git a/packages/vinext/src/build/action-owner-manifest.ts b/packages/vinext/src/build/action-owner-manifest.ts index 3218a2e033..5b746be929 100644 --- a/packages/vinext/src/build/action-owner-manifest.ts +++ b/packages/vinext/src/build/action-owner-manifest.ts @@ -184,6 +184,54 @@ function importedNames(specifiers: readonly AstNode[]): string[] | null { return [...names]; } +function importedNamesForLocalBindings( + specifiers: readonly AstNode[], + localBindings: ReadonlySet, +): string[] | null { + const names = new Set(); + for (const specifier of specifiers) { + const localName = identifierName(specifier.local); + if (!localName || !localBindings.has(localName)) continue; + if (specifier.type === "ImportNamespaceSpecifier") return null; + if (specifier.type === "ImportDefaultSpecifier") names.add("default"); + if (specifier.type === "ImportSpecifier") { + const name = identifierName(specifier.imported); + if (name) names.add(name); + } + } + return [...names]; +} + +function requestedLocalExportBindings(program: AstNode, requestedNames: string[]): Set { + const requested = new Set(requestedNames); + const bindings = new Set(); + for (const statement of (program.body as AstNode[] | undefined) ?? []) { + if (statement.type === "ExportDefaultDeclaration") { + const localName = identifierName(statement.declaration); + if (requested.has("default") && localName) bindings.add(localName); + continue; + } + if (statement.type !== "ExportNamedDeclaration" || statement.source) continue; + const declaration = statement.declaration as AstNode | undefined; + if (declaration?.type === "VariableDeclaration") { + for (const declarator of (declaration.declarations as AstNode[] | undefined) ?? []) { + const exportedName = identifierName(declarator.id); + const localName = identifierName(declarator.init); + if (exportedName && localName && requested.has(exportedName)) bindings.add(localName); + } + } else { + const exportedName = identifierName(declaration?.id); + if (exportedName && requested.has(exportedName)) bindings.add(exportedName); + } + for (const specifier of (statement.specifiers as AstNode[] | undefined) ?? []) { + const exportedName = identifierName(specifier.exported); + const localName = identifierName(specifier.local); + if (exportedName && localName && requested.has(exportedName)) bindings.add(localName); + } + } + return bindings; +} + function reexportedNames( specifiers: readonly AstNode[], requestedNames: string[] | null, @@ -222,14 +270,25 @@ function sourceDependencies( program: AstNode, requestedNames: string[] | null, includeImports: boolean, + narrowImports: boolean, ): SourceDependency[] { const dependencies: SourceDependency[] = []; + const requestedImportBindings = + includeImports && narrowImports && requestedNames !== null + ? requestedLocalExportBindings(program, requestedNames) + : null; for (const statement of (program.body as AstNode[] | undefined) ?? []) { if (includeImports && statement.type === "ImportDeclaration") { const specifier = literalString(statement.source); if (specifier) { + const names = requestedImportBindings + ? importedNamesForLocalBindings( + (statement.specifiers as AstNode[] | undefined) ?? [], + requestedImportBindings, + ) + : importedNames((statement.specifiers as AstNode[] | undefined) ?? []); dependencies.push({ - names: importedNames((statement.specifiers as AstNode[] | undefined) ?? []), + names, specifier, }); } @@ -261,7 +320,9 @@ function sourceDependencies( } continue; } - if (includeImports) collectImportExpressions(statement, dependencies); + if (includeImports && (!narrowImports || requestedNames === null)) { + collectImportExpressions(statement, dependencies); + } } return dependencies; } @@ -284,20 +345,6 @@ export function buildActionOwnerManifest(options: { for (const route of options.routes) { const entryIds = actionOwnerRouteEntryIds(route); const routeComponentIds = new Set(entryIds.map(canonicalizeModuleId)); - const reachableIds = new Set(routeComponentIds); - const queue = [...routeComponentIds]; - for (let index = 0; index < queue.length; index++) { - const moduleInfo = options.moduleInfo.getModuleInfo(queue[index]!); - for (const importedId of [ - ...(moduleInfo?.importedIds ?? []), - ...(moduleInfo?.dynamicImportedIds ?? []), - ]) { - const canonicalImportedId = canonicalizeModuleId(importedId); - if (reachableIds.has(canonicalImportedId)) continue; - reachableIds.add(canonicalImportedId); - queue.push(canonicalImportedId); - } - } for (const reference of options.serverReferences) { const referenceId = canonicalizeModuleId(reference.importId); @@ -404,8 +451,14 @@ export async function buildStaticActionOwnerManifest(options: { continue; } - const includeImports = !current.id.includes(`${path.sep}node_modules${path.sep}`); - for (const dependency of sourceDependencies(program, current.names, includeImports)) { + const isPackageModule = current.id.includes(`${path.sep}node_modules${path.sep}`); + const includeImports = !isPackageModule || current.names !== null; + for (const dependency of sourceDependencies( + program, + current.names, + includeImports, + isPackageModule, + )) { if (dependency.names?.length === 0) continue; const resolved = await resolveDependency(dependency.specifier, current.id); if (!resolved || resolved.id.includes("?")) continue; diff --git a/packages/vinext/src/client/app-nav-failure-handler.ts b/packages/vinext/src/client/app-nav-failure-handler.ts index 3fb67c7875..728e5a2e9e 100644 --- a/packages/vinext/src/client/app-nav-failure-handler.ts +++ b/packages/vinext/src/client/app-nav-failure-handler.ts @@ -31,6 +31,9 @@ export function clearAppNavigationFailureTarget(target?: string | URL): void { export function handleAppNavigationFailure(error: unknown): boolean { if (!process.env.__NEXT_APP_NAV_FAIL_HANDLING || typeof window === "undefined") return false; + if (error !== null && typeof error === "object" && "handled" in error && error.handled === true) { + return false; + } const pendingUrl = getPendingUrl(); if (pendingUrl === null || pendingUrl.href === window.location.href) return false; console.error("Error occurred during navigation, falling back to hard navigation", error); diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 92f63ef12d..6882d3aeae 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -49,14 +49,6 @@ const metadataRouteResponsePath = resolveEntryPath( "../server/metadata-route-response.js", import.meta.url, ); -const appServerActionExecutionPath = resolveEntryPath( - "../server/app-server-action-execution.js", - import.meta.url, -); -const appActionForwardingPath = resolveEntryPath( - "../server/app-action-forwarding.js", - import.meta.url, -); const appRscErrorsPath = resolveEntryPath("../server/app-rsc-errors.js", import.meta.url); const appPageExecutionPath = resolveEntryPath("../server/app-page-execution.js", import.meta.url); const appFallbackRendererPath = resolveEntryPath( @@ -309,6 +301,7 @@ const prerenderToReadableStream = createRscPrerenderer(async (model, options) => import { createElement } from "react"; import { getNavigationContext as _getNavigationContext } from "next/navigation"; import { configureMemoryCacheHandler as __configureMemoryCacheHandler } from "vinext/shims/cache-handler"; +import { getRequestExecutionContext as __getRequestExecutionContext } from "vinext/shims/request-context"; import { headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, consumeInvalidDynamicUsageError, setHeadersAccessPhase } from "next/headers"; import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; ${ @@ -339,8 +332,8 @@ ${ const __loadAppRouteHandlerDispatch = () => import(${JSON.stringify(appRouteHandlerDispatchPath)}); ${ hasServerActions - ? `const __loadAppServerActionExecution = () => import(${JSON.stringify(appServerActionExecutionPath)}); -const __loadAppActionForwarding = () => import(${JSON.stringify(appActionForwardingPath)});` + ? `const __loadAppServerActionExecution = () => import("vinext/internal/server/app-server-action-execution"); +const __loadAppActionForwarding = () => import("vinext/internal/server/app-action-forwarding");` : "" } ${ @@ -1063,11 +1056,23 @@ const __appRscHandler = createAppRscHandler({ const __forwardResponse = await __forwardServerActionIfNeeded({ actionId, actionOwners: __VINEXT_ACTION_OWNERS(), + allowedOrigins: __allowedOrigins, basePath: __basePath, clearRequestContext: __clearRequestContext, currentRoutePattern: __currentActionMatch?.route.pattern ?? null, dispatch(__forwardRequest) { - return __appRscHandler(__forwardRequest, { actionForwarded: true }); + const __executionContext = __getRequestExecutionContext(); + return __appRscHandler( + __forwardRequest, + __executionContext + ? { + actionForwarded: true, + cache: __executionContext.cache, + passThroughOnException: __executionContext.passThroughOnException?.bind(__executionContext), + waitUntil: __executionContext.waitUntil.bind(__executionContext), + } + : { actionForwarded: true }, + ); }, middlewareContext, request, @@ -1078,7 +1083,7 @@ const __appRscHandler = createAppRscHandler({ readActionBodyWithLimit: __readBodyWithLimit, readActionFormDataWithLimit: __readFormDataWithLimit, } = await __loadAppServerActionExecution(); - const __actionMatch = matchRoute(cleanPathname); + const __actionMatch = __currentActionMatch; if (__actionMatch) await __ensureRouteLoaded(__actionMatch.route); const __actionIsEdgeRuntime = __actionMatch ? __isEdgeRuntime(__resolveRouteRuntime(__actionMatch.route)) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 84e12842f6..fc3bd7f8e6 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1002,6 +1002,11 @@ const _appBrowserServerActionClientPath = resolveShimModulePath( "app-browser-server-action-client", ); const _appRscHandlerPath = resolveShimModulePath(_serverDir, "app-rsc-handler"); +const _appActionForwardingPath = resolveShimModulePath(_serverDir, "app-action-forwarding"); +const _appServerActionExecutionPath = resolveShimModulePath( + _serverDir, + "app-server-action-execution", +); const _pagesClientAssetsPath = resolveShimModulePath(_serverDir, "pages-client-assets"); // Source checkouts resolve to TypeScript and must stay in Vite's graph so tests // do not execute a stale dist build. Published packages resolve to emitted JS, @@ -3322,7 +3327,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // direct @vercel/og imports in metadata routes, and \0-prefixed // re-imports from @vitejs/plugin-rsc. filter: { - id: /(?:next\/|vinext\/(?:shims\/|server\/app-rsc-handler)|virtual:vinext-|@vercel\/og(?:\.js)?$)/, + id: /(?:next\/|vinext\/(?:shims\/|server\/app-rsc-handler|internal\/server\/(?:app-action-forwarding|app-server-action-execution))|virtual:vinext-|@vercel\/og(?:\.js)?$)/, }, handler(id, importer) { // Strip \0 prefix if present — @vitejs/plugin-rsc's generated @@ -3349,6 +3354,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return _appRscHandlerPath; } + if (cleanId === "vinext/internal/server/app-action-forwarding") { + return _appActionForwardingPath; + } + + if (cleanId === "vinext/internal/server/app-server-action-execution") { + return _appServerActionExecutionPath; + } + if (isVercelOgImport(cleanId) && !isVinextOgShimImporter(importer)) { return resolveShimModulePath(_shimsDir, "og"); } @@ -3646,12 +3659,11 @@ export const loadServerActionClient = ${ // pulling ModuleInfo from the wrong graph would give nonsense // results. if (this.environment?.name !== "rsc") return null; - if (!rscClassificationManifest) return null; - // Cheap pre-filter: skip chunks that don't mention the stub at all - // (e.g. the scan-phase chunk and every non-entry chunk). - if (!code.includes("__VINEXT_CLASS")) { - return null; - } + const hasClassificationStub = + rscClassificationManifest !== null && code.includes("__VINEXT_CLASS"); + const hasActionOwnerStub = + rscActionOwnerRoutes !== null && code.includes("__VINEXT_ACTION_OWNERS_STUB__"); + if (!hasClassificationStub && !hasActionOwnerStub) return null; // Patching per-chunk (rather than scanning the whole bundle in // generateBundle) assumes the stub body and its per-route call sites @@ -3685,19 +3697,20 @@ export const loadServerActionClient = ${ }, }; - const patchPlan = rscClassificationManifest - ? planRouteClassificationInjection({ - canonicalizeLayoutPath: canonicalize, - chunks: [{ code, fileName: chunk.fileName }], - dynamicShimPaths, - enableDebugReasons: enableClassificationDebug, - manifest: rscClassificationManifest, - moduleInfo, - }) - : { kind: "skip" as const }; + const patchPlan = + hasClassificationStub && rscClassificationManifest + ? planRouteClassificationInjection({ + canonicalizeLayoutPath: canonicalize, + chunks: [{ code, fileName: chunk.fileName }], + dynamicShimPaths, + enableDebugReasons: enableClassificationDebug, + manifest: rscClassificationManifest, + moduleInfo, + }) + : { kind: "skip" as const }; let nextCode = patchPlan.kind === "skip" ? code : patchPlan.code; - if (rscActionOwnerRoutes && nextCode.includes("__VINEXT_ACTION_OWNERS_STUB__")) { + if (hasActionOwnerStub && rscActionOwnerRoutes) { const rscModule = await rscPluginModulePromise; const pluginApi = rscModule?.getPluginApi(this.environment.config); const serverReferences = Object.values(pluginApi?.manager.serverReferenceMetaMap ?? {}); @@ -3728,6 +3741,14 @@ export const loadServerActionClient = ${ return { code: nextCode, map: null }; }, }, + generateBundle(_options, bundle) { + if (this.environment?.name !== "rsc") return; + for (const output of Object.values(bundle)) { + if (output.type === "chunk" && output.code.includes("__VINEXT_ACTION_OWNERS_STUB__")) { + this.error("vinext: server action owner manifest was not injected into the RSC entry"); + } + } + }, }, { name: "vinext:pages-client-assets-resolver", diff --git a/packages/vinext/src/server/app-action-forwarding.ts b/packages/vinext/src/server/app-action-forwarding.ts index 9f809ac264..2eb92f0cee 100644 --- a/packages/vinext/src/server/app-action-forwarding.ts +++ b/packages/vinext/src/server/app-action-forwarding.ts @@ -2,11 +2,13 @@ import { RequestCookies, ResponseCookies } from "vinext/shims/internal/cookies"; import { patternToNextFormat } from "../routing/route-validation.js"; import { buildRequestHeadersFromMiddlewareResponse } from "../utils/middleware-request-headers.js"; import type { AppMiddlewareContext } from "./app-middleware.js"; +import { getSetCookieName } from "./cookie-utils.js"; import { ACTION_FORWARDED_HEADER } from "./headers.js"; import { createServerActionNotFoundResponse, getServerActionNotFoundMessage, } from "./server-action-not-found.js"; +import { validateCsrfOrigin } from "./request-pipeline.js"; const ACTION_FORWARD_FORBIDDEN_HEADERS = new Set([ "accept-encoding", @@ -25,6 +27,7 @@ type ActionOwnerManifest = Readonly>; type ForwardServerActionOptions = { actionId: string; actionOwners: ActionOwnerManifest | null; + allowedOrigins: string[]; basePath: string; clearRequestContext: () => void; currentRoutePattern: string | null; @@ -33,12 +36,39 @@ type ForwardServerActionOptions = { request: Request; }; +function deletesCookie(setCookie: string, now: number): boolean { + for (const attribute of setCookie.split(";").slice(1)) { + const equalsIndex = attribute.indexOf("="); + if (equalsIndex === -1) continue; + const name = attribute.slice(0, equalsIndex).trim().toLowerCase(); + const value = attribute.slice(equalsIndex + 1).trim(); + if (name === "max-age") { + const maxAge = Number(value); + if (Number.isFinite(maxAge) && maxAge <= 0) return true; + } + if (name === "expires") { + const expiresAt = Date.parse(value); + if (Number.isFinite(expiresAt) && expiresAt <= now) return true; + } + } + return false; +} + function mergeActionForwardCookies(requestHeaders: Headers, responseHeaders: Headers): void { const requestCookies = new RequestCookies(requestHeaders); + const deletedCookieNames = new Set(); + const now = Date.now(); + for (const setCookie of responseHeaders.getSetCookie()) { + const name = getSetCookieName(setCookie); + if (!name) continue; + if (deletesCookie(setCookie, now)) { + deletedCookieNames.add(name); + requestCookies.delete(name); + } + } const responseCookies = new ResponseCookies(responseHeaders); for (const cookie of responseCookies.getAll()) { - if (cookie.value === undefined) requestCookies.delete(cookie.name); - else requestCookies.set(cookie); + if (!deletedCookieNames.has(cookie.name)) requestCookies.set(cookie); } requestHeaders.set("cookie", requestCookies.toString()); } @@ -98,6 +128,9 @@ export async function forwardServerActionIfNeeded( ): Promise { if (!options.actionOwners) return null; + const csrfResponse = validateCsrfOrigin(options.request, options.allowedOrigins); + if (csrfResponse) return csrfResponse; + const ownerPatterns = actionOwnerPatterns(options.actionOwners, options.actionId); if ( ownerPatterns && @@ -108,11 +141,15 @@ export async function forwardServerActionIfNeeded( } const ownerPath = ownerPatterns?.[0]; - if (!ownerPath || options.request.headers.get(ACTION_FORWARDED_HEADER)) { + if (!ownerPath) { console.warn(getServerActionNotFoundMessage(options.actionId)); options.clearRequestContext(); return createServerActionNotFoundResponse(); } + if (options.request.headers.get(ACTION_FORWARDED_HEADER)) { + options.clearRequestContext(); + return emptyActionForwardResponse(); + } const forwardUrl = new URL(options.request.url); forwardUrl.pathname = diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 2fd20ae2d4..513dca4108 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -962,6 +962,11 @@ function AppRouterRedirectBridge({ children }: { children?: React.ReactNode }) { const error = "reason" in event ? event.reason : event.error; if (!isRedirectError(error)) return; + if ("handled" in error && error.handled === true) { + event.preventDefault(); + return; + } + const result = decodeRedirectError(error.digest); if (!result) return; diff --git a/packages/vinext/src/server/app-browser-server-action-client.ts b/packages/vinext/src/server/app-browser-server-action-client.ts index b78c91976f..a2daa9dcfe 100644 --- a/packages/vinext/src/server/app-browser-server-action-client.ts +++ b/packages/vinext/src/server/app-browser-server-action-client.ts @@ -219,9 +219,10 @@ export async function invokeClientServerAction( } if (actionRedirectTarget) { - if (isServerActionResult(result) && result.root !== undefined) { + const redirectRoot = isServerActionResult(result) ? result.root : result; + if (redirectRoot !== undefined) { deps.renderRedirectPayload( - AppElementsWire.decode(result.root), + AppElementsWire.decode(redirectRoot), actionRedirectTarget, actionInitiation, revalidation, diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 383a92e18c..776dce265b 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -351,6 +351,17 @@ function isExecutionContextLike(value: unknown): value is ExecutionContextLike { return hasProperty(value, "waitUntil") && typeof value.waitUntil === "function"; } +function isForwardedActionContext( + value: unknown, +): value is { actionForwarded: true } & Partial { + return ( + value !== null && + typeof value === "object" && + hasProperty(value, "actionForwarded") && + value.actionForwarded === true + ); +} + function createMissingServerActionResponse( options: Pick, "clearRequestContext">, actionId: string | null, @@ -1303,12 +1314,7 @@ export function createAppRscHandler( rawRequest.headers.get(VINEXT_PRERENDER_SECRET_HEADER) !== null && rawRequest.headers.get(VINEXT_PRERENDER_SPECULATIVE_HEADER) === "1"; const filteredHeaders = filterInternalHeaders(rawRequest.headers); - if ( - ctx !== null && - typeof ctx === "object" && - "actionForwarded" in ctx && - ctx.actionForwarded === true - ) { + if (isForwardedActionContext(ctx)) { filteredHeaders.set("x-action-forwarded", "1"); } if (mwCtx !== null) { diff --git a/packages/vinext/src/server/app-server-action-execution.ts b/packages/vinext/src/server/app-server-action-execution.ts index aefacdd392..cf0836ff8e 100644 --- a/packages/vinext/src/server/app-server-action-execution.ts +++ b/packages/vinext/src/server/app-server-action-execution.ts @@ -35,6 +35,7 @@ import { VINEXT_RSC_CONTENT_TYPE, VINEXT_RSC_VARY_HEADER, applyRscCompatibilityIdHeader, + createRscRequestUrl, } from "./app-rsc-cache-busting.js"; import { applyEdgeRuntimeHeader } from "./app-page-response.js"; import { resolveAppPageActionRerenderTarget } from "./app-page-request.js"; @@ -445,11 +446,11 @@ function applySetCookieMutationsToRequestCookieHeader( : [...cookies].map(([name, value]) => `${name}=${value}`).join("; "); } -function createActionRedirectRenderRequest(options: { +async function createActionRedirectRenderRequest(options: { pendingCookies: readonly string[]; request: Request; url: URL; -}): Request { +}): Promise { const headers = cloneActionRedirectHeaders(options.request.headers); const cookieHeader = applySetCookieMutationsToRequestCookieHeader( headers.get("cookie"), @@ -461,8 +462,14 @@ function createActionRedirectRenderRequest(options: { headers.set("cookie", cookieHeader); } headers.delete("next-router-state-tree"); + headers.set("rsc", "1"); + + const requestUrl = new URL(options.url); + const rscPath = await createRscRequestUrl(`${requestUrl.pathname}${requestUrl.search}`, headers); + requestUrl.pathname = rscPath.split("?", 1)[0]!; + requestUrl.search = rscPath.includes("?") ? `?${rscPath.split("?").slice(1).join("?")}` : ""; - return new Request(options.url, { + return new Request(requestUrl, { headers, method: "GET", }); @@ -1310,7 +1317,7 @@ export async function handleServerActionRscRequest< // Hydrate the current route before resolving its runtime below. if (currentMatch) await options.ensureRouteLoaded?.(currentMatch.route); - const redirectRenderRequest = createActionRedirectRenderRequest({ + const redirectRenderRequest = await createActionRedirectRenderRequest({ pendingCookies: [ ...actionPendingCookies, ...(actionDraftCookie ? [actionDraftCookie] : []), @@ -1319,7 +1326,6 @@ export async function handleServerActionRscRequest< url: redirectTarget, }); if (options.dispatchRedirectRequest) { - redirectRenderRequest.headers.set("rsc", "1"); let forwardedResponse: Response; try { forwardedResponse = await options.dispatchRedirectRequest(redirectRenderRequest); diff --git a/tests/action-owner-manifest.test.ts b/tests/action-owner-manifest.test.ts index be968f0de1..a844fbf540 100644 --- a/tests/action-owner-manifest.test.ts +++ b/tests/action-owner-manifest.test.ts @@ -197,6 +197,106 @@ describe("server action owner manifest", () => { ); }); + it("follows narrowed imports through package intermediaries in development", async () => { + const app = await fixture({ + "app/page.tsx": `import { action1 } from "server-action-wrapper"; export default function Page() { return action1; }`, + "node_modules/server-action-wrapper/index.js": `import { action1 } from "server-action-mod"; export { action1 };`, + "node_modules/server-action-wrapper/package.json": `{"name":"server-action-wrapper","type":"module"}`, + "node_modules/server-action-mod/index.js": `'use server'; export async function action1() {}`, + "node_modules/server-action-mod/package.json": `{"name":"server-action-mod","type":"module"}`, + }); + const wrapperEntry = path.join(app.root, "node_modules/server-action-wrapper/index.js"); + const actionEntry = path.join(app.root, "node_modules/server-action-mod/index.js"); + const resolve = async (specifier: string, importer?: string) => { + if (specifier === "server-action-wrapper") return { id: wrapperEntry }; + if (specifier === "server-action-mod") return { id: actionEntry }; + return app.resolve(specifier, importer); + }; + + const manifest = await buildStaticActionOwnerManifest({ + mode: "development", + root: app.root, + routes: [route("/", path.join(app.root, "app/page.tsx"))], + resolve, + }); + + expect( + manifest[`/${path.relative(app.root, actionEntry).replaceAll(path.sep, "/")}#action1`], + ).toEqual(["/"]); + }); + + it.each([ + [ + "default export", + `import action from "server-action-mod"; export default action;`, + `import action from "server-action-wrapper"; export default function Page() { return action; }`, + "default", + ], + [ + "exported declaration", + `import { action1 } from "server-action-mod"; export const wrapped = action1;`, + `import { wrapped } from "server-action-wrapper"; export default function Page() { return wrapped; }`, + "action1", + ], + ])( + "follows %s package intermediaries in development", + async (_name, wrapperSource, pageSource, exportName) => { + const app = await fixture({ + "app/page.tsx": pageSource, + "node_modules/server-action-wrapper/index.js": wrapperSource, + "node_modules/server-action-wrapper/package.json": `{"name":"server-action-wrapper","type":"module"}`, + "node_modules/server-action-mod/index.js": `'use server'; export default async function action() {} export async function action1() {}`, + "node_modules/server-action-mod/package.json": `{"name":"server-action-mod","type":"module"}`, + }); + const wrapperEntry = path.join(app.root, "node_modules/server-action-wrapper/index.js"); + const actionEntry = path.join(app.root, "node_modules/server-action-mod/index.js"); + const resolve = async (specifier: string, importer?: string) => { + if (specifier === "server-action-wrapper") return { id: wrapperEntry }; + if (specifier === "server-action-mod") return { id: actionEntry }; + return app.resolve(specifier, importer); + }; + + const manifest = await buildStaticActionOwnerManifest({ + mode: "development", + root: app.root, + routes: [route("/", path.join(app.root, "app/page.tsx"))], + resolve, + }); + + expect( + manifest[ + `/${path.relative(app.root, actionEntry).replaceAll(path.sep, "/")}#${exportName}` + ], + ).toEqual(["/"]); + }, + ); + + it("does not assign unrelated package actions through a narrowed intermediary", async () => { + const app = await fixture({ + "app/page.tsx": `import { action1 } from "server-action-wrapper"; export default function Page() { return action1; }`, + "node_modules/server-action-wrapper/index.js": `import { action1, action2 } from "server-action-mod"; export { action1, action2 };`, + "node_modules/server-action-wrapper/package.json": `{"name":"server-action-wrapper","type":"module"}`, + "node_modules/server-action-mod/index.js": `'use server'; export async function action1() {} export async function action2() {}`, + "node_modules/server-action-mod/package.json": `{"name":"server-action-mod","type":"module"}`, + }); + const wrapperEntry = path.join(app.root, "node_modules/server-action-wrapper/index.js"); + const actionEntry = path.join(app.root, "node_modules/server-action-mod/index.js"); + const resolve = async (specifier: string, importer?: string) => { + if (specifier === "server-action-wrapper") return { id: wrapperEntry }; + if (specifier === "server-action-mod") return { id: actionEntry }; + return app.resolve(specifier, importer); + }; + const manifest = await buildStaticActionOwnerManifest({ + mode: "development", + root: app.root, + routes: [route("/", path.join(app.root, "app/page.tsx"))], + resolve, + }); + const actionKey = `/${path.relative(app.root, actionEntry).replaceAll(path.sep, "/")}`; + expect(manifest[`${actionKey}#action1`]).toEqual(["/"]); + expect(manifest[`${actionKey}#action2`]).toBeUndefined(); + }); + it("does not mistake inline action directives for module-level directives", async () => { const app = await fixture({ "app/page.tsx": `export default function Page() {\n async function action() {\n "use server";\n }\n return action;\n}`, diff --git a/tests/app-action-forwarding.test.ts b/tests/app-action-forwarding.test.ts index 28c9628f5b..02658ba7f8 100644 --- a/tests/app-action-forwarding.test.ts +++ b/tests/app-action-forwarding.test.ts @@ -18,6 +18,7 @@ function options(overrides: Record = {}) { return { actionId: "action-key#submit", actionOwners: { "action-key#submit": ["/teams/:team/actions"] }, + allowedOrigins: [], basePath: "/base", clearRequestContext() {}, currentRoutePattern: "/source", @@ -129,13 +130,65 @@ describe("server action forwarding", () => { expect(capturedHeaders.has("set-cookie")).toBe(false); }); - it("fails closed for unknown actions and forwarding loops", async () => { + it("applies middleware cookie deletions to the forwarded request", async () => { + const middlewareHeaders = new Headers({ + "set-cookie": "session=; Path=/; Max-Age=0", + }); + let forwardedCookie = ""; + + await forwardServerActionIfNeeded( + options({ + async dispatch(nextRequest: Request) { + forwardedCookie = nextRequest.headers.get("cookie") ?? ""; + return new Response("flight", { + headers: { "content-type": "text/x-component" }, + }); + }, + middlewareContext: { + headers: middlewareHeaders, + requestHeaders: null, + status: null, + }, + request: request({ cookie: "session=old; theme=dark" }), + }), + ); + + expect(forwardedCookie).not.toContain("session="); + expect(forwardedCookie).toContain("theme=dark"); + }); + + it.each([ + "session=; Path=/; Max-Age=-1", + "session=; Path=/; Expires=Wed, 01 Jan 2020 00:00:00 GMT", + ])("applies middleware cookie deletion from %s", async (setCookie) => { + let forwardedCookie = ""; + await forwardServerActionIfNeeded( + options({ + async dispatch(nextRequest: Request) { + forwardedCookie = nextRequest.headers.get("cookie") ?? ""; + return new Response("flight", { + headers: { "content-type": "text/x-component" }, + }); + }, + middlewareContext: { + headers: new Headers({ "set-cookie": setCookie }), + requestHeaders: null, + status: null, + }, + request: request({ cookie: "session=old; theme=dark" }), + }), + ); + expect(forwardedCookie).not.toContain("session="); + expect(forwardedCookie).toContain("theme=dark"); + }); + + it("fails closed for unknown actions and forwarded requests that missed their owner", async () => { const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); const clearRequestContext = vi.fn(); const unknown = await forwardServerActionIfNeeded( options({ actionId: "unknown#action", clearRequestContext }), ); - const loop = await forwardServerActionIfNeeded( + const forwarded = await forwardServerActionIfNeeded( options({ clearRequestContext, request: request({ [ACTION_FORWARDED_HEADER]: "1" }), @@ -143,9 +196,9 @@ describe("server action forwarding", () => { ); expect(unknown?.status).toBe(404); - expect(loop?.status).toBe(404); + expect(await forwarded?.text()).toBe("{}"); expect(clearRequestContext).toHaveBeenCalledTimes(2); - expect(warning).toHaveBeenCalledTimes(2); + expect(warning).toHaveBeenCalledTimes(1); warning.mockRestore(); }); diff --git a/tests/app-browser-server-action-client.test.ts b/tests/app-browser-server-action-client.test.ts index def82f01c0..b0538fa48f 100644 --- a/tests/app-browser-server-action-client.test.ts +++ b/tests/app-browser-server-action-client.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { createFromFetch } from "@vitejs/plugin-rsc/browser"; import { createClientNavigationRenderSnapshot } from "../packages/vinext/src/shims/navigation.js"; import { createServerActionInitiationSnapshot } from "../packages/vinext/src/server/app-browser-action-result.js"; import { invokeClientServerAction } from "../packages/vinext/src/server/app-browser-server-action-client.js"; @@ -21,6 +22,82 @@ afterEach(() => { }); describe("app browser server action client", () => { + it("renders raw Flight payloads returned for action redirects", async () => { + const redirectPayload = AppElementsWire.createMetadataEntries({ + interception: null, + interceptionContext: null, + layoutIds: [AppElementsWire.encodeLayoutId("/")], + rootLayoutTreePath: "/", + routeId: "route:/target", + slotBindings: [], + }); + const elements = normalizeAppElements(redirectPayload); + const routerState: AppRouterState = { + activeOperation: null, + bfcacheIds: {}, + elements, + interception: null, + interceptionContext: null, + layoutFlags: {}, + layoutIds: [AppElementsWire.encodeLayoutId("/")], + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/source", {}), + previousNextUrl: null, + renderId: 0, + rootLayoutTreePath: "/", + routeId: "route:/source", + slotBindings: [], + visibleCommitVersion: 0, + }; + const actionInitiation = createServerActionInitiationSnapshot({ + href: "https://example.com/source", + navigationId: 1, + routerState, + }); + vi.stubGlobal("window", { + location: { + href: "https://example.com/source", + origin: "https://example.com", + }, + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response("flight", { + status: 303, + headers: { + [ACTION_REDIRECT_HEADER]: "/target", + "content-type": "text/x-component", + }, + }), + ), + ); + vi.mocked(createFromFetch).mockResolvedValueOnce(redirectPayload); + const performHardNavigation = vi.fn(); + const renderRedirectPayload = vi.fn(); + + await expect( + invokeClientServerAction("action-id", [], actionInitiation, { + basePath: "", + clearClientNavigationCaches: vi.fn(), + clientRscCompatibilityId: null, + commitSameUrlNavigatePayload: vi.fn(), + navigationPlanner, + performHardNavigation, + renderRedirectPayload, + syncCurrentHistoryState: vi.fn(), + syncServerActionHttpFallbackHead: vi.fn(), + }), + ).rejects.toMatchObject({ digest: "NEXT_REDIRECT;push;%2Ftarget;307;", handled: true }); + + expect(renderRedirectPayload).toHaveBeenCalledWith( + elements, + expect.objectContaining({ href: "https://example.com/target" }), + actionInitiation, + "none", + ); + expect(performHardNavigation).not.toHaveBeenCalled(); + }); + it("uses action state captured before the lazy client loads", async () => { const elements = normalizeAppElements( AppElementsWire.createMetadataEntries({ diff --git a/tests/app-router-isr-codegen.test.ts b/tests/app-router-isr-codegen.test.ts index d01feee202..5b67e0c73d 100644 --- a/tests/app-router-isr-codegen.test.ts +++ b/tests/app-router-isr-codegen.test.ts @@ -35,7 +35,8 @@ describe("generateRscEntry ISR code generation", () => { it("generated handler delegates request and ctx handling to createAppRscHandler", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); expect(code).toContain("createAppRscHandler"); - expect(code).toContain("export default createAppRscHandler({"); + expect(code).toContain("const __appRscHandler = createAppRscHandler({"); + expect(code).toContain("export default __appRscHandler;"); }); it("configures the cache through the lightweight handler runtime", () => { diff --git a/tests/app-router-next-config-codegen.test.ts b/tests/app-router-next-config-codegen.test.ts index 11d5fb8be7..6778d9519c 100644 --- a/tests/app-router-next-config-codegen.test.ts +++ b/tests/app-router-next-config-codegen.test.ts @@ -209,7 +209,8 @@ describe("App Router next.config.js features (generateRscEntry)", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, { redirects: [{ source: "/old", destination: "/new", permanent: true }], }); - expect(code).toContain("export default createAppRscHandler({"); + expect(code).toContain("const __appRscHandler = createAppRscHandler({"); + expect(code).toContain("export default __appRscHandler;"); expect(code).toContain("configRedirects: __configRedirects"); expect(code).toContain("dispatchMatchedPage({"); expect(code).toContain(" clientReuseManifest,"); diff --git a/tests/app-server-action-execution.test.ts b/tests/app-server-action-execution.test.ts index 3c6e2dd5cb..df36097765 100644 --- a/tests/app-server-action-execution.test.ts +++ b/tests/app-server-action-execution.test.ts @@ -1732,10 +1732,11 @@ describe("app server action execution helpers", () => { if (!renderRequest) throw new Error("Expected redirect render request"); expect(renderRequest.method).toBe("GET"); - expect(renderRequest.url).toBe("https://example.com/redirect-target?from=action"); + expect(renderRequest.url).toBe("https://example.com/redirect-target?from=action&_rsc"); + expect(renderRequest.headers.get("rsc")).toBe("1"); expect(renderRequest.headers.get("next-action")).toBeNull(); expect(renderRequest.headers.get("x-rsc-action")).toBeNull(); - expect(renderRequest.headers.get("rsc")).toBeNull(); + expect(renderRequest.headers.get("rsc")).toBe("1"); expect(renderRequest.headers.get("content-type")).toBeNull(); expect(renderRequest.headers.get("origin")).toBeNull(); expect(renderRequest.headers.get("cookie")).toBe( diff --git a/tests/e2e/app-router/server-actions.spec.ts b/tests/e2e/app-router/server-actions.spec.ts index 5c4762bd3a..fdbeb2ccdd 100644 --- a/tests/e2e/app-router/server-actions.spec.ts +++ b/tests/e2e/app-router/server-actions.spec.ts @@ -448,16 +448,13 @@ test.describe("Server action forwarding loop guard", () => { await expect(page.locator("h1")).toHaveText("Action Forward Loop Test"); await waitForAppRouterHydration(page); - // Click the action button. Middleware rewrites POST to rewrite-target page, - // but vinext's single-worker bundle still finds the action locally. - // The action should succeed without any infinite loop / timeout. + // Click the action button. Middleware rewrites POST to a page that does not + // own the action, so the forwarded request must settle without executing it + // or entering another forwarding cycle. await page.click("#run-action"); - // Wait for action result to appear (or the boundary text if the action fails) - await expect(async () => { - const text = await page.locator("#action-result").textContent(); - expect(text).toContain("action-ok"); - }).toPass({ timeout: 10_000 }); + await expect(page.locator("#action-result")).not.toContainText("action-ok"); + await expect(page.locator("h1")).toHaveText("Action Forward Loop Test"); }); // This tests the pre-existing "unknown action ID" path, not the new diff --git a/tests/fixtures/app-basic/app/actions/page.tsx b/tests/fixtures/app-basic/app/actions/page.tsx index ace3e53635..6aa2a006b8 100644 --- a/tests/fixtures/app-basic/app/actions/page.tsx +++ b/tests/fixtures/app-basic/app/actions/page.tsx @@ -1,5 +1,6 @@ import { LikeButton } from "./like-button"; import { MessageForm } from "./message-form"; +import { notFoundAction } from "./actions"; export default function ActionsPage() { return ( @@ -13,6 +14,9 @@ export default function ActionsPage() {

Message Form

+
+ +
); From 2dcf18aade96fb1d435a3b683b6ba481bf2418e4 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 4 Jul 2026 14:12:42 +0100 Subject: [PATCH 05/17] fix(app-router): escape action owner metadata --- packages/vinext/src/build/action-owner-manifest.ts | 3 ++- packages/vinext/src/entries/app-rsc-entry.ts | 3 ++- .../vinext/src/server/app-action-forwarding.ts | 2 ++ tests/action-owner-manifest.test.ts | 14 ++++++++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/build/action-owner-manifest.ts b/packages/vinext/src/build/action-owner-manifest.ts index 5b746be929..cdccc1d50b 100644 --- a/packages/vinext/src/build/action-owner-manifest.ts +++ b/packages/vinext/src/build/action-owner-manifest.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { parseAstAsync, transformWithOxc, type ConfigEnv } from "vite"; import type { AppRoute } from "../routing/app-route-graph.js"; +import { safeJsonStringify } from "../server/html.js"; type ActionOwnerRoute = Pick< AppRoute, @@ -374,7 +375,7 @@ export function injectActionOwnerManifest( if (!match) return null; return code.replace( marker, - () => `function ${match[1]}() { return ${JSON.stringify(manifest)}; }`, + () => `function ${match[1]}() { return ${safeJsonStringify(manifest)}; }`, ); } diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 6882d3aeae..f0ea79f332 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -21,6 +21,7 @@ import type { import type { ImageConfig } from "../server/image-optimization.js"; import type { AppRoute } from "../routing/app-router.js"; import { generateDevOriginCheckCode } from "../server/dev-origin-check.js"; +import { safeJsonStringify } from "../server/html.js"; import type { MetadataFileRoute } from "../server/metadata-routes.js"; import { isProxyFile } from "../server/middleware.js"; import { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "../server/image-optimization.js"; @@ -705,7 +706,7 @@ ${rootParamNameEntries.join("\n")} }; __setPagesClientAssets(__pagesClientAssets); -function __VINEXT_ACTION_OWNERS() { return ${actionOwners ? JSON.stringify(actionOwners) : '"__VINEXT_ACTION_OWNERS_STUB__"'}; } +function __VINEXT_ACTION_OWNERS() { return ${actionOwners ? safeJsonStringify(actionOwners) : '"__VINEXT_ACTION_OWNERS_STUB__"'}; } const __appRscHandler = createAppRscHandler({ basePath: __basePath, buildId: process.env.__VINEXT_BUILD_ID ?? null, diff --git a/packages/vinext/src/server/app-action-forwarding.ts b/packages/vinext/src/server/app-action-forwarding.ts index 2eb92f0cee..0e1fbb05d9 100644 --- a/packages/vinext/src/server/app-action-forwarding.ts +++ b/packages/vinext/src/server/app-action-forwarding.ts @@ -154,6 +154,8 @@ export async function forwardServerActionIfNeeded( const forwardUrl = new URL(options.request.url); forwardUrl.pathname = options.basePath + (ownerPath === "/" ? "" : patternToNextFormat(ownerPath)); + // Match Next.js createForwardedActionResponse(): forwarding targets only the + // owner worker pathname, not the caller's query string. forwardUrl.search = ""; const forwardRequest = new Request(forwardUrl, { body: options.request.body, diff --git a/tests/action-owner-manifest.test.ts b/tests/action-owner-manifest.test.ts index a844fbf540..8ccae7edec 100644 --- a/tests/action-owner-manifest.test.ts +++ b/tests/action-owner-manifest.test.ts @@ -9,6 +9,7 @@ import { actionOwnerRouteEntryIds, buildActionOwnerManifest, buildStaticActionOwnerManifest, + injectActionOwnerManifest, } from "../packages/vinext/src/build/action-owner-manifest.js"; let roots: string[] = []; @@ -116,6 +117,19 @@ describe("server action owner manifest", () => { expect(development["/app/actions.ts#default"]).toEqual(["/"]); }); + it("escapes action owner manifests embedded in generated JavaScript", () => { + const injected = injectActionOwnerManifest( + `function __VINEXT_ACTION_OWNERS() { return "__VINEXT_ACTION_OWNERS_STUB__"; }`, + { "module#action\u2028": ["/route&segment\u2029"] }, + ); + + expect(injected).toContain("\\u003c/script\\u003e"); + expect(injected).toContain("\\u0026"); + expect(injected).toContain("\\u2028"); + expect(injected).toContain("\\u2029"); + expect(injected).not.toContain(""); + }); + it("traces aliased re-exports to the original server action export", async () => { const app = await fixture({ "app/actions.ts": `'use server'; export async function original() {}`, From 577afffafa3f3a6c6cdad9d94f19c6f69304bf5e Mon Sep 17 00:00:00 2001 From: James Date: Sat, 4 Jul 2026 14:15:36 +0100 Subject: [PATCH 06/17] fix(routing): parse route parameters linearly --- packages/vinext/src/routing/route-validation.ts | 15 +++++++++------ tests/route-sorting.test.ts | 5 +++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/routing/route-validation.ts b/packages/vinext/src/routing/route-validation.ts index b63414b66c..7d32f04411 100644 --- a/packages/vinext/src/routing/route-validation.ts +++ b/packages/vinext/src/routing/route-validation.ts @@ -150,13 +150,16 @@ class UrlNode { export function patternToNextFormat(pattern: string): string { if (pattern === "/") return "/"; - // Match any non-/ param name. Non-greedy with lookahead ensures the - // +/* suffix is consumed as a modifier, not swallowed into the param name - // when the name itself contains + or * internally (e.g. :c++lang → [c++lang]). return pattern - .replace(/:([^/]+?)\+(?=\/|$)/g, "[...$1]") - .replace(/:([^/]+?)\*(?=\/|$)/g, "[[...$1]]") - .replace(/:([^/]+?)(?=\/|$)/g, "[$1]"); + .split("/") + .map((segment) => { + if (!segment.startsWith(":")) return segment; + const parameter = segment.slice(1); + if (parameter.endsWith("+")) return `[...${parameter.slice(0, -1)}]`; + if (parameter.endsWith("*")) return `[[...${parameter.slice(0, -1)}]]`; + return `[${parameter}]`; + }) + .join("/"); } function normalizeRoutePattern(pattern: string): string { diff --git a/tests/route-sorting.test.ts b/tests/route-sorting.test.ts index 62101bd478..15d3d435f8 100644 --- a/tests/route-sorting.test.ts +++ b/tests/route-sorting.test.ts @@ -174,6 +174,11 @@ describe("patternToNextFormat", () => { expect(patternToNextFormat("/:a*b")).toBe("/[a*b]"); expect(patternToNextFormat("/utils/:a+b/c")).toBe("/utils/[a+b]/c"); }); + + it("handles adversarial parameter names in linear time", () => { + const parameter = `${":.".repeat(20_000)}value`; + expect(patternToNextFormat(`/${parameter}`)).toBe(`/[${parameter.slice(1)}]`); + }); }); describe("validateRoutePatterns", () => { From ba44206a38a51190f25123a18ba4fa0b8b20528b Mon Sep 17 00:00:00 2001 From: James Date: Mon, 6 Jul 2026 10:19:48 +0100 Subject: [PATCH 07/17] perf(app-router): collect action owners during RSC scans --- .../vinext/src/build/action-owner-manifest.ts | 337 +++++++++++++++++- packages/vinext/src/index.ts | 103 ++++-- tests/action-owner-manifest.test.ts | 126 +++++++ tests/action-runtime-security.test.ts | 36 +- 4 files changed, 574 insertions(+), 28 deletions(-) diff --git a/packages/vinext/src/build/action-owner-manifest.ts b/packages/vinext/src/build/action-owner-manifest.ts index cdccc1d50b..75c1216ae7 100644 --- a/packages/vinext/src/build/action-owner-manifest.ts +++ b/packages/vinext/src/build/action-owner-manifest.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import { parseAstAsync, transformWithOxc, type ConfigEnv } from "vite"; +import { parseAst, parseAstAsync, transformWithOxc, type ConfigEnv } from "vite"; import type { AppRoute } from "../routing/app-route-graph.js"; import { safeJsonStringify } from "../server/html.js"; @@ -48,6 +48,59 @@ type SourceDependency = { specifier: string; }; +type SourceImport = { + bindings: readonly { + importedName: string | null; + localName: string; + }[]; + specifier: string; +}; + +type SourceReexport = { + namespaceName: string | null; + specifier: string; + specifiers: readonly { + exportedName: string; + sourceName: string; + }[]; +}; + +type ActionOwnerModuleAnalysis = { + dynamicImports: readonly string[]; + exportedNames: readonly string[]; + exportLocalBindings: ReadonlyMap>; + hasModuleUseServerDirective: boolean; + imports: readonly SourceImport[]; + reexports: readonly SourceReexport[]; +}; + +type CollectedActionOwnerModuleEnvironment = { + analysis: ActionOwnerModuleAnalysis; + resolvedDependencies: ReadonlyMap; +}; + +type CollectedActionOwnerModule = { + id: string; + environments: Partial>; +}; + +export type ActionOwnerModuleCollector = { + buildManifest(options: { + canonicalizeModuleId?: (id: string) => string; + mode: "development" | "production"; + root: string; + routes: readonly ActionOwnerRoute[]; + }): Record; + clear(): void; + collect(options: { + code: string; + environment: "rsc" | "ssr"; + id: string; + resolve: (source: string, importer: string) => Promise<{ id: string } | null>; + }): Promise; + size(): number; +}; + export function actionOwnerManifestMode( env: Pick, ): "development" | "production" { @@ -267,6 +320,12 @@ function collectImportExpressions(node: unknown, dependencies: SourceDependency[ } } +function collectDynamicImportSpecifiers(node: unknown, specifiers: string[]): void { + const dependencies: SourceDependency[] = []; + collectImportExpressions(node, dependencies); + specifiers.push(...dependencies.map((dependency) => dependency.specifier)); +} + function sourceDependencies( program: AstNode, requestedNames: string[] | null, @@ -328,11 +387,287 @@ function sourceDependencies( return dependencies; } +function analyzeActionOwnerModule(program: AstNode): ActionOwnerModuleAnalysis { + const imports: SourceImport[] = []; + const reexports: SourceReexport[] = []; + const dynamicImports: string[] = []; + const exportLocalBindings = new Map>(); + + for (const statement of (program.body as AstNode[] | undefined) ?? []) { + if (statement.type === "ImportDeclaration") { + const specifier = literalString(statement.source); + if (!specifier) continue; + imports.push({ + bindings: ((statement.specifiers as AstNode[] | undefined) ?? []).flatMap< + SourceImport["bindings"][number] + >((item) => { + const localName = identifierName(item.local); + if (!localName) return []; + if (item.type === "ImportNamespaceSpecifier") { + return [{ importedName: null, localName }]; + } + if (item.type === "ImportDefaultSpecifier") { + return [{ importedName: "default", localName }]; + } + const importedName = identifierName(item.imported); + return importedName ? [{ importedName, localName }] : []; + }), + specifier, + }); + continue; + } + if (statement.type === "ExportNamedDeclaration" && statement.source) { + const specifier = literalString(statement.source); + if (!specifier) continue; + reexports.push({ + namespaceName: null, + specifier, + specifiers: ((statement.specifiers as AstNode[] | undefined) ?? []).flatMap((item) => { + const exportedName = identifierName(item.exported); + const sourceName = identifierName(item.local); + return exportedName && sourceName ? [{ exportedName, sourceName }] : []; + }), + }); + continue; + } + if (statement.type === "ExportAllDeclaration") { + const specifier = literalString(statement.source); + if (!specifier) continue; + reexports.push({ + namespaceName: identifierName(statement.exported), + specifier, + specifiers: [], + }); + continue; + } + collectDynamicImportSpecifiers(statement, dynamicImports); + + if (statement.type === "ExportDefaultDeclaration") { + const localName = identifierName(statement.declaration); + if (localName) exportLocalBindings.set("default", new Set([localName])); + continue; + } + if (statement.type !== "ExportNamedDeclaration" || statement.source) continue; + const declaration = statement.declaration as AstNode | undefined; + if (declaration?.type === "VariableDeclaration") { + for (const declarator of (declaration.declarations as AstNode[] | undefined) ?? []) { + const exportedName = identifierName(declarator.id); + const localName = identifierName(declarator.init); + if (exportedName && localName) exportLocalBindings.set(exportedName, new Set([localName])); + } + } else { + const exportedName = identifierName(declaration?.id); + if (exportedName) exportLocalBindings.set(exportedName, new Set([exportedName])); + } + for (const item of (statement.specifiers as AstNode[] | undefined) ?? []) { + const exportedName = identifierName(item.exported); + const localName = identifierName(item.local); + if (exportedName && localName) exportLocalBindings.set(exportedName, new Set([localName])); + } + } + + return { + dynamicImports, + exportedNames: exportedNames(program), + exportLocalBindings, + hasModuleUseServerDirective: hasModuleUseServerDirective(program), + imports, + reexports, + }; +} + +function collectedSourceDependencies( + analysis: ActionOwnerModuleAnalysis, + requestedNames: string[] | null, + includeImports: boolean, + narrowImports: boolean, +): SourceDependency[] { + const dependencies: SourceDependency[] = []; + const requestedLocalBindings = new Set(); + if (narrowImports && requestedNames) { + for (const name of requestedNames) { + for (const localName of analysis.exportLocalBindings.get(name) ?? []) { + requestedLocalBindings.add(localName); + } + } + } + + if (includeImports) { + for (const sourceImport of analysis.imports) { + const bindings = + narrowImports && requestedNames + ? sourceImport.bindings.filter((binding) => requestedLocalBindings.has(binding.localName)) + : sourceImport.bindings; + dependencies.push({ + names: bindings.some((binding) => binding.importedName === null) + ? null + : [...new Set(bindings.flatMap((binding) => binding.importedName ?? []))], + specifier: sourceImport.specifier, + }); + } + } + + for (const reexport of analysis.reexports) { + if (reexport.specifiers.length > 0) { + const requested = requestedNames ? new Set(requestedNames) : null; + dependencies.push({ + names: reexport.specifiers.flatMap(({ exportedName, sourceName }) => + !requested || requested.has(exportedName) ? [sourceName] : [], + ), + specifier: reexport.specifier, + }); + } else { + dependencies.push({ + names: reexport.namespaceName + ? requestedNames === null || requestedNames.includes(reexport.namespaceName) + ? null + : [] + : requestedNames, + specifier: reexport.specifier, + }); + } + } + + if (includeImports && (!narrowImports || requestedNames === null)) { + for (const specifier of analysis.dynamicImports) { + dependencies.push({ names: null, specifier }); + } + } + return dependencies; +} + function addOwner(manifest: Record, key: string, pattern: string): void { const owners = (manifest[key] ??= []); if (!owners.includes(pattern)) owners.push(pattern); } +function buildManifestFromModules(options: { + canonicalizeModuleId?: (id: string) => string; + mode: "development" | "production"; + modules: ReadonlyMap; + root: string; + routes: readonly ActionOwnerRoute[]; +}): Record { + const manifest: Record = {}; + const canonicalizeModuleId = options.canonicalizeModuleId ?? ((id: string) => id); + + for (const route of options.routes) { + const entryIds = actionOwnerRouteEntryIds(route).map(canonicalizeModuleId); + const entrySet = new Set(entryIds); + const queue = entryIds.map((id) => ({ id, names: null as string[] | null })); + const visited = new Set(); + + for (let index = 0; index < queue.length; index++) { + const current = queue[index]!; + const visitKey = `${current.id}\0${current.names?.join(",") ?? "*"}`; + if (visited.has(visitKey)) continue; + visited.add(visitKey); + + const module = options.modules.get(current.id); + const moduleEnvironment = module?.environments.ssr ?? module?.environments.rsc; + if (!module || !moduleEnvironment) continue; + + const moduleReferenceKey = referenceKey(options.root, module.id, options.mode); + if (entrySet.has(current.id)) addOwner(manifest, moduleReferenceKey, route.pattern); + + if (moduleEnvironment.analysis.hasModuleUseServerDirective) { + for (const name of current.names ?? moduleEnvironment.analysis.exportedNames) { + addOwner(manifest, `${moduleReferenceKey}#${name}`, route.pattern); + } + continue; + } + + const isPackageModule = module.id.includes("/node_modules/"); + const includeImports = !isPackageModule || current.names !== null; + for (const dependency of collectedSourceDependencies( + moduleEnvironment.analysis, + current.names, + includeImports, + isPackageModule, + )) { + if (dependency.names?.length === 0) continue; + const resolvedId = + module.environments.ssr?.resolvedDependencies.get(dependency.specifier) ?? + module.environments.rsc?.resolvedDependencies.get(dependency.specifier); + if (!resolvedId) continue; + queue.push({ id: canonicalizeModuleId(resolvedId), names: dependency.names }); + } + } + } + + return manifest; +} + +export function createActionOwnerModuleCollector( + options: { + parse?: (code: string) => AstNode; + } = {}, +): ActionOwnerModuleCollector { + const modules = new Map(); + const pendingModules = new Map>(); + const analyses = new Map(); + const parse = options.parse ?? ((code: string) => parseAst(code) as unknown as AstNode); + + return { + buildManifest(buildOptions) { + return buildManifestFromModules({ ...buildOptions, modules }); + }, + clear() { + modules.clear(); + pendingModules.clear(); + analyses.clear(); + }, + async collect({ code, environment, id, resolve }) { + const module = modules.get(id); + if (module?.environments[environment]) return; + const pendingKey = `${environment}\0${id}`; + const existing = pendingModules.get(pendingKey); + if (existing) return existing; + + const pending = (async () => { + const analysisKey = createHash("sha256").update(code).digest("base64url"); + let analysis = analyses.get(analysisKey); + if (!analysis) { + let program: AstNode; + try { + program = parse(code); + } catch { + return; + } + analysis = analyzeActionOwnerModule(program); + analyses.set(analysisKey, analysis); + } + + const dependencies = collectedSourceDependencies(analysis, null, true, false); + const resolvedDependencies = new Map(); + await Promise.all( + [...new Set(dependencies.map((dependency) => dependency.specifier))].map( + async (specifier) => { + let resolved: { id: string } | null; + try { + resolved = await resolve(specifier, id); + } catch { + return; + } + if (!resolved || resolved.id.includes("?")) return; + resolvedDependencies.set(specifier, resolved.id); + }, + ), + ); + const collected = modules.get(id) ?? { environments: {}, id }; + collected.environments[environment] = { analysis, resolvedDependencies }; + modules.set(id, collected); + })(); + pendingModules.set(pendingKey, pending); + await pending; + pendingModules.delete(pendingKey); + }, + size() { + return modules.size; + }, + }; +} + export function buildActionOwnerManifest(options: { canonicalizeModuleId?: (id: string) => string; moduleInfo: ModuleInfoProvider; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index fc3bd7f8e6..25a9543505 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -72,6 +72,7 @@ import { actionOwnerManifestMode, buildActionOwnerManifest, buildStaticActionOwnerManifest, + createActionOwnerModuleCollector, injectActionOwnerManifest, } from "./build/action-owner-manifest.js"; import { normalizePathnameForRouteMatchStrict } from "./routing/utils.js"; @@ -1328,7 +1329,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // `__VINEXT_CLASS` stub with a real dispatch table. let rscClassificationManifest: RouteClassificationManifest | null = null; let rscActionOwnerRoutes: Awaited> | null = null; - let rscStaticActionOwners: Record | null = null; + const rscActionOwnerModuleCollector = createActionOwnerModuleCollector(); // Resolve shim paths - works both from source (.ts) and built (.js). // Normalize to forward slashes so every downstream `path.posix.join` keeps @@ -3506,19 +3507,19 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // indices in the manifest correspond 1:1 to the route.layouts arrays // used during codegen. renderChunk clears this after patching. rscClassificationManifest = collectRouteClassificationManifest(routes); - const actionOwners = hasServerActions - ? await buildStaticActionOwnerManifest({ - mode: isDevelopmentServe ? "development" : "production", - root, - routes, - resolve: async (source, importer) => { - const resolved = await this.resolve(source, importer); - return resolved ? { id: resolved.id } : null; - }, - }) - : {}; + const actionOwners = + hasServerActions && isDevelopmentServe + ? await buildStaticActionOwnerManifest({ + mode: "development", + root, + routes, + resolve: async (source, importer) => { + const resolved = await this.resolve(source, importer); + return resolved ? { id: resolved.id } : null; + }, + }) + : {}; rscActionOwnerRoutes = isDevelopmentServe || !hasServerActions ? null : routes; - rscStaticActionOwners = isDevelopmentServe || !hasServerActions ? null : actionOwners; return generateRscEntry( appDir, routes, @@ -3713,19 +3714,31 @@ export const loadServerActionClient = ${ if (hasActionOwnerStub && rscActionOwnerRoutes) { const rscModule = await rscPluginModulePromise; const pluginApi = rscModule?.getPluginApi(this.environment.config); - const serverReferences = Object.values(pluginApi?.manager.serverReferenceMetaMap ?? {}); - const discoveredActionOwners = buildActionOwnerManifest({ - canonicalizeModuleId: canonicalize, - moduleInfo, - routes: rscActionOwnerRoutes, - serverReferences, - staticOwners: rscStaticActionOwners ?? {}, - }); - const injected = injectActionOwnerManifest(nextCode, discoveredActionOwners); - if (!injected) this.error("vinext: failed to inject the server action owner manifest"); - nextCode = injected; - rscActionOwnerRoutes = null; - rscStaticActionOwners = null; + if (!pluginApi?.manager.isScanBuild) { + const serverReferences = Object.values( + pluginApi?.manager.serverReferenceMetaMap ?? {}, + ); + const staticOwners = rscActionOwnerModuleCollector.buildManifest({ + canonicalizeModuleId: canonicalize, + mode: "production", + root, + routes: rscActionOwnerRoutes, + }); + const discoveredActionOwners = buildActionOwnerManifest({ + canonicalizeModuleId: canonicalize, + moduleInfo, + routes: rscActionOwnerRoutes, + serverReferences, + staticOwners, + }); + const injected = injectActionOwnerManifest(nextCode, discoveredActionOwners); + if (!injected) { + this.error("vinext: failed to inject the server action owner manifest"); + } + nextCode = injected; + rscActionOwnerRoutes = null; + rscActionOwnerModuleCollector.clear(); + } } // Consume the manifest exactly once per RSC entry. Clearing here @@ -6546,6 +6559,44 @@ export const loadServerActionClient = ${ }, ]; + plugins.push({ + name: "vinext:action-owner-scan", + apply: "build", + async buildStart() { + if (this.environment.name !== "rsc" || !rscPluginModulePromise) return; + const { getPluginApi } = await rscPluginModulePromise; + if (getPluginApi(this.environment.config)?.manager.isScanBuild) { + rscActionOwnerModuleCollector.clear(); + } + }, + transform: { + filter: { + id: /(?:\.[cm]?[jt]sx?|\.mdx)$/i, + code: /\b(?:import|export)\b|["']use server["']/, + }, + async handler(code, id) { + if ( + (this.environment.name !== "rsc" && this.environment.name !== "ssr") || + id.includes("?") || + !rscPluginModulePromise + ) { + return; + } + const { getPluginApi } = await rscPluginModulePromise; + if (!getPluginApi(this.environment.config)?.manager.isScanBuild) return; + await rscActionOwnerModuleCollector.collect({ + code, + environment: this.environment.name, + id: canonicalize(id), + resolve: async (source, importer) => { + const resolved = await this.resolve(source, importer); + return resolved ? { id: canonicalize(resolved.id) } : null; + }, + }); + }, + }, + }); + // Append auto-injected RSC plugins if applicable if (rscPluginPromise) { plugins.push(rscPluginPromise); diff --git a/tests/action-owner-manifest.test.ts b/tests/action-owner-manifest.test.ts index 8ccae7edec..749efde016 100644 --- a/tests/action-owner-manifest.test.ts +++ b/tests/action-owner-manifest.test.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { parseAst } from "vite"; import { afterEach, describe, expect, it } from "vite-plus/test"; import type { AppRoute } from "../packages/vinext/src/routing/app-route-graph.js"; import { @@ -9,6 +10,7 @@ import { actionOwnerRouteEntryIds, buildActionOwnerManifest, buildStaticActionOwnerManifest, + createActionOwnerModuleCollector, injectActionOwnerManifest, } from "../packages/vinext/src/build/action-owner-manifest.js"; @@ -117,6 +119,130 @@ describe("server action owner manifest", () => { expect(development["/app/actions.ts#default"]).toEqual(["/"]); }); + it("builds production ownership from already-transformed scan modules", async () => { + const root = "/project"; + const sources = new Map([ + [ + "/project/app/page.js", + `import { publicOnly } from "./actions.js"; export default function Page() { return publicOnly; }`, + ], + [ + "/project/app/admin/page.js", + `import { adminOnly } from "../actions.js"; export default function Page() { return adminOnly; }`, + ], + [ + "/project/app/actions.js", + `'use server'; export async function publicOnly() {} export async function adminOnly() {}`, + ], + ]); + const collector = createActionOwnerModuleCollector(); + const resolve = async (specifier: string, importer: string) => ({ + id: path.posix.resolve(path.posix.dirname(importer), specifier), + }); + + await Promise.all( + [...sources].map(([id, code]) => + collector.collect({ code, environment: "ssr", id, resolve }), + ), + ); + + const manifest = collector.buildManifest({ + mode: "production", + root, + routes: [route("/", "/project/app/page.js"), route("/admin", "/project/app/admin/page.js")], + }); + + const actionKey = productionKey("app/actions.js"); + expect(manifest[`${actionKey}#publicOnly`]).toEqual(["/"]); + expect(manifest[`${actionKey}#adminOnly`]).toEqual(["/admin"]); + }); + + it("parses each scan module once and resets between builds", async () => { + let parseCount = 0; + const collector = createActionOwnerModuleCollector({ + parse(code) { + parseCount++; + return parseAst(code) as never; + }, + }); + const options = { + code: `export default function Page() {}`, + environment: "ssr" as const, + id: "/project/app/page.js", + resolve: async () => null, + }; + + await Promise.all([collector.collect(options), collector.collect(options)]); + expect(parseCount).toBe(1); + expect(collector.size()).toBe(1); + + collector.clear(); + expect(collector.size()).toBe(0); + await collector.collect(options); + expect(parseCount).toBe(2); + }); + + it("prefers SSR dependency resolution while retaining RSC-only modules", async () => { + const collector = createActionOwnerModuleCollector(); + const pageCode = `import { action } from "conditional-actions"; export default function Page() { return action; }`; + await collector.collect({ + code: pageCode, + environment: "rsc", + id: "/project/app/page.js", + resolve: async () => ({ id: "/project/rsc-actions.js" }), + }); + await collector.collect({ + code: pageCode, + environment: "ssr", + id: "/project/app/page.js", + resolve: async () => ({ id: "/project/ssr-actions.js" }), + }); + await collector.collect({ + code: `'use server'; export async function action() {}`, + environment: "ssr", + id: "/project/ssr-actions.js", + resolve: async () => null, + }); + + const manifest = collector.buildManifest({ + mode: "production", + root: "/project", + routes: [route("/", "/project/app/page.js")], + }); + expect(manifest[`${productionKey("ssr-actions.js")}#action`]).toEqual(["/"]); + expect(manifest[`${productionKey("rsc-actions.js")}#action`]).toBeUndefined(); + }); + + it("narrows package imports with normalized Windows module ids", async () => { + const collector = createActionOwnerModuleCollector(); + await collector.collect({ + code: `import { publicOnly } from "pkg"; export default publicOnly;`, + environment: "ssr", + id: "C:/project/app/page.js", + resolve: async () => ({ id: "C:/project/node_modules/pkg/index.js" }), + }); + await collector.collect({ + code: `import { publicOnly, adminOnly } from "./actions.js"; export { publicOnly, adminOnly };`, + environment: "ssr", + id: "C:/project/node_modules/pkg/index.js", + resolve: async () => ({ id: "C:/project/node_modules/pkg/actions.js" }), + }); + await collector.collect({ + code: `'use server'; export async function publicOnly() {} export async function adminOnly() {}`, + environment: "ssr", + id: "C:/project/node_modules/pkg/actions.js", + resolve: async () => null, + }); + + const manifest = collector.buildManifest({ + mode: "production", + root: "C:/project", + routes: [route("/", "C:/project/app/page.js")], + }); + expect(Object.keys(manifest).some((key) => key.endsWith("#publicOnly"))).toBe(true); + expect(Object.keys(manifest).some((key) => key.endsWith("#adminOnly"))).toBe(false); + }); + it("escapes action owner manifests embedded in generated JavaScript", () => { const injected = injectActionOwnerManifest( `function __VINEXT_ACTION_OWNERS() { return "__VINEXT_ACTION_OWNERS_STUB__"; }`, diff --git a/tests/action-runtime-security.test.ts b/tests/action-runtime-security.test.ts index 1baa8c14b2..c6b25109ee 100644 --- a/tests/action-runtime-security.test.ts +++ b/tests/action-runtime-security.test.ts @@ -94,12 +94,15 @@ export function middleware(request) { if (request.nextUrl.pathname.startsWith("/admin")) { return new NextResponse("unauthorized", { status: 401 }); } + if (request.nextUrl.pathname === "/client-owner") { + return new NextResponse("client owner denied", { status: 401 }); + } if (request.nextUrl.pathname === "/loop") { return NextResponse.rewrite(new URL("/", request.url)); } return NextResponse.next(); } -export const config = { matcher: ["/admin/:path*", "/cookie-source", "/loop"] }; +export const config = { matcher: ["/admin/:path*", "/client-owner", "/cookie-source", "/loop"] }; `, ); await write( @@ -128,6 +131,27 @@ export default function Page() { "app/admin/actions.ts", `'use server'; export async function deleteEverything() { return "ADMIN_ACTION_EXECUTED"; } +`, + ); + await write( + "app/client-owner/actions.ts", + `'use server'; +export async function clientOwnedAction() { return "CLIENT_OWNED_ACTION_EXECUTED"; } +`, + ); + await write( + "app/client-owner/button.tsx", + `'use client'; +import { clientOwnedAction } from "./actions"; +export function ClientButton() { + return
; +} +`, + ); + await write( + "app/client-owner/page.tsx", + `import { ClientButton } from "./button"; +export default function Page() { return ; } `, ); await write( @@ -226,6 +250,7 @@ export default function Page() { builtSource = await readBuiltJavaScript(path.join(fixtureRoot, "dist", "server")); actionIds = { + clientOwnedAction: findActionId(builtSource, "clientOwnedAction"), deleteEverything: findActionId(builtSource, "deleteEverything"), goTo: findActionId(builtSource, "goTo"), }; @@ -276,6 +301,15 @@ export default function Page() { expect(await response.text()).toBe("{}"); }); + it("retains action ownership through client component boundaries", async () => { + const direct = await handler(new Request("http://example.com/client-owner")); + expect(direct.status).toBe(401); + + const response = await handler(actionRequest("/", actionIds.clientOwnedAction, [])); + expect(response.status).toBe(200); + expect(await response.text()).toBe("{}"); + }); + it("blocks cross-action closure replay and the composed redirect chain before action decode", async () => { const encryptedBoundArgs = await encryptBoundArgsFixture('0:["victim-carol"]\n'); const encryptedBytes = Buffer.from(encryptedBoundArgs, "base64"); From 6423c6b353e8b4d485b16fbe3aba28598f313320 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 6 Jul 2026 14:03:10 +0100 Subject: [PATCH 08/17] perf(app-router): reuse RSC action consumer metadata --- .../vinext/src/build/action-owner-manifest.ts | 716 +----------------- packages/vinext/src/entries/app-rsc-entry.ts | 4 +- packages/vinext/src/index.ts | 66 +- patches/@vitejs__plugin-rsc@0.5.27.patch | 257 +++++++ pnpm-lock.yaml | 67 +- pnpm-workspace.yaml | 3 + tests/action-owner-manifest.test.ts | 475 +----------- tests/entry-templates.test.ts | 12 + 8 files changed, 423 insertions(+), 1177 deletions(-) create mode 100644 patches/@vitejs__plugin-rsc@0.5.27.patch diff --git a/packages/vinext/src/build/action-owner-manifest.ts b/packages/vinext/src/build/action-owner-manifest.ts index 75c1216ae7..7efb178e98 100644 --- a/packages/vinext/src/build/action-owner-manifest.ts +++ b/packages/vinext/src/build/action-owner-manifest.ts @@ -1,7 +1,3 @@ -import { createHash } from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { parseAst, parseAstAsync, transformWithOxc, type ConfigEnv } from "vite"; import type { AppRoute } from "../routing/app-route-graph.js"; import { safeJsonStringify } from "../server/html.js"; @@ -38,75 +34,6 @@ type ModuleInfoProvider = { } | null; }; -type AstNode = { - type: string; - [key: string]: unknown; -}; - -type SourceDependency = { - names: string[] | null; - specifier: string; -}; - -type SourceImport = { - bindings: readonly { - importedName: string | null; - localName: string; - }[]; - specifier: string; -}; - -type SourceReexport = { - namespaceName: string | null; - specifier: string; - specifiers: readonly { - exportedName: string; - sourceName: string; - }[]; -}; - -type ActionOwnerModuleAnalysis = { - dynamicImports: readonly string[]; - exportedNames: readonly string[]; - exportLocalBindings: ReadonlyMap>; - hasModuleUseServerDirective: boolean; - imports: readonly SourceImport[]; - reexports: readonly SourceReexport[]; -}; - -type CollectedActionOwnerModuleEnvironment = { - analysis: ActionOwnerModuleAnalysis; - resolvedDependencies: ReadonlyMap; -}; - -type CollectedActionOwnerModule = { - id: string; - environments: Partial>; -}; - -export type ActionOwnerModuleCollector = { - buildManifest(options: { - canonicalizeModuleId?: (id: string) => string; - mode: "development" | "production"; - root: string; - routes: readonly ActionOwnerRoute[]; - }): Record; - clear(): void; - collect(options: { - code: string; - environment: "rsc" | "ssr"; - id: string; - resolve: (source: string, importer: string) => Promise<{ id: string } | null>; - }): Promise; - size(): number; -}; - -export function actionOwnerManifestMode( - env: Pick, -): "development" | "production" { - return env.command === "serve" && env.isPreview !== true ? "development" : "production"; -} - export function actionOwnerRouteEntryIds(route: ActionOwnerRoute): string[] { return [ route.pagePath, @@ -141,539 +68,17 @@ export function actionOwnerRouteEntryIds(route: ActionOwnerRoute): string[] { ].filter((value): value is string => typeof value === "string"); } -function referenceKey(root: string, id: string, mode: "development" | "production"): string { - const relative = path.relative(root, id).replaceAll(path.sep, "/"); - if (mode === "production") { - return createHash("sha256").update(relative).digest("hex").slice(0, 12); - } - return relative.startsWith("../") ? `/@fs/${id.replaceAll(path.sep, "/")}` : `/${relative}`; -} - -function identifierName(value: unknown): string | null { - if (!value || typeof value !== "object") return null; - const name = (value as { name?: unknown }).name; - return typeof name === "string" ? name : null; -} - -function literalString(value: unknown): string | null { - if (!value || typeof value !== "object") return null; - const literal = value as { type?: unknown; value?: unknown }; - return literal.type === "Literal" && typeof literal.value === "string" ? literal.value : null; -} - -function declaredNames(pattern: unknown, names: Set): void { - if (!pattern || typeof pattern !== "object") return; - const node = pattern as AstNode; - if (node.type === "Identifier") { - const name = identifierName(node); - if (name) names.add(name); - return; - } - if (node.type === "RestElement") { - declaredNames(node.argument, names); - return; - } - if (node.type === "AssignmentPattern") { - declaredNames(node.left, names); - return; - } - if (node.type === "ArrayPattern") { - for (const element of (node.elements as unknown[] | undefined) ?? []) { - declaredNames(element, names); - } - return; - } - if (node.type === "ObjectPattern") { - for (const property of (node.properties as AstNode[] | undefined) ?? []) { - declaredNames(property.type === "Property" ? property.value : property.argument, names); - } - } -} - -function exportedNames(program: AstNode): string[] { - const names = new Set(); - for (const statement of (program.body as AstNode[] | undefined) ?? []) { - if (statement.type === "ExportDefaultDeclaration") { - names.add("default"); - continue; - } - if (statement.type !== "ExportNamedDeclaration") continue; - const declaration = statement.declaration as AstNode | null | undefined; - if (declaration?.type === "VariableDeclaration") { - for (const declarator of (declaration.declarations as AstNode[] | undefined) ?? []) { - declaredNames(declarator.id, names); - } - } else if (declaration) { - const name = identifierName(declaration.id); - if (name) names.add(name); - } - for (const specifier of (statement.specifiers as AstNode[] | undefined) ?? []) { - const name = identifierName(specifier.exported); - if (name) names.add(name); - } - } - return [...names]; -} - -function hasModuleUseServerDirective(program: AstNode): boolean { - for (const statement of (program.body as AstNode[] | undefined) ?? []) { - if (statement.type !== "ExpressionStatement") return false; - const directive = statement.directive; - if (directive === "use server") return true; - if (typeof directive !== "string") return false; - } - return false; -} - -function importedNames(specifiers: readonly AstNode[]): string[] | null { - const names = new Set(); - for (const specifier of specifiers) { - if (specifier.type === "ImportNamespaceSpecifier") return null; - if (specifier.type === "ImportDefaultSpecifier") names.add("default"); - if (specifier.type === "ImportSpecifier") { - const name = identifierName(specifier.imported); - if (name) names.add(name); - } - } - return [...names]; -} - -function importedNamesForLocalBindings( - specifiers: readonly AstNode[], - localBindings: ReadonlySet, -): string[] | null { - const names = new Set(); - for (const specifier of specifiers) { - const localName = identifierName(specifier.local); - if (!localName || !localBindings.has(localName)) continue; - if (specifier.type === "ImportNamespaceSpecifier") return null; - if (specifier.type === "ImportDefaultSpecifier") names.add("default"); - if (specifier.type === "ImportSpecifier") { - const name = identifierName(specifier.imported); - if (name) names.add(name); - } - } - return [...names]; -} - -function requestedLocalExportBindings(program: AstNode, requestedNames: string[]): Set { - const requested = new Set(requestedNames); - const bindings = new Set(); - for (const statement of (program.body as AstNode[] | undefined) ?? []) { - if (statement.type === "ExportDefaultDeclaration") { - const localName = identifierName(statement.declaration); - if (requested.has("default") && localName) bindings.add(localName); - continue; - } - if (statement.type !== "ExportNamedDeclaration" || statement.source) continue; - const declaration = statement.declaration as AstNode | undefined; - if (declaration?.type === "VariableDeclaration") { - for (const declarator of (declaration.declarations as AstNode[] | undefined) ?? []) { - const exportedName = identifierName(declarator.id); - const localName = identifierName(declarator.init); - if (exportedName && localName && requested.has(exportedName)) bindings.add(localName); - } - } else { - const exportedName = identifierName(declaration?.id); - if (exportedName && requested.has(exportedName)) bindings.add(exportedName); - } - for (const specifier of (statement.specifiers as AstNode[] | undefined) ?? []) { - const exportedName = identifierName(specifier.exported); - const localName = identifierName(specifier.local); - if (exportedName && localName && requested.has(exportedName)) bindings.add(localName); - } - } - return bindings; -} - -function reexportedNames( - specifiers: readonly AstNode[], - requestedNames: string[] | null, -): string[] { - const requested = requestedNames ? new Set(requestedNames) : null; - const names = new Set(); - for (const specifier of specifiers) { - const exportedName = identifierName(specifier.exported); - const sourceName = identifierName(specifier.local); - if (sourceName && (!requested || (exportedName && requested.has(exportedName)))) { - names.add(sourceName); - } - } - return [...names]; -} - -function collectImportExpressions(node: unknown, dependencies: SourceDependency[]): void { - if (!node || typeof node !== "object") return; - if (Array.isArray(node)) { - for (const value of node) collectImportExpressions(value, dependencies); - return; - } - const astNode = node as AstNode; - if (astNode.type === "ImportExpression") { - const specifier = literalString(astNode.source); - if (specifier) dependencies.push({ names: null, specifier }); - return; - } - for (const [key, value] of Object.entries(astNode)) { - if (key === "start" || key === "end" || key === "loc") continue; - collectImportExpressions(value, dependencies); - } -} - -function collectDynamicImportSpecifiers(node: unknown, specifiers: string[]): void { - const dependencies: SourceDependency[] = []; - collectImportExpressions(node, dependencies); - specifiers.push(...dependencies.map((dependency) => dependency.specifier)); -} - -function sourceDependencies( - program: AstNode, - requestedNames: string[] | null, - includeImports: boolean, - narrowImports: boolean, -): SourceDependency[] { - const dependencies: SourceDependency[] = []; - const requestedImportBindings = - includeImports && narrowImports && requestedNames !== null - ? requestedLocalExportBindings(program, requestedNames) - : null; - for (const statement of (program.body as AstNode[] | undefined) ?? []) { - if (includeImports && statement.type === "ImportDeclaration") { - const specifier = literalString(statement.source); - if (specifier) { - const names = requestedImportBindings - ? importedNamesForLocalBindings( - (statement.specifiers as AstNode[] | undefined) ?? [], - requestedImportBindings, - ) - : importedNames((statement.specifiers as AstNode[] | undefined) ?? []); - dependencies.push({ - names, - specifier, - }); - } - continue; - } - if (statement.type === "ExportNamedDeclaration" && statement.source) { - const specifier = literalString(statement.source); - if (specifier) { - dependencies.push({ - names: reexportedNames( - (statement.specifiers as AstNode[] | undefined) ?? [], - requestedNames, - ), - specifier, - }); - } - continue; - } - if (statement.type === "ExportAllDeclaration") { - const specifier = literalString(statement.source); - if (specifier) { - const namespaceName = identifierName(statement.exported); - const names = namespaceName - ? requestedNames === null || requestedNames.includes(namespaceName) - ? null - : [] - : requestedNames; - dependencies.push({ names, specifier }); - } - continue; - } - if (includeImports && (!narrowImports || requestedNames === null)) { - collectImportExpressions(statement, dependencies); - } - } - return dependencies; -} - -function analyzeActionOwnerModule(program: AstNode): ActionOwnerModuleAnalysis { - const imports: SourceImport[] = []; - const reexports: SourceReexport[] = []; - const dynamicImports: string[] = []; - const exportLocalBindings = new Map>(); - - for (const statement of (program.body as AstNode[] | undefined) ?? []) { - if (statement.type === "ImportDeclaration") { - const specifier = literalString(statement.source); - if (!specifier) continue; - imports.push({ - bindings: ((statement.specifiers as AstNode[] | undefined) ?? []).flatMap< - SourceImport["bindings"][number] - >((item) => { - const localName = identifierName(item.local); - if (!localName) return []; - if (item.type === "ImportNamespaceSpecifier") { - return [{ importedName: null, localName }]; - } - if (item.type === "ImportDefaultSpecifier") { - return [{ importedName: "default", localName }]; - } - const importedName = identifierName(item.imported); - return importedName ? [{ importedName, localName }] : []; - }), - specifier, - }); - continue; - } - if (statement.type === "ExportNamedDeclaration" && statement.source) { - const specifier = literalString(statement.source); - if (!specifier) continue; - reexports.push({ - namespaceName: null, - specifier, - specifiers: ((statement.specifiers as AstNode[] | undefined) ?? []).flatMap((item) => { - const exportedName = identifierName(item.exported); - const sourceName = identifierName(item.local); - return exportedName && sourceName ? [{ exportedName, sourceName }] : []; - }), - }); - continue; - } - if (statement.type === "ExportAllDeclaration") { - const specifier = literalString(statement.source); - if (!specifier) continue; - reexports.push({ - namespaceName: identifierName(statement.exported), - specifier, - specifiers: [], - }); - continue; - } - collectDynamicImportSpecifiers(statement, dynamicImports); - - if (statement.type === "ExportDefaultDeclaration") { - const localName = identifierName(statement.declaration); - if (localName) exportLocalBindings.set("default", new Set([localName])); - continue; - } - if (statement.type !== "ExportNamedDeclaration" || statement.source) continue; - const declaration = statement.declaration as AstNode | undefined; - if (declaration?.type === "VariableDeclaration") { - for (const declarator of (declaration.declarations as AstNode[] | undefined) ?? []) { - const exportedName = identifierName(declarator.id); - const localName = identifierName(declarator.init); - if (exportedName && localName) exportLocalBindings.set(exportedName, new Set([localName])); - } - } else { - const exportedName = identifierName(declaration?.id); - if (exportedName) exportLocalBindings.set(exportedName, new Set([exportedName])); - } - for (const item of (statement.specifiers as AstNode[] | undefined) ?? []) { - const exportedName = identifierName(item.exported); - const localName = identifierName(item.local); - if (exportedName && localName) exportLocalBindings.set(exportedName, new Set([localName])); - } - } - - return { - dynamicImports, - exportedNames: exportedNames(program), - exportLocalBindings, - hasModuleUseServerDirective: hasModuleUseServerDirective(program), - imports, - reexports, - }; -} - -function collectedSourceDependencies( - analysis: ActionOwnerModuleAnalysis, - requestedNames: string[] | null, - includeImports: boolean, - narrowImports: boolean, -): SourceDependency[] { - const dependencies: SourceDependency[] = []; - const requestedLocalBindings = new Set(); - if (narrowImports && requestedNames) { - for (const name of requestedNames) { - for (const localName of analysis.exportLocalBindings.get(name) ?? []) { - requestedLocalBindings.add(localName); - } - } - } - - if (includeImports) { - for (const sourceImport of analysis.imports) { - const bindings = - narrowImports && requestedNames - ? sourceImport.bindings.filter((binding) => requestedLocalBindings.has(binding.localName)) - : sourceImport.bindings; - dependencies.push({ - names: bindings.some((binding) => binding.importedName === null) - ? null - : [...new Set(bindings.flatMap((binding) => binding.importedName ?? []))], - specifier: sourceImport.specifier, - }); - } - } - - for (const reexport of analysis.reexports) { - if (reexport.specifiers.length > 0) { - const requested = requestedNames ? new Set(requestedNames) : null; - dependencies.push({ - names: reexport.specifiers.flatMap(({ exportedName, sourceName }) => - !requested || requested.has(exportedName) ? [sourceName] : [], - ), - specifier: reexport.specifier, - }); - } else { - dependencies.push({ - names: reexport.namespaceName - ? requestedNames === null || requestedNames.includes(reexport.namespaceName) - ? null - : [] - : requestedNames, - specifier: reexport.specifier, - }); - } - } - - if (includeImports && (!narrowImports || requestedNames === null)) { - for (const specifier of analysis.dynamicImports) { - dependencies.push({ names: null, specifier }); - } - } - return dependencies; -} - function addOwner(manifest: Record, key: string, pattern: string): void { const owners = (manifest[key] ??= []); if (!owners.includes(pattern)) owners.push(pattern); } -function buildManifestFromModules(options: { - canonicalizeModuleId?: (id: string) => string; - mode: "development" | "production"; - modules: ReadonlyMap; - root: string; - routes: readonly ActionOwnerRoute[]; -}): Record { - const manifest: Record = {}; - const canonicalizeModuleId = options.canonicalizeModuleId ?? ((id: string) => id); - - for (const route of options.routes) { - const entryIds = actionOwnerRouteEntryIds(route).map(canonicalizeModuleId); - const entrySet = new Set(entryIds); - const queue = entryIds.map((id) => ({ id, names: null as string[] | null })); - const visited = new Set(); - - for (let index = 0; index < queue.length; index++) { - const current = queue[index]!; - const visitKey = `${current.id}\0${current.names?.join(",") ?? "*"}`; - if (visited.has(visitKey)) continue; - visited.add(visitKey); - - const module = options.modules.get(current.id); - const moduleEnvironment = module?.environments.ssr ?? module?.environments.rsc; - if (!module || !moduleEnvironment) continue; - - const moduleReferenceKey = referenceKey(options.root, module.id, options.mode); - if (entrySet.has(current.id)) addOwner(manifest, moduleReferenceKey, route.pattern); - - if (moduleEnvironment.analysis.hasModuleUseServerDirective) { - for (const name of current.names ?? moduleEnvironment.analysis.exportedNames) { - addOwner(manifest, `${moduleReferenceKey}#${name}`, route.pattern); - } - continue; - } - - const isPackageModule = module.id.includes("/node_modules/"); - const includeImports = !isPackageModule || current.names !== null; - for (const dependency of collectedSourceDependencies( - moduleEnvironment.analysis, - current.names, - includeImports, - isPackageModule, - )) { - if (dependency.names?.length === 0) continue; - const resolvedId = - module.environments.ssr?.resolvedDependencies.get(dependency.specifier) ?? - module.environments.rsc?.resolvedDependencies.get(dependency.specifier); - if (!resolvedId) continue; - queue.push({ id: canonicalizeModuleId(resolvedId), names: dependency.names }); - } - } - } - - return manifest; -} - -export function createActionOwnerModuleCollector( - options: { - parse?: (code: string) => AstNode; - } = {}, -): ActionOwnerModuleCollector { - const modules = new Map(); - const pendingModules = new Map>(); - const analyses = new Map(); - const parse = options.parse ?? ((code: string) => parseAst(code) as unknown as AstNode); - - return { - buildManifest(buildOptions) { - return buildManifestFromModules({ ...buildOptions, modules }); - }, - clear() { - modules.clear(); - pendingModules.clear(); - analyses.clear(); - }, - async collect({ code, environment, id, resolve }) { - const module = modules.get(id); - if (module?.environments[environment]) return; - const pendingKey = `${environment}\0${id}`; - const existing = pendingModules.get(pendingKey); - if (existing) return existing; - - const pending = (async () => { - const analysisKey = createHash("sha256").update(code).digest("base64url"); - let analysis = analyses.get(analysisKey); - if (!analysis) { - let program: AstNode; - try { - program = parse(code); - } catch { - return; - } - analysis = analyzeActionOwnerModule(program); - analyses.set(analysisKey, analysis); - } - - const dependencies = collectedSourceDependencies(analysis, null, true, false); - const resolvedDependencies = new Map(); - await Promise.all( - [...new Set(dependencies.map((dependency) => dependency.specifier))].map( - async (specifier) => { - let resolved: { id: string } | null; - try { - resolved = await resolve(specifier, id); - } catch { - return; - } - if (!resolved || resolved.id.includes("?")) return; - resolvedDependencies.set(specifier, resolved.id); - }, - ), - ); - const collected = modules.get(id) ?? { environments: {}, id }; - collected.environments[environment] = { analysis, resolvedDependencies }; - modules.set(id, collected); - })(); - pendingModules.set(pendingKey, pending); - await pending; - pendingModules.delete(pendingKey); - }, - size() { - return modules.size; - }, - }; -} - export function buildActionOwnerManifest(options: { canonicalizeModuleId?: (id: string) => string; moduleInfo: ModuleInfoProvider; routes: readonly ActionOwnerRoute[]; + serverReferenceConsumers: Readonly>; serverReferences: readonly ServerReferenceMeta[]; - staticOwners: Readonly>; }): Record { const manifest: Record = {}; const canonicalizeModuleId = options.canonicalizeModuleId ?? ((id: string) => id); @@ -681,16 +86,35 @@ export function buildActionOwnerManifest(options: { for (const route of options.routes) { const entryIds = actionOwnerRouteEntryIds(route); const routeComponentIds = new Set(entryIds.map(canonicalizeModuleId)); + const reachableIds = new Set(routeComponentIds); + const queue = [...routeComponentIds]; + for (let index = 0; index < queue.length; index++) { + const info = options.moduleInfo.getModuleInfo(queue[index]!); + for (const importedId of [ + ...(info?.importedIds ?? []), + ...(info?.dynamicImportedIds ?? []), + ]) { + const canonicalImportedId = canonicalizeModuleId(importedId); + if (reachableIds.has(canonicalImportedId)) continue; + reachableIds.add(canonicalImportedId); + queue.push(canonicalImportedId); + } + } + + const consumedActions = new Set(); + for (const reachableId of reachableIds) { + for (const actionId of options.serverReferenceConsumers[reachableId] ?? []) { + consumedActions.add(actionId); + } + } for (const reference of options.serverReferences) { const referenceId = canonicalizeModuleId(reference.importId); const isRouteComponent = routeComponentIds.has(referenceId); - if (isRouteComponent) { - addOwner(manifest, reference.referenceKey, route.pattern); - } + if (isRouteComponent) addOwner(manifest, reference.referenceKey, route.pattern); for (const exportName of reference.exportNames) { const actionId = `${reference.referenceKey}#${exportName}`; - if (isRouteComponent || options.staticOwners[actionId]?.includes(route.pattern)) { + if (isRouteComponent || consumedActions.has(actionId)) { addOwner(manifest, actionId, route.pattern); } } @@ -713,95 +137,3 @@ export function injectActionOwnerManifest( () => `function ${match[1]}() { return ${safeJsonStringify(manifest)}; }`, ); } - -export async function buildStaticActionOwnerManifest(options: { - mode: "development" | "production"; - root: string; - routes: readonly ActionOwnerRoute[]; - resolve: (source: string, importer?: string) => Promise<{ id: string } | null>; -}): Promise> { - const manifest: Record = {}; - const modulePrograms = new Map>(); - const resolvedDependencies = new Map>(); - - function loadProgram(id: string): Promise { - let pending = modulePrograms.get(id); - if (pending) return pending; - pending = (async () => { - let source: string; - try { - source = await fs.readFile(id, "utf8"); - } catch { - return null; - } - - const extension = path.extname(id).toLowerCase(); - try { - source = ( - await transformWithOxc(source, id, { - lang: extension === ".ts" ? "ts" : extension === ".tsx" ? "tsx" : "jsx", - jsx: { runtime: "automatic" }, - sourcemap: false, - }) - ).code; - return (await parseAstAsync(source)) as unknown as AstNode; - } catch { - return null; - } - })(); - modulePrograms.set(id, pending); - return pending; - } - - function resolveDependency(specifier: string, importer: string): Promise<{ id: string } | null> { - const key = `${importer}\0${specifier}`; - let pending = resolvedDependencies.get(key); - if (pending) return pending; - pending = options.resolve(specifier, importer).catch(() => null); - resolvedDependencies.set(key, pending); - return pending; - } - - for (const route of options.routes) { - const entryIds = actionOwnerRouteEntryIds(route); - const entrySet = new Set(entryIds); - const queue = entryIds.map((id) => ({ id, names: null as string[] | null })); - const visited = new Set(); - - for (let index = 0; index < queue.length; index++) { - const current = queue[index]!; - const visitKey = `${current.id}\0${current.names?.join(",") ?? "*"}`; - if (visited.has(visitKey)) continue; - visited.add(visitKey); - - const program = await loadProgram(current.id); - if (!program) continue; - - const moduleReferenceKey = referenceKey(options.root, current.id, options.mode); - if (entrySet.has(current.id)) addOwner(manifest, moduleReferenceKey, route.pattern); - - if (hasModuleUseServerDirective(program)) { - for (const name of current.names ?? exportedNames(program)) { - addOwner(manifest, `${moduleReferenceKey}#${name}`, route.pattern); - } - continue; - } - - const isPackageModule = current.id.includes(`${path.sep}node_modules${path.sep}`); - const includeImports = !isPackageModule || current.names !== null; - for (const dependency of sourceDependencies( - program, - current.names, - includeImports, - isPackageModule, - )) { - if (dependency.names?.length === 0) continue; - const resolved = await resolveDependency(dependency.specifier, current.id); - if (!resolved || resolved.id.includes("?")) continue; - queue.push({ id: resolved.id, names: dependency.names }); - } - } - } - - return manifest; -} diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index f0ea79f332..2b5ef755b2 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -115,7 +115,7 @@ const appPagesBridgePath = resolveEntryPath("../server/app-pages-bridge.js", imp * Passed from the Vite plugin where the full next.config.js is loaded. */ type AppRouterConfig = { - actionOwners?: Record; + actionOwners?: Record | null; redirects?: NextRedirect[]; rewrites?: { beforeFiles: NextRewrite[]; @@ -706,7 +706,7 @@ ${rootParamNameEntries.join("\n")} }; __setPagesClientAssets(__pagesClientAssets); -function __VINEXT_ACTION_OWNERS() { return ${actionOwners ? safeJsonStringify(actionOwners) : '"__VINEXT_ACTION_OWNERS_STUB__"'}; } +function __VINEXT_ACTION_OWNERS() { return ${actionOwners === undefined ? '"__VINEXT_ACTION_OWNERS_STUB__"' : safeJsonStringify(actionOwners)}; } const __appRscHandler = createAppRscHandler({ basePath: __basePath, buildId: process.env.__VINEXT_BUILD_ID ?? null, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 967074dfe4..9b7b37b992 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -69,6 +69,10 @@ import { } from "./build/route-classification-manifest.js"; import { extractMiddlewareMatcherConfig, hasExportedName } from "./build/report.js"; import { planRouteClassificationInjection } from "./build/route-classification-injector.js"; +import { + buildActionOwnerManifest, + injectActionOwnerManifest, +} from "./build/action-owner-manifest.js"; import { normalizePathnameForRouteMatchStrict } from "./routing/utils.js"; import { createRscCompatibilityId, @@ -1316,6 +1320,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // module's load hook and consumed in renderChunk to patch the generated // `__VINEXT_CLASS` stub with a real dispatch table. let rscClassificationManifest: RouteClassificationManifest | null = null; + let rscActionOwnerRoutes: Awaited> | null = null; // Resolve shim paths - works both from source (.ts) and built (.js). // Normalize to forward slashes so every downstream `path.posix.join` keeps @@ -3502,6 +3507,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // indices in the manifest correspond 1:1 to the route.layouts arrays // used during codegen. renderChunk clears this after patching. rscClassificationManifest = collectRouteClassificationManifest(routes); + rscActionOwnerRoutes = + this.environment.config.command === "build" && hasServerActions ? routes : null; return generateRscEntry( appDir, routes, @@ -3529,6 +3536,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { cacheComponents: nextConfig?.cacheComponents, prefetchInlining: nextConfig?.prefetchInlining, hasServerActions, + actionOwners: this.environment.config.command === "build" ? undefined : null, i18n: nextConfig?.i18n, imageConfig: { deviceSizes: nextConfig?.images?.deviceSizes, @@ -3636,16 +3644,18 @@ export const loadServerActionClient = ${ // by reference rather than by name. renderChunk: { order: "pre", - handler(code, chunk) { + async handler(code, chunk) { // Only run in the RSC environment. SSR/client builds never contain // the __VINEXT_CLASS stub so there is nothing to patch there, and // pulling ModuleInfo from the wrong graph would give nonsense // results. if (this.environment?.name !== "rsc") return null; - if (!rscClassificationManifest) return null; + if (!rscClassificationManifest && !rscActionOwnerRoutes) return null; // Cheap pre-filter: skip chunks that don't mention the stub at all // (e.g. the scan-phase chunk and every non-entry chunk). - if (!code.includes("__VINEXT_CLASS")) return null; + const hasClassificationStub = code.includes("__VINEXT_CLASS"); + const hasActionOwnerStub = code.includes("__VINEXT_ACTION_OWNERS_STUB__"); + if (!hasClassificationStub && !hasActionOwnerStub) return null; // Patching per-chunk (rather than scanning the whole bundle in // generateBundle) assumes the stub body and its per-route call sites @@ -3679,27 +3689,55 @@ export const loadServerActionClient = ${ }, }; - const patchPlan = planRouteClassificationInjection({ - canonicalizeLayoutPath: canonicalize, - chunks: [{ code, fileName: chunk.fileName }], - dynamicShimPaths, - enableDebugReasons: enableClassificationDebug, - manifest: rscClassificationManifest, - moduleInfo, - }); - if (patchPlan.kind === "skip") return null; + const patchPlan = + hasClassificationStub && rscClassificationManifest + ? planRouteClassificationInjection({ + canonicalizeLayoutPath: canonicalize, + chunks: [{ code, fileName: chunk.fileName }], + dynamicShimPaths, + enableDebugReasons: enableClassificationDebug, + manifest: rscClassificationManifest, + moduleInfo, + }) + : { kind: "skip" as const }; + let nextCode = patchPlan.kind === "skip" ? code : patchPlan.code; + + if (hasActionOwnerStub && rscActionOwnerRoutes) { + const rscModule = await rscPluginModulePromise; + const pluginApi = rscModule?.getPluginApi(this.environment.config); + if (!pluginApi?.manager.isScanBuild) { + const serverReferences = Object.values( + pluginApi?.manager.serverReferenceMetaMap ?? {}, + ); + const discoveredActionOwners = buildActionOwnerManifest({ + canonicalizeModuleId: canonicalize, + moduleInfo, + routes: rscActionOwnerRoutes, + serverReferenceConsumers: pluginApi?.manager.serverReferenceConsumerMap ?? {}, + serverReferences, + }); + const injected = injectActionOwnerManifest(nextCode, discoveredActionOwners); + if (!injected) { + this.error("vinext: failed to inject the server action owner manifest"); + } + nextCode = injected; + rscActionOwnerRoutes = null; + } + } + + if (patchPlan.kind === "skip" && nextCode === code) return null; // Consume the manifest exactly once per RSC entry. Clearing here // prevents a stale manifest from leaking into a subsequent build pass // if the load hook is not re-triggered (e.g., in non-standard rebuild // paths). - rscClassificationManifest = null; + if (patchPlan.kind !== "skip") rscClassificationManifest = null; // The patched body is longer than the stub, so any existing source // map would be stale. RSC entry source maps are not served or // consumed, so nulling the map is safe and prevents stale-map // confusion in tooling. - return { code: patchPlan.code, map: patchPlan.map }; + return { code: nextCode, map: null }; }, }, }, diff --git a/patches/@vitejs__plugin-rsc@0.5.27.patch b/patches/@vitejs__plugin-rsc@0.5.27.patch new file mode 100644 index 0000000000..016ca3f594 --- /dev/null +++ b/patches/@vitejs__plugin-rsc@0.5.27.patch @@ -0,0 +1,257 @@ +diff --git a/dist/plugin-BK29Va7z.js b/dist/plugin-BK29Va7z.js +--- a/dist/plugin-BK29Va7z.js ++++ b/dist/plugin-BK29Va7z.js +@@ -406,26 +406,53 @@ + } + //#endregion + //#region src/plugins/scan.ts +-function scanBuildStripPlugin({ manager }) { ++function scanBuildStripPlugin({ manager, resetEnvironmentName }) { ++ const unresolvedEdgeMap = /* @__PURE__ */ new Map(); + return { + name: "rsc:scan-strip", + apply: "build", + enforce: "post", ++ buildStart() { ++ if (manager.isScanBuild && this.environment.name === resetEnvironmentName) { ++ unresolvedEdgeMap.clear(); ++ manager.serverReferenceModuleEdgeMap = {}; ++ } ++ }, + transform: { + filter: { id: { exclude: exactRegex("\0rolldown/runtime.js") } }, +- async handler(code, _id, _options) { ++ async handler(code, id, _options) { + if (!manager.isScanBuild) return; + return { +- code: await transformScanBuildStrip(code), ++ code: await transformScanBuildStrip(code, (imports, exports) => { ++ unresolvedEdgeMap.set(id, collectServerReferenceModuleEdges(code, imports, exports)); ++ }), + map: { mappings: "" } + }; ++ } ++ }, ++ moduleParsed(info) { ++ if (!manager.isScanBuild) return; ++ const unresolved = unresolvedEdgeMap.get(info.id); ++ if (!unresolved) return; ++ unresolvedEdgeMap.delete(info.id); ++ if (info.importedIds.length !== unresolved.staticSourceOrder.length || info.dynamicallyImportedIds.length !== unresolved.dynamicSourceOrder.length) { ++ delete manager.serverReferenceModuleEdgeMap[info.id]; ++ return; + } ++ const resolvedIds = /* @__PURE__ */ new Map([ ++ ...unresolved.staticSourceOrder.map((source, index) => [source, info.importedIds[index]]), ++ ...unresolved.dynamicSourceOrder.map((source, index) => [source, info.dynamicallyImportedIds[index]]) ++ ]); ++ const imports = resolveServerReferenceEdges(unresolved.imports, resolvedIds); ++ const reexports = resolveServerReferenceEdges(unresolved.reexports, resolvedIds); ++ if (imports.length > 0 || reexports.length > 0) manager.serverReferenceModuleEdgeMap[info.id] = { imports, reexports }; + } + }; + } + const importGlobRE = /\bimport\.meta\.glob(?:<\w+>)?\s*\(/g; +-async function transformScanBuildStrip(code) { +- const [imports] = esModuleLexer.parse(code); ++async function transformScanBuildStrip(code, onImports) { ++ const [imports, exports] = esModuleLexer.parse(code); ++ onImports?.(imports, exports); + let output = imports.map((e) => e.n && `import ${JSON.stringify(e.n)};\n`).filter(Boolean).join(""); + if (importGlobRE.test(code)) { + walk(await parseAstAsync(code), { enter(node) { +@@ -437,7 +464,154 @@ + output += ""; + } + return output; ++} ++function splitCommaSeparated(value) { ++ const parts = []; ++ let start = 0; ++ let quote = ""; ++ let escaped = false; ++ for (let index = 0; index < value.length; index++) { ++ const character = value[index]; ++ if (quote) { ++ if (escaped) escaped = false; ++ else if (character === "\\") escaped = true; ++ else if (character === quote) quote = ""; ++ continue; ++ } ++ if (character === '"' || character === "'") quote = character; ++ else if (character === ",") { ++ parts.push(value.slice(start, index).trim()); ++ start = index + 1; ++ } ++ } ++ parts.push(value.slice(start).trim()); ++ return parts.filter(Boolean); ++} ++function unquoteExportName(value) { ++ const trimmed = value.trim(); ++ if (trimmed[0] === '"' || trimmed[0] === "'") { ++ try { ++ return JSON.parse(trimmed[0] === "'" ? `"${trimmed.slice(1, -1).replaceAll('"', '\\"')}"` : trimmed); ++ } catch {} ++ } ++ return trimmed; ++} ++function parseNamedSpecifiers(value) { ++ return splitCommaSeparated(value).flatMap((part) => { ++ const match = part.match(/^(.+?)(?:\s+as\s+(.+))?$/); ++ if (!match) return []; ++ const sourceName = unquoteExportName(match[1]); ++ const exportedName = unquoteExportName(match[2] ?? match[1]); ++ return sourceName && exportedName ? [{ sourceName, exportedName }] : []; ++ }); ++} ++function collectServerReferenceModuleEdges(code, imports, exports) { ++ const staticSourceOrder = []; ++ const dynamicSourceOrder = []; ++ const staticSources = /* @__PURE__ */ new Set(); ++ const dynamicSources = /* @__PURE__ */ new Set(); ++ const importsBySource = /* @__PURE__ */ new Map(); ++ const reexports = []; ++ const importedLocals = /* @__PURE__ */ new Map(); ++ for (const item of imports) { ++ if (!item.n || item.d === -2) continue; ++ if (item.d >= 0) { ++ if (!dynamicSources.has(item.n)) { ++ dynamicSources.add(item.n); ++ dynamicSourceOrder.push(item.n); ++ } ++ importsBySource.set(item.n, { sourceId: item.n, exportNames: "*" }); ++ continue; ++ } ++ if (!staticSources.has(item.n)) { ++ staticSources.add(item.n); ++ staticSourceOrder.push(item.n); ++ } ++ const declaration = code.slice(item.ss, item.se).trim(); ++ if (declaration.startsWith("export")) { ++ const namespace = declaration.match(/^export\s*\*\s+as\s+([^\s]+)\s+from\b/); ++ if (namespace) reexports.push({ sourceId: item.n, exportNames: "*", exportedName: unquoteExportName(namespace[1]) }); ++ else if (/^export\s*\*/.test(declaration)) reexports.push({ sourceId: item.n, exportNames: "*" }); ++ else { ++ const named = declaration.match(/^export\s*\{([\s\S]*?)\}\s*from\b/); ++ if (named) for (const specifier of parseNamedSpecifiers(named[1])) reexports.push({ sourceId: item.n, exportNames: [specifier.sourceName], exportedName: specifier.exportedName }); ++ } ++ continue; ++ } ++ if (!declaration.startsWith("import")) continue; ++ const beforeSource = code.slice(item.ss, item.s); ++ const clauseMatch = beforeSource.match(/^\s*import\s+([\s\S]*?)\s+from\s*["']?$/); ++ if (!clauseMatch) continue; ++ const clause = clauseMatch[1].trim(); ++ const exportNames = []; ++ let consumesNamespace = false; ++ const named = clause.match(/\{([\s\S]*?)\}/); ++ if (named) for (const specifier of parseNamedSpecifiers(named[1])) { ++ exportNames.push(specifier.sourceName); ++ importedLocals.set(specifier.exportedName, { sourceId: item.n, exportNames: [specifier.sourceName] }); ++ } ++ const namespace = clause.match(/\*\s+as\s+([\w$]+)/); ++ if (namespace) { ++ consumesNamespace = true; ++ importedLocals.set(namespace[1], { sourceId: item.n, exportNames: "*" }); ++ } ++ const defaultName = clause.match(/^([\w$]+)(?:\s*,|$)/)?.[1]; ++ if (defaultName) { ++ exportNames.push("default"); ++ importedLocals.set(defaultName, { sourceId: item.n, exportNames: ["default"] }); ++ } ++ if (exportNames.length > 0 || consumesNamespace) importsBySource.set(item.n, { sourceId: item.n, exportNames: consumesNamespace ? "*" : [...new Set(exportNames)] }); ++ } ++ for (const item of exports) { ++ if (!item.ln) continue; ++ const imported = importedLocals.get(item.ln); ++ if (imported) reexports.push({ ...imported, exportedName: item.n }); ++ } ++ const sourceOrder = [...staticSourceOrder, ...dynamicSourceOrder]; ++ return { staticSourceOrder, dynamicSourceOrder, imports: sourceOrder.flatMap((source) => importsBySource.get(source) ? [importsBySource.get(source)] : []), reexports }; + } ++function resolveServerReferenceEdges(edges, resolvedIds) { ++ return edges.flatMap((edge) => { ++ const sourceId = resolvedIds.get(edge.sourceId); ++ return sourceId ? [{ ...edge, sourceId }] : []; ++ }); ++} ++function deriveServerReferenceConsumerMap(serverReferenceMetaMap, moduleEdgeMap) { ++ const exportedReferences = /* @__PURE__ */ new Map(); ++ const resolving = /* @__PURE__ */ new Set(); ++ const resolveModuleExports = (id) => { ++ const cached = exportedReferences.get(id); ++ if (cached) return cached; ++ if (resolving.has(id)) return /* @__PURE__ */ new Map(); ++ resolving.add(id); ++ const resolved = /* @__PURE__ */ new Map(); ++ const meta = serverReferenceMetaMap[id]; ++ if (meta) for (const exportName of meta.exportNames) resolved.set(exportName, [`${meta.referenceKey}#${exportName}`]); ++ for (const edge of moduleEdgeMap[id]?.reexports ?? []) { ++ const sourceExports = resolveModuleExports(edge.sourceId); ++ if (edge.exportNames === "*") { ++ if (edge.exportedName) addResolvedServerReferences(resolved, edge.exportedName, [...sourceExports.values()].flat()); ++ else for (const [name, references] of sourceExports) if (name !== "default") addResolvedServerReferences(resolved, name, references); ++ } else if (edge.exportedName) addResolvedServerReferences(resolved, edge.exportedName, edge.exportNames.flatMap((name) => sourceExports.get(name) ?? [])); ++ } ++ resolving.delete(id); ++ exportedReferences.set(id, resolved); ++ return resolved; ++ }; ++ const result = {}; ++ for (const [consumerId, edges] of Object.entries(moduleEdgeMap)) { ++ const actionIds = /* @__PURE__ */ new Set(); ++ for (const edge of [...edges.imports, ...edges.reexports]) { ++ const sourceExports = resolveModuleExports(edge.sourceId); ++ for (const actionId of edge.exportNames === "*" ? [...sourceExports.values()].flat() : edge.exportNames.flatMap((name) => sourceExports.get(name) ?? [])) actionIds.add(actionId); ++ } ++ if (actionIds.size > 0) result[consumerId] = [...actionIds].sort(); ++ } ++ return result; ++} ++function addResolvedServerReferences(target, name, references) { ++ if (references.length > 0) target.set(name, [...target.get(name) ?? [], ...references]); ++} + //#endregion + //#region src/plugins/validate-import.ts + function validateImportPlugin() { +@@ -544,6 +718,10 @@ + clientReferenceMetaMap = {}; + clientReferenceGroups = {}; + serverReferenceMetaMap = {}; ++ serverReferenceModuleEdgeMap = {}; ++ get serverReferenceConsumerMap() { ++ return deriveServerReferenceConsumerMap(this.serverReferenceMetaMap, this.serverReferenceModuleEdgeMap); ++ } + serverResourcesMetaMap = {}; + environmentImportMetaMap = {}; + stabilize() { +@@ -620,7 +798,7 @@ + } + } + }, +- scanBuildStripPlugin({ manager }), ++ scanBuildStripPlugin({ manager, resetEnvironmentName: rscPluginOptions.environment?.rsc ?? "rsc" }), + vitePluginResolvedIdProxy() + ]; + } +@@ -1208,7 +1386,7 @@ + ...validateImportPlugin(), + apply: () => rscPluginOptions.validateImports !== false + }, +- scanBuildStripPlugin({ manager }), ++ scanBuildStripPlugin({ manager, resetEnvironmentName: rscPluginOptions.environment?.rsc ?? "rsc" }), + ...cjsModuleRunnerPlugin(), + ...globalAsyncLocalStoragePlugin() + ]; +diff --git a/dist/plugin-Bm-Fb4g7.d.ts b/dist/plugin-Bm-Fb4g7.d.ts +--- a/dist/plugin-Bm-Fb4g7.d.ts ++++ b/dist/plugin-Bm-Fb4g7.d.ts +@@ -37,6 +37,7 @@ + clientReferenceMetaMap: Record; + clientReferenceGroups: Record; + serverReferenceMetaMap: Record; ++ readonly serverReferenceConsumerMap: Record; + serverResourcesMetaMap: Record; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd785fe3a9..dfe8f7fd0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,6 +249,9 @@ overrides: packageExtensionsChecksum: sha256-DELwn26pds5KeVDWtpKSByRpbPdf/D1CYmAVTGs16Cc= +patchedDependencies: + '@vitejs/plugin-rsc@0.5.27': 21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8 + importers: .: @@ -286,7 +289,7 @@ importers: version: 7.0.0-dev.20260217.1 '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -377,7 +380,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) drizzle-kit: specifier: 'catalog:' version: 0.31.10 @@ -420,7 +423,7 @@ importers: devDependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.1 version: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)' @@ -441,7 +444,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -576,7 +579,7 @@ importers: version: 19.2.3(@types/react@19.2.16) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) postcss: specifier: 'catalog:' version: 8.5.10 @@ -615,7 +618,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -698,7 +701,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) postcss: specifier: 'catalog:' version: 8.5.10 @@ -731,7 +734,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) ms: specifier: 'catalog:' version: 3.0.0-canary.1 @@ -802,7 +805,7 @@ importers: version: 19.2.3(@types/react@19.2.16) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) typescript: specifier: 'catalog:' version: 5.9.3 @@ -897,7 +900,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -943,7 +946,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1038,7 +1041,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) am-i-vibing: specifier: 'catalog:' version: 0.5.0 @@ -1053,7 +1056,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) fake-context-lib: specifier: file:./__test_packages__/fake-context-lib version: file:tests/fixtures/app-basic/__test_packages__/fake-context-lib @@ -1087,7 +1090,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1112,7 +1115,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1139,7 +1142,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1170,7 +1173,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1207,7 +1210,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1273,7 +1276,7 @@ importers: version: 1.9.1 '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) better-auth: specifier: 'catalog:' version: 1.5.6(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.6.2)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.6.2)(kysely@0.28.15))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) @@ -1304,7 +1307,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-intl: specifier: 'catalog:' version: 4.11.1(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7)(typescript@5.9.3) @@ -1332,7 +1335,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1360,7 +1363,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-view-transitions: specifier: 'catalog:' version: 0.3.5(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1388,7 +1391,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) nuqs: specifier: 'catalog:' version: 2.8.8(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7) @@ -1425,7 +1428,7 @@ importers: version: 1.2.4(@types/react@19.2.16)(react@19.2.7) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) class-variance-authority: specifier: 'catalog:' version: 0.7.1 @@ -1481,7 +1484,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1506,7 +1509,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1531,7 +1534,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1618,7 +1621,7 @@ importers: version: 1.31.0(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(workerd@1.20260401.1)(wrangler@4.80.0) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1649,7 +1652,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1696,7 +1699,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1717,7 +1720,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -10556,7 +10559,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)' - '@vitejs/plugin-rsc@0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + '@vitejs/plugin-rsc@0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': dependencies: '@rolldown/pluginutils': 1.0.1 es-module-lexer: 2.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 397bbd4e23..44cc0bc50d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -101,6 +101,9 @@ overrides: picomatch: ">=2.3.2" srvx: ">=0.11.13" +patchedDependencies: + "@vitejs/plugin-rsc@0.5.27": patches/@vitejs__plugin-rsc@0.5.27.patch + peerDependencyRules: allowAny: - vite diff --git a/tests/action-owner-manifest.test.ts b/tests/action-owner-manifest.test.ts index 749efde016..0dbfb87ae0 100644 --- a/tests/action-owner-manifest.test.ts +++ b/tests/action-owner-manifest.test.ts @@ -1,25 +1,11 @@ -import { createHash } from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { parseAst } from "vite"; -import { afterEach, describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import type { AppRoute } from "../packages/vinext/src/routing/app-route-graph.js"; import { - actionOwnerManifestMode, actionOwnerRouteEntryIds, buildActionOwnerManifest, - buildStaticActionOwnerManifest, - createActionOwnerModuleCollector, injectActionOwnerManifest, } from "../packages/vinext/src/build/action-owner-manifest.js"; -let roots: string[] = []; - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => fs.rm(root, { force: true, recursive: true }))); -}); - function route(pattern: string, pagePath: string) { return { errorPath: null, @@ -34,215 +20,14 @@ function route(pattern: string, pagePath: string) { pagePath, parallelSlots: [], pattern, + siblingIntercepts: [], templates: [], unauthorizedPath: null, unauthorizedPaths: [], - siblingIntercepts: [], - }; -} - -async function fixture(files: Record) { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-action-owner-")); - roots.push(root); - for (const [relativePath, source] of Object.entries(files)) { - const filePath = path.join(root, relativePath); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, source); - } - return { - root, - resolve: async (specifier: string, importer?: string) => { - const candidate = path.resolve(importer ? path.dirname(importer) : root, specifier); - for (const filePath of [candidate, `${candidate}.ts`, `${candidate}.tsx`]) { - try { - if ((await fs.stat(filePath)).isFile()) return { id: filePath }; - } catch {} - } - return null; - }, }; } -function productionKey(relativePath: string): string { - return createHash("sha256").update(relativePath).digest("hex").slice(0, 12); -} - describe("server action owner manifest", () => { - it("treats Vite preview as production despite command serve", () => { - expect(actionOwnerManifestMode({ command: "serve", isPreview: false })).toBe("development"); - expect(actionOwnerManifestMode({ command: "serve", isPreview: true })).toBe("production"); - expect(actionOwnerManifestMode({ command: "build" })).toBe("production"); - }); - it("maps only imported action exports to each owning route", async () => { - const app = await fixture({ - "app/actions.ts": `'use server';\nexport async function publicOnly() {}\nexport async function adminOnly() {}\n`, - "app/admin/page.tsx": `import { adminOnly } from "../actions"; export default function Page() { return adminOnly; }`, - "app/page.tsx": `import { publicOnly } from "./actions"; export default function Page() { return publicOnly; }`, - }); - - const manifest = await buildStaticActionOwnerManifest({ - mode: "production", - root: app.root, - routes: [ - route("/", path.join(app.root, "app/page.tsx")), - route("/admin", path.join(app.root, "app/admin/page.tsx")), - ], - resolve: app.resolve, - }); - - const actionKey = productionKey("app/actions.ts"); - expect(manifest[`${actionKey}#publicOnly`]).toEqual(["/"]); - expect(manifest[`${actionKey}#adminOnly`]).toEqual(["/admin"]); - }); - - it("uses the unchanged plugin-rsc production and development reference keys", async () => { - const app = await fixture({ - "app/actions.ts": `'use server'; export default async function action() {}`, - "app/page.tsx": `import action from "./actions"; export default function Page() { return action; }`, - }); - const pagePath = path.join(app.root, "app/page.tsx"); - - const production = await buildStaticActionOwnerManifest({ - mode: "production", - root: app.root, - routes: [route("/", pagePath)], - resolve: app.resolve, - }); - const development = await buildStaticActionOwnerManifest({ - mode: "development", - root: app.root, - routes: [route("/", pagePath)], - resolve: app.resolve, - }); - - expect(production[`${productionKey("app/actions.ts")}#default`]).toEqual(["/"]); - expect(development["/app/actions.ts#default"]).toEqual(["/"]); - }); - - it("builds production ownership from already-transformed scan modules", async () => { - const root = "/project"; - const sources = new Map([ - [ - "/project/app/page.js", - `import { publicOnly } from "./actions.js"; export default function Page() { return publicOnly; }`, - ], - [ - "/project/app/admin/page.js", - `import { adminOnly } from "../actions.js"; export default function Page() { return adminOnly; }`, - ], - [ - "/project/app/actions.js", - `'use server'; export async function publicOnly() {} export async function adminOnly() {}`, - ], - ]); - const collector = createActionOwnerModuleCollector(); - const resolve = async (specifier: string, importer: string) => ({ - id: path.posix.resolve(path.posix.dirname(importer), specifier), - }); - - await Promise.all( - [...sources].map(([id, code]) => - collector.collect({ code, environment: "ssr", id, resolve }), - ), - ); - - const manifest = collector.buildManifest({ - mode: "production", - root, - routes: [route("/", "/project/app/page.js"), route("/admin", "/project/app/admin/page.js")], - }); - - const actionKey = productionKey("app/actions.js"); - expect(manifest[`${actionKey}#publicOnly`]).toEqual(["/"]); - expect(manifest[`${actionKey}#adminOnly`]).toEqual(["/admin"]); - }); - - it("parses each scan module once and resets between builds", async () => { - let parseCount = 0; - const collector = createActionOwnerModuleCollector({ - parse(code) { - parseCount++; - return parseAst(code) as never; - }, - }); - const options = { - code: `export default function Page() {}`, - environment: "ssr" as const, - id: "/project/app/page.js", - resolve: async () => null, - }; - - await Promise.all([collector.collect(options), collector.collect(options)]); - expect(parseCount).toBe(1); - expect(collector.size()).toBe(1); - - collector.clear(); - expect(collector.size()).toBe(0); - await collector.collect(options); - expect(parseCount).toBe(2); - }); - - it("prefers SSR dependency resolution while retaining RSC-only modules", async () => { - const collector = createActionOwnerModuleCollector(); - const pageCode = `import { action } from "conditional-actions"; export default function Page() { return action; }`; - await collector.collect({ - code: pageCode, - environment: "rsc", - id: "/project/app/page.js", - resolve: async () => ({ id: "/project/rsc-actions.js" }), - }); - await collector.collect({ - code: pageCode, - environment: "ssr", - id: "/project/app/page.js", - resolve: async () => ({ id: "/project/ssr-actions.js" }), - }); - await collector.collect({ - code: `'use server'; export async function action() {}`, - environment: "ssr", - id: "/project/ssr-actions.js", - resolve: async () => null, - }); - - const manifest = collector.buildManifest({ - mode: "production", - root: "/project", - routes: [route("/", "/project/app/page.js")], - }); - expect(manifest[`${productionKey("ssr-actions.js")}#action`]).toEqual(["/"]); - expect(manifest[`${productionKey("rsc-actions.js")}#action`]).toBeUndefined(); - }); - - it("narrows package imports with normalized Windows module ids", async () => { - const collector = createActionOwnerModuleCollector(); - await collector.collect({ - code: `import { publicOnly } from "pkg"; export default publicOnly;`, - environment: "ssr", - id: "C:/project/app/page.js", - resolve: async () => ({ id: "C:/project/node_modules/pkg/index.js" }), - }); - await collector.collect({ - code: `import { publicOnly, adminOnly } from "./actions.js"; export { publicOnly, adminOnly };`, - environment: "ssr", - id: "C:/project/node_modules/pkg/index.js", - resolve: async () => ({ id: "C:/project/node_modules/pkg/actions.js" }), - }); - await collector.collect({ - code: `'use server'; export async function publicOnly() {} export async function adminOnly() {}`, - environment: "ssr", - id: "C:/project/node_modules/pkg/actions.js", - resolve: async () => null, - }); - - const manifest = collector.buildManifest({ - mode: "production", - root: "C:/project", - routes: [route("/", "C:/project/app/page.js")], - }); - expect(Object.keys(manifest).some((key) => key.endsWith("#publicOnly"))).toBe(true); - expect(Object.keys(manifest).some((key) => key.endsWith("#adminOnly"))).toBe(false); - }); - it("escapes action owner manifests embedded in generated JavaScript", () => { const injected = injectActionOwnerManifest( `function __VINEXT_ACTION_OWNERS() { return "__VINEXT_ACTION_OWNERS_STUB__"; }`, @@ -256,225 +41,37 @@ describe("server action owner manifest", () => { expect(injected).not.toContain(""); }); - it("traces aliased re-exports to the original server action export", async () => { - const app = await fixture({ - "app/actions.ts": `'use server'; export async function original() {}`, - "app/barrel.ts": `export { original as exposed } from "./actions";`, - "app/page.tsx": `import { exposed } from "./barrel"; export default function Page() { return exposed; }`, - }); - - const manifest = await buildStaticActionOwnerManifest({ - mode: "production", - root: app.root, - routes: [route("/", path.join(app.root, "app/page.tsx"))], - resolve: app.resolve, - }); - - expect(manifest[`${productionKey("app/actions.ts")}#original`]).toEqual(["/"]); - }); - - it("does not authorize sibling exports from a shared barrel", async () => { - const app = await fixture({ - "app/actions.ts": `'use server'; export async function publicOnly() {} export async function adminOnly() {}`, - "app/barrel.ts": `export { publicOnly, adminOnly } from "./actions";`, - "app/page.tsx": `import { publicOnly } from "./barrel"; export default function Page() { return publicOnly; }`, - }); - - const manifest = await buildStaticActionOwnerManifest({ - mode: "production", - root: app.root, - routes: [route("/", path.join(app.root, "app/page.tsx"))], - resolve: app.resolve, - }); - - const actionKey = productionKey("app/actions.ts"); - expect(manifest[`${actionKey}#publicOnly`]).toEqual(["/"]); - expect(manifest[`${actionKey}#adminOnly`]).toBeUndefined(); - }); - - it("follows namespace re-exports only when the namespace is consumed", async () => { - const app = await fixture({ - "app/actions.ts": `'use server'; export async function first() {} export async function second() {}`, - "app/barrel.ts": `export * as actions from "./actions";`, - "app/page.tsx": `import { actions } from "./barrel"; export default function Page() { return actions.first; }`, - }); - - const manifest = await buildStaticActionOwnerManifest({ - mode: "production", - root: app.root, - routes: [route("/", path.join(app.root, "app/page.tsx"))], - resolve: app.resolve, - }); - - const actionKey = productionKey("app/actions.ts"); - expect(manifest[`${actionKey}#first`]).toEqual(["/"]); - expect(manifest[`${actionKey}#second`]).toEqual(["/"]); - }); - - // Ported from Next.js: test/e2e/app-dir/app-external/app/action/client/page.js - // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-external/app/action/client/page.js - it("maps server actions imported from packages", async () => { - const app = await fixture({ - "app/page.tsx": `import { action1 } from "server-action-mod"; export default function Page() { return action1; }`, - "node_modules/server-action-mod/index.js": `'use server'; export async function action1() {}`, - "node_modules/server-action-mod/package.json": `{"name":"server-action-mod","type":"module"}`, - }); - const packageEntry = path.join(app.root, "node_modules/server-action-mod/index.js"); - const resolve = async (specifier: string, importer?: string) => { - if (specifier === "server-action-mod") return { id: packageEntry }; - return app.resolve(specifier, importer); - }; - - const manifest = await buildStaticActionOwnerManifest({ - mode: "production", - root: app.root, - routes: [route("/", path.join(app.root, "app/page.tsx"))], - resolve, - }); - - expect(manifest[`${productionKey("node_modules/server-action-mod/index.js")}#action1`]).toEqual( - ["/"], - ); - }); - - it("follows narrowed imports through package intermediaries in development", async () => { - const app = await fixture({ - "app/page.tsx": `import { action1 } from "server-action-wrapper"; export default function Page() { return action1; }`, - "node_modules/server-action-wrapper/index.js": `import { action1 } from "server-action-mod"; export { action1 };`, - "node_modules/server-action-wrapper/package.json": `{"name":"server-action-wrapper","type":"module"}`, - "node_modules/server-action-mod/index.js": `'use server'; export async function action1() {}`, - "node_modules/server-action-mod/package.json": `{"name":"server-action-mod","type":"module"}`, - }); - const wrapperEntry = path.join(app.root, "node_modules/server-action-wrapper/index.js"); - const actionEntry = path.join(app.root, "node_modules/server-action-mod/index.js"); - const resolve = async (specifier: string, importer?: string) => { - if (specifier === "server-action-wrapper") return { id: wrapperEntry }; - if (specifier === "server-action-mod") return { id: actionEntry }; - return app.resolve(specifier, importer); + it("maps exact consumed action exports to each owning route", () => { + const moduleGraph: Record = { + "/app/page.tsx": ["/app/actions.ts"], + "/app/admin/page.tsx": ["/app/admin-consumer.ts"], + "/app/admin-consumer.ts": ["/app/actions.ts"], }; - const manifest = await buildStaticActionOwnerManifest({ - mode: "development", - root: app.root, - routes: [route("/", path.join(app.root, "app/page.tsx"))], - resolve, - }); - expect( - manifest[`/${path.relative(app.root, actionEntry).replaceAll(path.sep, "/")}#action1`], - ).toEqual(["/"]); - }); - - it.each([ - [ - "default export", - `import action from "server-action-mod"; export default action;`, - `import action from "server-action-wrapper"; export default function Page() { return action; }`, - "default", - ], - [ - "exported declaration", - `import { action1 } from "server-action-mod"; export const wrapped = action1;`, - `import { wrapped } from "server-action-wrapper"; export default function Page() { return wrapped; }`, - "action1", - ], - ])( - "follows %s package intermediaries in development", - async (_name, wrapperSource, pageSource, exportName) => { - const app = await fixture({ - "app/page.tsx": pageSource, - "node_modules/server-action-wrapper/index.js": wrapperSource, - "node_modules/server-action-wrapper/package.json": `{"name":"server-action-wrapper","type":"module"}`, - "node_modules/server-action-mod/index.js": `'use server'; export default async function action() {} export async function action1() {}`, - "node_modules/server-action-mod/package.json": `{"name":"server-action-mod","type":"module"}`, - }); - const wrapperEntry = path.join(app.root, "node_modules/server-action-wrapper/index.js"); - const actionEntry = path.join(app.root, "node_modules/server-action-mod/index.js"); - const resolve = async (specifier: string, importer?: string) => { - if (specifier === "server-action-wrapper") return { id: wrapperEntry }; - if (specifier === "server-action-mod") return { id: actionEntry }; - return app.resolve(specifier, importer); - }; - - const manifest = await buildStaticActionOwnerManifest({ - mode: "development", - root: app.root, - routes: [route("/", path.join(app.root, "app/page.tsx"))], - resolve, - }); - - expect( - manifest[ - `/${path.relative(app.root, actionEntry).replaceAll(path.sep, "/")}#${exportName}` + buildActionOwnerManifest({ + moduleInfo: { + getModuleInfo(id) { + return { importedIds: moduleGraph[id] ?? [] }; + }, + }, + routes: [route("/", "/app/page.tsx"), route("/admin", "/app/admin/page.tsx")], + serverReferenceConsumers: { + "/app/page.tsx": ["action-key#publicOnly"], + "/app/admin-consumer.ts": ["action-key#adminOnly"], + }, + serverReferences: [ + { + exportNames: ["publicOnly", "adminOnly", "unrelated"], + importId: "/app/actions.ts", + referenceKey: "action-key", + }, ], - ).toEqual(["/"]); - }, - ); - - it("does not assign unrelated package actions through a narrowed intermediary", async () => { - const app = await fixture({ - "app/page.tsx": `import { action1 } from "server-action-wrapper"; export default function Page() { return action1; }`, - "node_modules/server-action-wrapper/index.js": `import { action1, action2 } from "server-action-mod"; export { action1, action2 };`, - "node_modules/server-action-wrapper/package.json": `{"name":"server-action-wrapper","type":"module"}`, - "node_modules/server-action-mod/index.js": `'use server'; export async function action1() {} export async function action2() {}`, - "node_modules/server-action-mod/package.json": `{"name":"server-action-mod","type":"module"}`, - }); - const wrapperEntry = path.join(app.root, "node_modules/server-action-wrapper/index.js"); - const actionEntry = path.join(app.root, "node_modules/server-action-mod/index.js"); - const resolve = async (specifier: string, importer?: string) => { - if (specifier === "server-action-wrapper") return { id: wrapperEntry }; - if (specifier === "server-action-mod") return { id: actionEntry }; - return app.resolve(specifier, importer); - }; - const manifest = await buildStaticActionOwnerManifest({ - mode: "development", - root: app.root, - routes: [route("/", path.join(app.root, "app/page.tsx"))], - resolve, - }); - const actionKey = `/${path.relative(app.root, actionEntry).replaceAll(path.sep, "/")}`; - expect(manifest[`${actionKey}#action1`]).toEqual(["/"]); - expect(manifest[`${actionKey}#action2`]).toBeUndefined(); - }); - - it("does not mistake inline action directives for module-level directives", async () => { - const app = await fixture({ - "app/page.tsx": `export default function Page() {\n async function action() {\n "use server";\n }\n return action;\n}`, - }); - const pagePath = path.join(app.root, "app/page.tsx"); - - const manifest = await buildStaticActionOwnerManifest({ - mode: "production", - root: app.root, - routes: [route("/", pagePath)], - resolve: app.resolve, - }); - const pageKey = productionKey("app/page.tsx"); - - expect(manifest[pageKey]).toEqual(["/"]); - expect(manifest[`${pageKey}#default`]).toBeUndefined(); - }); - - it("ignores type-only and commented imports when assigning action ownership", async () => { - const app = await fixture({ - "app/admin-actions.ts": `'use server'; export async function adminOnly() {}`, - "app/page.tsx": `import type { adminOnly } from "./admin-actions"; -// import { adminOnly } from "./admin-actions"; -const example = 'import { adminOnly } from "./admin-actions"'; -const template = \` -import { adminOnly } from "./admin-actions"; -\`; -export default function Page() { return example + template; }`, - }); - - const manifest = await buildStaticActionOwnerManifest({ - mode: "production", - root: app.root, - routes: [route("/", path.join(app.root, "app/page.tsx"))], - resolve: app.resolve, + }), + ).toEqual({ + "action-key#adminOnly": ["/admin"], + "action-key#publicOnly": ["/"], }); - - expect(manifest[`${productionKey("app/admin-actions.ts")}#adminOnly`]).toBeUndefined(); }); it("uses final plugin metadata only for actions defined in route components", () => { @@ -486,6 +83,7 @@ export default function Page() { return example + template; }`, }, }, routes: [route("/", "/app/page.tsx")], + serverReferenceConsumers: {}, serverReferences: [ { exportNames: ["$$hoist_0_inline"], @@ -498,7 +96,6 @@ export default function Page() { return example + template; }`, referenceKey: "imported-key", }, ], - staticOwners: {}, }), ).toEqual({ "inline-key": ["/"], @@ -515,6 +112,9 @@ export default function Page() { return example + template; }`, }, }, routes: [route("/", "/app/page.tsx")], + serverReferenceConsumers: { + "/app/page.tsx": ["admin-key#adminOnly"], + }, serverReferences: [ { exportNames: ["adminOnly"], @@ -522,7 +122,6 @@ export default function Page() { return example + template; }`, referenceKey: "admin-key", }, ], - staticOwners: { "admin-key#adminOnly": ["/"] }, }), ).toEqual({ "admin-key#adminOnly": ["/"] }); }); @@ -538,6 +137,9 @@ export default function Page() { return example + template; }`, }, }, routes: [route("/", "/app/page.tsx")], + serverReferenceConsumers: { + "/app/actions.ts": ["action-key#submit"], + }, serverReferences: [ { exportNames: ["submit"], @@ -545,7 +147,6 @@ export default function Page() { return example + template; }`, referenceKey: "action-key", }, ], - staticOwners: { "action-key#submit": ["/"] }, }), ).toEqual({ "action-key#submit": ["/"] }); }); @@ -583,9 +184,6 @@ export default function Page() { return example + template; }`, pagePath: "/app/@slot/page.tsx", } as unknown as AppRoute["parallelSlots"][number], ], - templates: ["/app/template.tsx"], - unauthorizedPath: "/app/unauthorized.tsx", - unauthorizedPaths: ["/app/nested-unauthorized.tsx"], siblingIntercepts: [ { convention: "(.)", @@ -596,6 +194,9 @@ export default function Page() { return example + template; }`, targetPattern: "/modal", }, ], + templates: ["/app/template.tsx"], + unauthorizedPath: "/app/unauthorized.tsx", + unauthorizedPaths: ["/app/nested-unauthorized.tsx"], }); expect(entryIds).toEqual([ diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 932db7205d..bf05bcd017 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -979,11 +979,23 @@ describe("App Router entry templates", () => { expect(code).toContain("const __loadAppActionForwarding = () => import("); expect(code).toContain("forwardServerActionIfNeeded: __forwardServerActionIfNeeded"); expect(code).toContain("await __forwardServerActionIfNeeded({"); + expect(code).toContain( + 'function __VINEXT_ACTION_OWNERS() { return "__VINEXT_ACTION_OWNERS_STUB__"; }', + ); expect(code).not.toContain("next/dist/server/web/spec-extension/cookies"); expect(code).not.toContain("function __mergeActionForwardCookies"); expect(code).not.toContain("const __ACTION_FORWARD_FORBIDDEN_HEADERS"); }); + it("generateRscEntry disables action forwarding when ownership is unavailable", () => { + const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false, { + actionOwners: null, + }); + + expect(code).toContain("function __VINEXT_ACTION_OWNERS() { return null; }"); + expect(code).not.toContain("__VINEXT_ACTION_OWNERS_STUB__"); + }); + it("generateRscEntry omits server action imports when no server references were found", () => { const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false, { hasServerActions: false, From c9e6041d3a29fc716623a5c23f7e71524591a190 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 6 Jul 2026 15:38:45 +0100 Subject: [PATCH 09/17] perf(app-router): narrow RSC action metadata patch --- .../vinext/src/build/action-owner-manifest.ts | 111 +++++++++++++++++- packages/vinext/src/index.ts | 2 +- patches/@vitejs__plugin-rsc@0.5.27.patch | 78 +++++------- pnpm-lock.yaml | 66 +++++------ tests/action-owner-manifest.test.ts | 66 +++++++++-- 5 files changed, 232 insertions(+), 91 deletions(-) diff --git a/packages/vinext/src/build/action-owner-manifest.ts b/packages/vinext/src/build/action-owner-manifest.ts index 7efb178e98..1a9a679a08 100644 --- a/packages/vinext/src/build/action-owner-manifest.ts +++ b/packages/vinext/src/build/action-owner-manifest.ts @@ -27,6 +27,17 @@ type ServerReferenceMeta = { referenceKey: string; }; +type ServerReferenceModuleEdge = { + exportNames: readonly string[] | "*"; + exportedName?: string; + sourceId: string; +}; + +type ServerReferenceModuleEdges = { + imports: readonly ServerReferenceModuleEdge[]; + reexports: readonly ServerReferenceModuleEdge[]; +}; + type ModuleInfoProvider = { getModuleInfo(id: string): { dynamicImportedIds?: readonly string[]; @@ -73,15 +84,111 @@ function addOwner(manifest: Record, key: string, pattern: stri if (!owners.includes(pattern)) owners.push(pattern); } +function deriveServerReferenceConsumerMap(options: { + canonicalizeModuleId: (id: string) => string; + moduleEdges: Readonly>; + serverReferences: readonly ServerReferenceMeta[]; +}): Record { + const serverReferences = new Map( + options.serverReferences.map((reference) => [ + options.canonicalizeModuleId(reference.importId), + reference, + ]), + ); + const moduleEdges = new Map( + Object.entries(options.moduleEdges).map(([id, edges]) => [ + options.canonicalizeModuleId(id), + { + imports: edges.imports.map((edge) => ({ + ...edge, + sourceId: options.canonicalizeModuleId(edge.sourceId), + })), + reexports: edges.reexports.map((edge) => ({ + ...edge, + sourceId: options.canonicalizeModuleId(edge.sourceId), + })), + }, + ]), + ); + const exportedActions = new Map>(); + const resolving = new Set(); + + const resolveModuleExports = (id: string): Map => { + const cached = exportedActions.get(id); + if (cached) return cached; + if (resolving.has(id)) return new Map(); + resolving.add(id); + + const resolved = new Map(); + const reference = serverReferences.get(id); + if (reference) { + for (const exportName of reference.exportNames) { + resolved.set(exportName, [`${reference.referenceKey}#${exportName}`]); + } + } + + for (const edge of moduleEdges.get(id)?.reexports ?? []) { + const sourceExports = resolveModuleExports(edge.sourceId); + if (edge.exportNames === "*") { + if (edge.exportedName) { + addResolvedActions(resolved, edge.exportedName, [...sourceExports.values()].flat()); + } else { + for (const [name, actionIds] of sourceExports) { + if (name !== "default") addResolvedActions(resolved, name, actionIds); + } + } + } else if (edge.exportedName) { + addResolvedActions( + resolved, + edge.exportedName, + edge.exportNames.flatMap((name) => sourceExports.get(name) ?? []), + ); + } + } + + resolving.delete(id); + exportedActions.set(id, resolved); + return resolved; + }; + + const consumers: Record = {}; + for (const [consumerId, edges] of moduleEdges) { + const actionIds = new Set(); + for (const edge of [...edges.imports, ...edges.reexports]) { + const sourceExports = resolveModuleExports(edge.sourceId); + const consumed = + edge.exportNames === "*" + ? [...sourceExports.values()].flat() + : edge.exportNames.flatMap((name) => sourceExports.get(name) ?? []); + for (const actionId of consumed) actionIds.add(actionId); + } + if (actionIds.size > 0) consumers[consumerId] = [...actionIds].sort(); + } + return consumers; +} + +function addResolvedActions( + target: Map, + name: string, + actionIds: readonly string[], +): void { + if (actionIds.length > 0) target.set(name, [...(target.get(name) ?? []), ...actionIds]); +} + export function buildActionOwnerManifest(options: { canonicalizeModuleId?: (id: string) => string; moduleInfo: ModuleInfoProvider; routes: readonly ActionOwnerRoute[]; - serverReferenceConsumers: Readonly>; + serverReferenceModuleEdges: Readonly>; serverReferences: readonly ServerReferenceMeta[]; }): Record { const manifest: Record = {}; const canonicalizeModuleId = options.canonicalizeModuleId ?? ((id: string) => id); + const serverReferenceConsumers = deriveServerReferenceConsumerMap({ + canonicalizeModuleId, + moduleEdges: options.serverReferenceModuleEdges, + serverReferences: options.serverReferences, + }); for (const route of options.routes) { const entryIds = actionOwnerRouteEntryIds(route); @@ -103,7 +210,7 @@ export function buildActionOwnerManifest(options: { const consumedActions = new Set(); for (const reachableId of reachableIds) { - for (const actionId of options.serverReferenceConsumers[reachableId] ?? []) { + for (const actionId of serverReferenceConsumers[reachableId] ?? []) { consumedActions.add(actionId); } } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 9b7b37b992..cb2fd4932a 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3713,7 +3713,7 @@ export const loadServerActionClient = ${ canonicalizeModuleId: canonicalize, moduleInfo, routes: rscActionOwnerRoutes, - serverReferenceConsumers: pluginApi?.manager.serverReferenceConsumerMap ?? {}, + serverReferenceModuleEdges: pluginApi?.manager.serverReferenceModuleEdgeMap ?? {}, serverReferences, }); const injected = injectActionOwnerManifest(nextCode, discoveredActionOwners); diff --git a/patches/@vitejs__plugin-rsc@0.5.27.patch b/patches/@vitejs__plugin-rsc@0.5.27.patch index 016ca3f594..2d13761807 100644 --- a/patches/@vitejs__plugin-rsc@0.5.27.patch +++ b/patches/@vitejs__plugin-rsc@0.5.27.patch @@ -60,7 +60,7 @@ diff --git a/dist/plugin-BK29Va7z.js b/dist/plugin-BK29Va7z.js let output = imports.map((e) => e.n && `import ${JSON.stringify(e.n)};\n`).filter(Boolean).join(""); if (importGlobRE.test(code)) { walk(await parseAstAsync(code), { enter(node) { -@@ -437,7 +464,154 @@ +@@ -437,7 +464,118 @@ output += ""; } return output; @@ -86,7 +86,7 @@ diff --git a/dist/plugin-BK29Va7z.js b/dist/plugin-BK29Va7z.js + } + parts.push(value.slice(start).trim()); + return parts.filter(Boolean); -+} + } +function unquoteExportName(value) { + const trimmed = value.trim(); + if (trimmed[0] === '"' || trimmed[0] === "'") { @@ -169,64 +169,25 @@ diff --git a/dist/plugin-BK29Va7z.js b/dist/plugin-BK29Va7z.js + } + const sourceOrder = [...staticSourceOrder, ...dynamicSourceOrder]; + return { staticSourceOrder, dynamicSourceOrder, imports: sourceOrder.flatMap((source) => importsBySource.get(source) ? [importsBySource.get(source)] : []), reexports }; - } ++} +function resolveServerReferenceEdges(edges, resolvedIds) { + return edges.flatMap((edge) => { + const sourceId = resolvedIds.get(edge.sourceId); + return sourceId ? [{ ...edge, sourceId }] : []; + }); -+} -+function deriveServerReferenceConsumerMap(serverReferenceMetaMap, moduleEdgeMap) { -+ const exportedReferences = /* @__PURE__ */ new Map(); -+ const resolving = /* @__PURE__ */ new Set(); -+ const resolveModuleExports = (id) => { -+ const cached = exportedReferences.get(id); -+ if (cached) return cached; -+ if (resolving.has(id)) return /* @__PURE__ */ new Map(); -+ resolving.add(id); -+ const resolved = /* @__PURE__ */ new Map(); -+ const meta = serverReferenceMetaMap[id]; -+ if (meta) for (const exportName of meta.exportNames) resolved.set(exportName, [`${meta.referenceKey}#${exportName}`]); -+ for (const edge of moduleEdgeMap[id]?.reexports ?? []) { -+ const sourceExports = resolveModuleExports(edge.sourceId); -+ if (edge.exportNames === "*") { -+ if (edge.exportedName) addResolvedServerReferences(resolved, edge.exportedName, [...sourceExports.values()].flat()); -+ else for (const [name, references] of sourceExports) if (name !== "default") addResolvedServerReferences(resolved, name, references); -+ } else if (edge.exportedName) addResolvedServerReferences(resolved, edge.exportedName, edge.exportNames.flatMap((name) => sourceExports.get(name) ?? [])); -+ } -+ resolving.delete(id); -+ exportedReferences.set(id, resolved); -+ return resolved; -+ }; -+ const result = {}; -+ for (const [consumerId, edges] of Object.entries(moduleEdgeMap)) { -+ const actionIds = /* @__PURE__ */ new Set(); -+ for (const edge of [...edges.imports, ...edges.reexports]) { -+ const sourceExports = resolveModuleExports(edge.sourceId); -+ for (const actionId of edge.exportNames === "*" ? [...sourceExports.values()].flat() : edge.exportNames.flatMap((name) => sourceExports.get(name) ?? [])) actionIds.add(actionId); -+ } -+ if (actionIds.size > 0) result[consumerId] = [...actionIds].sort(); -+ } -+ return result; -+} -+function addResolvedServerReferences(target, name, references) { -+ if (references.length > 0) target.set(name, [...target.get(name) ?? [], ...references]); +} //#endregion //#region src/plugins/validate-import.ts function validateImportPlugin() { -@@ -544,6 +718,10 @@ +@@ -544,6 +682,7 @@ clientReferenceMetaMap = {}; clientReferenceGroups = {}; serverReferenceMetaMap = {}; + serverReferenceModuleEdgeMap = {}; -+ get serverReferenceConsumerMap() { -+ return deriveServerReferenceConsumerMap(this.serverReferenceMetaMap, this.serverReferenceModuleEdgeMap); -+ } serverResourcesMetaMap = {}; environmentImportMetaMap = {}; stabilize() { -@@ -620,7 +798,7 @@ +@@ -620,7 +759,7 @@ } } }, @@ -235,7 +196,7 @@ diff --git a/dist/plugin-BK29Va7z.js b/dist/plugin-BK29Va7z.js vitePluginResolvedIdProxy() ]; } -@@ -1208,7 +1386,7 @@ +@@ -1208,7 +1347,7 @@ ...validateImportPlugin(), apply: () => rscPluginOptions.validateImports !== false }, @@ -247,11 +208,34 @@ diff --git a/dist/plugin-BK29Va7z.js b/dist/plugin-BK29Va7z.js diff --git a/dist/plugin-Bm-Fb4g7.d.ts b/dist/plugin-Bm-Fb4g7.d.ts --- a/dist/plugin-Bm-Fb4g7.d.ts +++ b/dist/plugin-Bm-Fb4g7.d.ts -@@ -37,6 +37,7 @@ +@@ -25,6 +25,15 @@ + referenceKey: string; + exportNames: string[]; + }; ++type ServerReferenceModuleEdge = { ++ sourceId: string; ++ exportNames: readonly string[] | "*"; ++ exportedName?: string; ++}; ++type ServerReferenceModuleEdges = { ++ imports: readonly ServerReferenceModuleEdge[]; ++ reexports: readonly ServerReferenceModuleEdge[]; ++}; + /** + * @experimental + */ +@@ -37,6 +46,7 @@ clientReferenceMetaMap: Record; clientReferenceGroups: Record; serverReferenceMetaMap: Record; -+ readonly serverReferenceConsumerMap: Record; ++ serverReferenceModuleEdgeMap: Record; serverResourcesMetaMap: Record; +@@ -182,4 +192,4 @@ + output: MagicString; + } | undefined>; + //#endregion +-export { ResolvedAssetsManifest as a, getPluginApi as c, vitePluginRscMinimal as d, ResolvedAssetDeps as i, transformRscCssExport as l, AssetsManifest as n, RscPluginManager as o, PluginApi as r, RscPluginOptions as s, AssetDeps as t, vitePluginRsc as u }; +\ No newline at end of file ++export { ResolvedAssetsManifest as a, getPluginApi as c, vitePluginRscMinimal as d, ResolvedAssetDeps as i, transformRscCssExport as l, AssetsManifest as n, RscPluginManager as o, PluginApi as r, RscPluginOptions as s, AssetDeps as t, vitePluginRsc as u }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfe8f7fd0b..b90a9dd502 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,7 +250,7 @@ overrides: packageExtensionsChecksum: sha256-DELwn26pds5KeVDWtpKSByRpbPdf/D1CYmAVTGs16Cc= patchedDependencies: - '@vitejs/plugin-rsc@0.5.27': 21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8 + '@vitejs/plugin-rsc@0.5.27': 97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7 importers: @@ -289,7 +289,7 @@ importers: version: 7.0.0-dev.20260217.1 '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -380,7 +380,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) drizzle-kit: specifier: 'catalog:' version: 0.31.10 @@ -423,7 +423,7 @@ importers: devDependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.1 version: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)' @@ -444,7 +444,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -579,7 +579,7 @@ importers: version: 19.2.3(@types/react@19.2.16) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) postcss: specifier: 'catalog:' version: 8.5.10 @@ -618,7 +618,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -701,7 +701,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) postcss: specifier: 'catalog:' version: 8.5.10 @@ -734,7 +734,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) ms: specifier: 'catalog:' version: 3.0.0-canary.1 @@ -805,7 +805,7 @@ importers: version: 19.2.3(@types/react@19.2.16) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) typescript: specifier: 'catalog:' version: 5.9.3 @@ -900,7 +900,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -946,7 +946,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1041,7 +1041,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) am-i-vibing: specifier: 'catalog:' version: 0.5.0 @@ -1056,7 +1056,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) fake-context-lib: specifier: file:./__test_packages__/fake-context-lib version: file:tests/fixtures/app-basic/__test_packages__/fake-context-lib @@ -1090,7 +1090,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1115,7 +1115,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1142,7 +1142,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1173,7 +1173,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1210,7 +1210,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1276,7 +1276,7 @@ importers: version: 1.9.1 '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) better-auth: specifier: 'catalog:' version: 1.5.6(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.6.2)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.6.2)(kysely@0.28.15))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) @@ -1307,7 +1307,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-intl: specifier: 'catalog:' version: 4.11.1(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7)(typescript@5.9.3) @@ -1335,7 +1335,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1363,7 +1363,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-view-transitions: specifier: 'catalog:' version: 0.3.5(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1391,7 +1391,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) nuqs: specifier: 'catalog:' version: 2.8.8(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7) @@ -1428,7 +1428,7 @@ importers: version: 1.2.4(@types/react@19.2.16)(react@19.2.7) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) class-variance-authority: specifier: 'catalog:' version: 0.7.1 @@ -1484,7 +1484,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1509,7 +1509,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1534,7 +1534,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1621,7 +1621,7 @@ importers: version: 1.31.0(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(workerd@1.20260401.1)(wrangler@4.80.0) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1652,7 +1652,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1699,7 +1699,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1720,7 +1720,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -10559,7 +10559,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)' - '@vitejs/plugin-rsc@0.5.27(patch_hash=21f4bdf7ef92b5307db3f26198a54fc0789512431a5964f0c010b7add7fc3ab8)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + '@vitejs/plugin-rsc@0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': dependencies: '@rolldown/pluginutils': 1.0.1 es-module-lexer: 2.1.0 diff --git a/tests/action-owner-manifest.test.ts b/tests/action-owner-manifest.test.ts index 0dbfb87ae0..1bc5fc6fdb 100644 --- a/tests/action-owner-manifest.test.ts +++ b/tests/action-owner-manifest.test.ts @@ -56,9 +56,15 @@ describe("server action owner manifest", () => { }, }, routes: [route("/", "/app/page.tsx"), route("/admin", "/app/admin/page.tsx")], - serverReferenceConsumers: { - "/app/page.tsx": ["action-key#publicOnly"], - "/app/admin-consumer.ts": ["action-key#adminOnly"], + serverReferenceModuleEdges: { + "/app/page.tsx": { + imports: [{ exportNames: ["publicOnly"], sourceId: "/app/actions.ts" }], + reexports: [], + }, + "/app/admin-consumer.ts": { + imports: [{ exportNames: ["adminOnly"], sourceId: "/app/actions.ts" }], + reexports: [], + }, }, serverReferences: [ { @@ -83,7 +89,7 @@ describe("server action owner manifest", () => { }, }, routes: [route("/", "/app/page.tsx")], - serverReferenceConsumers: {}, + serverReferenceModuleEdges: {}, serverReferences: [ { exportNames: ["$$hoist_0_inline"], @@ -112,8 +118,11 @@ describe("server action owner manifest", () => { }, }, routes: [route("/", "/app/page.tsx")], - serverReferenceConsumers: { - "/app/page.tsx": ["admin-key#adminOnly"], + serverReferenceModuleEdges: { + "/app/page.tsx": { + imports: [{ exportNames: ["adminOnly"], sourceId: "/app/admin-actions.ts" }], + reexports: [], + }, }, serverReferences: [ { @@ -126,6 +135,44 @@ describe("server action owner manifest", () => { ).toEqual({ "admin-key#adminOnly": ["/"] }); }); + it("resolves aliased server actions through re-export barrels", () => { + expect( + buildActionOwnerManifest({ + moduleInfo: { + getModuleInfo(id) { + return id === "/app/page.tsx" + ? { importedIds: ["/app/barrel.ts"] } + : { importedIds: [] }; + }, + }, + routes: [route("/", "/app/page.tsx")], + serverReferenceModuleEdges: { + "/app/page.tsx": { + imports: [{ exportNames: ["submit"], sourceId: "/app/barrel.ts" }], + reexports: [], + }, + "/app/barrel.ts": { + imports: [], + reexports: [ + { + exportedName: "submit", + exportNames: ["internalSubmit"], + sourceId: "/app/actions.ts", + }, + ], + }, + }, + serverReferences: [ + { + exportNames: ["internalSubmit", "unrelated"], + importId: "/app/actions.ts", + referenceKey: "action-key", + }, + ], + }), + ).toEqual({ "action-key#internalSubmit": ["/"] }); + }); + it("accepts actions reachable through the final dynamic import graph", () => { expect( buildActionOwnerManifest({ @@ -137,8 +184,11 @@ describe("server action owner manifest", () => { }, }, routes: [route("/", "/app/page.tsx")], - serverReferenceConsumers: { - "/app/actions.ts": ["action-key#submit"], + serverReferenceModuleEdges: { + "/app/page.tsx": { + imports: [{ exportNames: "*", sourceId: "/app/actions.ts" }], + reexports: [], + }, }, serverReferences: [ { From 09feeadf805a3a97e6d15d16858e20cfcdba6b1f Mon Sep 17 00:00:00 2001 From: James Date: Mon, 6 Jul 2026 17:50:32 +0100 Subject: [PATCH 10/17] perf(app-router): move action edge analysis into vinext --- .../vinext/src/build/action-owner-manifest.ts | 219 +++++++++++++++ packages/vinext/src/index.ts | 26 +- patches/@vitejs__plugin-rsc@0.5.27.patch | 264 +++--------------- pnpm-lock.yaml | 66 ++--- tests/action-owner-manifest.test.ts | 83 ++++++ 5 files changed, 400 insertions(+), 258 deletions(-) diff --git a/packages/vinext/src/build/action-owner-manifest.ts b/packages/vinext/src/build/action-owner-manifest.ts index 1a9a679a08..a6fe8a444e 100644 --- a/packages/vinext/src/build/action-owner-manifest.ts +++ b/packages/vinext/src/build/action-owner-manifest.ts @@ -38,6 +38,36 @@ type ServerReferenceModuleEdges = { reexports: readonly ServerReferenceModuleEdge[]; }; +type ScanImport = { + d: number; + n?: string; + s: number; + se: number; + ss: number; +}; + +type ScanExport = { + ln?: string; + n: string; +}; + +type ScanModuleInfo = { + dynamicallyImportedIds: readonly string[]; + id: string; + importedIds: readonly string[]; +}; + +export type ActionOwnerScanEvent = + | { environmentName: string; type: "reset" } + | { + code: string; + environmentName: string; + exports: readonly ScanExport[]; + info: ScanModuleInfo; + imports: readonly ScanImport[]; + type: "module"; + }; + type ModuleInfoProvider = { getModuleInfo(id: string): { dynamicImportedIds?: readonly string[]; @@ -84,6 +114,195 @@ function addOwner(manifest: Record, key: string, pattern: stri if (!owners.includes(pattern)) owners.push(pattern); } +function splitCommaSeparated(value: string): string[] { + const parts: string[] = []; + let start = 0; + let quote = ""; + let escaped = false; + for (let index = 0; index < value.length; index++) { + const character = value[index]!; + if (quote) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = ""; + continue; + } + if (character === '"' || character === "'") quote = character; + else if (character === ",") { + parts.push(value.slice(start, index).trim()); + start = index + 1; + } + } + parts.push(value.slice(start).trim()); + return parts.filter(Boolean); +} + +function unquoteExportName(value: string): string { + const trimmed = value.trim(); + if (trimmed[0] === '"' || trimmed[0] === "'") { + try { + return JSON.parse( + trimmed[0] === "'" ? `"${trimmed.slice(1, -1).replaceAll('"', '\\"')}"` : trimmed, + ); + } catch {} + } + return trimmed; +} + +function parseNamedSpecifiers(value: string): { exportedName: string; sourceName: string }[] { + return splitCommaSeparated(value).flatMap((part) => { + const match = part.match(/^(.+?)(?:\s+as\s+(.+))?$/); + if (!match) return []; + const sourceName = unquoteExportName(match[1]!); + const exportedName = unquoteExportName(match[2] ?? match[1]!); + return sourceName && exportedName ? [{ exportedName, sourceName }] : []; + }); +} + +type UnresolvedServerReferenceModuleEdges = ServerReferenceModuleEdges & { + sourceOrder: readonly string[]; +}; + +function collectServerReferenceModuleEdges( + code: string, + imports: readonly ScanImport[], + exports: readonly ScanExport[], +): UnresolvedServerReferenceModuleEdges { + const sourceOrder: string[] = []; + const sources = new Set(); + const importsBySource = new Map(); + const reexports: ServerReferenceModuleEdge[] = []; + const importedLocals = new Map(); + + for (const item of imports) { + if (!item.n || item.d === -2) continue; + if (!sources.has(item.n)) { + sources.add(item.n); + sourceOrder.push(item.n); + } + if (item.d >= 0) { + importsBySource.set(item.n, { exportNames: "*", sourceId: item.n }); + continue; + } + const declaration = code.slice(item.ss, item.se).trim(); + if (declaration.startsWith("export")) { + const namespace = declaration.match(/^export\s*\*\s+as\s+([^\s]+)\s+from\b/); + if (namespace) { + reexports.push({ + exportedName: unquoteExportName(namespace[1]!), + exportNames: "*", + sourceId: item.n, + }); + } else if (/^export\s*\*/.test(declaration)) { + reexports.push({ exportNames: "*", sourceId: item.n }); + } else { + const named = declaration.match(/^export\s*\{([\s\S]*?)\}\s*from\b/); + if (named) { + for (const specifier of parseNamedSpecifiers(named[1]!)) { + reexports.push({ + exportedName: specifier.exportedName, + exportNames: [specifier.sourceName], + sourceId: item.n, + }); + } + } + } + continue; + } + if (!declaration.startsWith("import")) continue; + const beforeSource = code.slice(item.ss, item.s); + const clauseMatch = beforeSource.match(/^\s*import\s+([\s\S]*?)\s+from\s*["']?$/); + if (!clauseMatch) continue; + const clause = clauseMatch[1]!.trim(); + const exportNames: string[] = []; + let consumesNamespace = false; + const named = clause.match(/\{([\s\S]*?)\}/); + if (named) { + for (const specifier of parseNamedSpecifiers(named[1]!)) { + exportNames.push(specifier.sourceName); + importedLocals.set(specifier.exportedName, { + exportNames: [specifier.sourceName], + sourceId: item.n, + }); + } + } + const namespace = clause.match(/\*\s+as\s+([\w$]+)/); + if (namespace) { + consumesNamespace = true; + importedLocals.set(namespace[1]!, { exportNames: "*", sourceId: item.n }); + } + const defaultName = clause.match(/^([\w$]+)(?:\s*,|$)/)?.[1]; + if (defaultName) { + exportNames.push("default"); + importedLocals.set(defaultName, { exportNames: ["default"], sourceId: item.n }); + } + if (exportNames.length > 0 || consumesNamespace) { + const existing = importsBySource.get(item.n); + importsBySource.set(item.n, { + exportNames: + consumesNamespace || existing?.exportNames === "*" + ? "*" + : [...new Set([...(existing?.exportNames ?? []), ...exportNames])], + sourceId: item.n, + }); + } + } + + for (const item of exports) { + if (!item.ln) continue; + const imported = importedLocals.get(item.ln); + if (imported) reexports.push({ ...imported, exportedName: item.n }); + } + + return { + imports: sourceOrder.flatMap((source) => { + const edge = importsBySource.get(source); + return edge ? [edge] : []; + }), + reexports, + sourceOrder, + }; +} + +export function createActionOwnerScanObserver(options?: { environmentName?: string }): { + moduleEdges: Record; + observe: (event: ActionOwnerScanEvent) => void; +} { + const environmentName = options?.environmentName ?? "rsc"; + const moduleEdges: Record = {}; + + return { + moduleEdges, + observe(event) { + if (event.type === "reset") { + if (event.environmentName !== environmentName) return; + for (const id of Object.keys(moduleEdges)) delete moduleEdges[id]; + return; + } + const pending = collectServerReferenceModuleEdges(event.code, event.imports, event.exports); + if (pending.imports.length === 0 && pending.reexports.length === 0) return; + if (event.info.importedIds.length !== pending.sourceOrder.length) { + return; + } + const resolvedIds = new Map( + pending.sourceOrder.map( + (source, index) => [source, event.info.importedIds[index]!] as const, + ), + ); + const resolveEdges = (edges: readonly ServerReferenceModuleEdge[]) => + edges.flatMap((edge) => { + const sourceId = resolvedIds.get(edge.sourceId); + return sourceId ? [{ ...edge, sourceId }] : []; + }); + const imports = resolveEdges(pending.imports); + const reexports = resolveEdges(pending.reexports); + if (imports.length > 0 || reexports.length > 0) { + moduleEdges[event.info.id] = { imports, reexports }; + } + }, + }; +} + function deriveServerReferenceConsumerMap(options: { canonicalizeModuleId: (id: string) => string; moduleEdges: Readonly>; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index cb2fd4932a..95da895853 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -71,6 +71,7 @@ import { extractMiddlewareMatcherConfig, hasExportedName } from "./build/report. import { planRouteClassificationInjection } from "./build/route-classification-injector.js"; import { buildActionOwnerManifest, + createActionOwnerScanObserver, injectActionOwnerManifest, } from "./build/action-owner-manifest.js"; import { normalizePathnameForRouteMatchStrict } from "./routing/utils.js"; @@ -1321,6 +1322,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // `__VINEXT_CLASS` stub with a real dispatch table. let rscClassificationManifest: RouteClassificationManifest | null = null; let rscActionOwnerRoutes: Awaited> | null = null; + const actionOwnerScan = createActionOwnerScanObserver(); // Resolve shim paths - works both from source (.ts) and built (.js). // Normalize to forward slashes so every downstream `path.posix.join` keeps @@ -1456,6 +1458,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // resolves to the configured RSC plugin array. Vite's asyncFlatten // will resolve this before processing the plugin list. let rscPluginPromise: Promise | null = null; + if (earlyAppDirExists && resolvedRscPath) { + rscPluginModulePromise = import(pathToFileURL(resolvedRscPath).href); + } if (earlyAppDirExists && autoRsc) { if (!resolvedRscPath) { throw new Error( @@ -1465,9 +1470,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { " @vitejs/plugin-rsc", ); } - const rscImport = import(pathToFileURL(resolvedRscPath).href); - rscPluginModulePromise = rscImport; - rscPluginPromise = rscImport + rscPluginPromise = rscPluginModulePromise! .then((mod) => { const rsc = mod.default; return rsc({ @@ -3188,6 +3191,21 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, async configResolved(config) { + if (config.command === "build" && hasAppDir && rscPluginModulePromise) { + const { getPluginApi } = await rscPluginModulePromise; + const pluginApi = getPluginApi(config); + if (pluginApi) { + const manager = pluginApi.manager as typeof pluginApi.manager & { + scanBuildObservers?: Set; + }; + if (!(manager.scanBuildObservers instanceof Set)) { + throw new Error( + "[vinext] The installed @vitejs/plugin-rsc does not expose scan-build observers required for production server action ownership. Install a compatible release before building.", + ); + } + manager.scanBuildObservers.add(actionOwnerScan.observe); + } + } const cacheDirPrefix = getCacheDirPrefix(config.cacheDir); typeofWindowIdFilter.exclude = new RegExp(`^${escapeRegExp(cacheDirPrefix)}`); if (isServeCommand && hasCloudflarePlugin && hasPagesDir && !hasAppDir) { @@ -3713,7 +3731,7 @@ export const loadServerActionClient = ${ canonicalizeModuleId: canonicalize, moduleInfo, routes: rscActionOwnerRoutes, - serverReferenceModuleEdges: pluginApi?.manager.serverReferenceModuleEdgeMap ?? {}, + serverReferenceModuleEdges: actionOwnerScan.moduleEdges, serverReferences, }); const injected = injectActionOwnerManifest(nextCode, discoveredActionOwners); diff --git a/patches/@vitejs__plugin-rsc@0.5.27.patch b/patches/@vitejs__plugin-rsc@0.5.27.patch index 2d13761807..fe5027bab9 100644 --- a/patches/@vitejs__plugin-rsc@0.5.27.patch +++ b/patches/@vitejs__plugin-rsc@0.5.27.patch @@ -1,241 +1,63 @@ diff --git a/dist/plugin-BK29Va7z.js b/dist/plugin-BK29Va7z.js +index 548c631..b34b86e 100644 --- a/dist/plugin-BK29Va7z.js +++ b/dist/plugin-BK29Va7z.js -@@ -406,26 +406,53 @@ - } - //#endregion - //#region src/plugins/scan.ts --function scanBuildStripPlugin({ manager }) { -+function scanBuildStripPlugin({ manager, resetEnvironmentName }) { -+ const unresolvedEdgeMap = /* @__PURE__ */ new Map(); - return { - name: "rsc:scan-strip", - apply: "build", - enforce: "post", +@@ -408,0 +409 @@ function vitePluginResolvedIdProxy() { ++ +@@ -409,0 +411 @@ function scanBuildStripPlugin({ manager }) { ++ const lexedModules = /* @__PURE__ */ new Map(); +@@ -413,0 +416,6 @@ function scanBuildStripPlugin({ manager }) { + buildStart() { -+ if (manager.isScanBuild && this.environment.name === resetEnvironmentName) { -+ unresolvedEdgeMap.clear(); -+ manager.serverReferenceModuleEdgeMap = {}; ++ if (manager.isScanBuild && manager.scanBuildObservers.size > 0) { ++ lexedModules.clear(); ++ for (const observer of manager.scanBuildObservers) observer({ type: "reset", environmentName: this.environment.name }); + } + }, - transform: { - filter: { id: { exclude: exactRegex("\0rolldown/runtime.js") } }, +@@ -416 +424 @@ function scanBuildStripPlugin({ manager }) { - async handler(code, _id, _options) { + async handler(code, id, _options) { - if (!manager.isScanBuild) return; - return { +@@ -419 +427 @@ function scanBuildStripPlugin({ manager }) { - code: await transformScanBuildStrip(code), -+ code: await transformScanBuildStrip(code, (imports, exports) => { -+ unresolvedEdgeMap.set(id, collectServerReferenceModuleEdges(code, imports, exports)); -+ }), - map: { mappings: "" } - }; -+ } ++ code: await transformScanBuildStrip(code, manager.scanBuildObservers.size > 0 ? (imports, exports) => lexedModules.set(id, { code, imports, exports }) : void 0), +@@ -422,0 +431,7 @@ function scanBuildStripPlugin({ manager }) { + }, + moduleParsed(info) { -+ if (!manager.isScanBuild) return; -+ const unresolved = unresolvedEdgeMap.get(info.id); -+ if (!unresolved) return; -+ unresolvedEdgeMap.delete(info.id); -+ if (info.importedIds.length !== unresolved.staticSourceOrder.length || info.dynamicallyImportedIds.length !== unresolved.dynamicSourceOrder.length) { -+ delete manager.serverReferenceModuleEdgeMap[info.id]; -+ return; - } -+ const resolvedIds = /* @__PURE__ */ new Map([ -+ ...unresolved.staticSourceOrder.map((source, index) => [source, info.importedIds[index]]), -+ ...unresolved.dynamicSourceOrder.map((source, index) => [source, info.dynamicallyImportedIds[index]]) -+ ]); -+ const imports = resolveServerReferenceEdges(unresolved.imports, resolvedIds); -+ const reexports = resolveServerReferenceEdges(unresolved.reexports, resolvedIds); -+ if (imports.length > 0 || reexports.length > 0) manager.serverReferenceModuleEdgeMap[info.id] = { imports, reexports }; - } - }; - } - const importGlobRE = /\bimport\.meta\.glob(?:<\w+>)?\s*\(/g; ++ if (!manager.isScanBuild || manager.scanBuildObservers.size === 0) return; ++ const lexed = lexedModules.get(info.id); ++ if (!lexed) return; ++ lexedModules.delete(info.id); ++ for (const observer of manager.scanBuildObservers) observer({ type: "module", environmentName: this.environment.name, info, ...lexed }); +@@ -427,2 +442,3 @@ const importGlobRE = /\bimport\.meta\.glob(?:<\w+>)?\s*\(/g; -async function transformScanBuildStrip(code) { - const [imports] = esModuleLexer.parse(code); -+async function transformScanBuildStrip(code, onImports) { ++async function transformScanBuildStrip(code, onLexed) { + const [imports, exports] = esModuleLexer.parse(code); -+ onImports?.(imports, exports); - let output = imports.map((e) => e.n && `import ${JSON.stringify(e.n)};\n`).filter(Boolean).join(""); - if (importGlobRE.test(code)) { - walk(await parseAstAsync(code), { enter(node) { -@@ -437,7 +464,118 @@ - output += ""; - } - return output; -+} -+function splitCommaSeparated(value) { -+ const parts = []; -+ let start = 0; -+ let quote = ""; -+ let escaped = false; -+ for (let index = 0; index < value.length; index++) { -+ const character = value[index]; -+ if (quote) { -+ if (escaped) escaped = false; -+ else if (character === "\\") escaped = true; -+ else if (character === quote) quote = ""; -+ continue; -+ } -+ if (character === '"' || character === "'") quote = character; -+ else if (character === ",") { -+ parts.push(value.slice(start, index).trim()); -+ start = index + 1; -+ } -+ } -+ parts.push(value.slice(start).trim()); -+ return parts.filter(Boolean); - } -+function unquoteExportName(value) { -+ const trimmed = value.trim(); -+ if (trimmed[0] === '"' || trimmed[0] === "'") { -+ try { -+ return JSON.parse(trimmed[0] === "'" ? `"${trimmed.slice(1, -1).replaceAll('"', '\\"')}"` : trimmed); -+ } catch {} -+ } -+ return trimmed; -+} -+function parseNamedSpecifiers(value) { -+ return splitCommaSeparated(value).flatMap((part) => { -+ const match = part.match(/^(.+?)(?:\s+as\s+(.+))?$/); -+ if (!match) return []; -+ const sourceName = unquoteExportName(match[1]); -+ const exportedName = unquoteExportName(match[2] ?? match[1]); -+ return sourceName && exportedName ? [{ sourceName, exportedName }] : []; -+ }); -+} -+function collectServerReferenceModuleEdges(code, imports, exports) { -+ const staticSourceOrder = []; -+ const dynamicSourceOrder = []; -+ const staticSources = /* @__PURE__ */ new Set(); -+ const dynamicSources = /* @__PURE__ */ new Set(); -+ const importsBySource = /* @__PURE__ */ new Map(); -+ const reexports = []; -+ const importedLocals = /* @__PURE__ */ new Map(); -+ for (const item of imports) { -+ if (!item.n || item.d === -2) continue; -+ if (item.d >= 0) { -+ if (!dynamicSources.has(item.n)) { -+ dynamicSources.add(item.n); -+ dynamicSourceOrder.push(item.n); -+ } -+ importsBySource.set(item.n, { sourceId: item.n, exportNames: "*" }); -+ continue; -+ } -+ if (!staticSources.has(item.n)) { -+ staticSources.add(item.n); -+ staticSourceOrder.push(item.n); -+ } -+ const declaration = code.slice(item.ss, item.se).trim(); -+ if (declaration.startsWith("export")) { -+ const namespace = declaration.match(/^export\s*\*\s+as\s+([^\s]+)\s+from\b/); -+ if (namespace) reexports.push({ sourceId: item.n, exportNames: "*", exportedName: unquoteExportName(namespace[1]) }); -+ else if (/^export\s*\*/.test(declaration)) reexports.push({ sourceId: item.n, exportNames: "*" }); -+ else { -+ const named = declaration.match(/^export\s*\{([\s\S]*?)\}\s*from\b/); -+ if (named) for (const specifier of parseNamedSpecifiers(named[1])) reexports.push({ sourceId: item.n, exportNames: [specifier.sourceName], exportedName: specifier.exportedName }); -+ } -+ continue; -+ } -+ if (!declaration.startsWith("import")) continue; -+ const beforeSource = code.slice(item.ss, item.s); -+ const clauseMatch = beforeSource.match(/^\s*import\s+([\s\S]*?)\s+from\s*["']?$/); -+ if (!clauseMatch) continue; -+ const clause = clauseMatch[1].trim(); -+ const exportNames = []; -+ let consumesNamespace = false; -+ const named = clause.match(/\{([\s\S]*?)\}/); -+ if (named) for (const specifier of parseNamedSpecifiers(named[1])) { -+ exportNames.push(specifier.sourceName); -+ importedLocals.set(specifier.exportedName, { sourceId: item.n, exportNames: [specifier.sourceName] }); -+ } -+ const namespace = clause.match(/\*\s+as\s+([\w$]+)/); -+ if (namespace) { -+ consumesNamespace = true; -+ importedLocals.set(namespace[1], { sourceId: item.n, exportNames: "*" }); -+ } -+ const defaultName = clause.match(/^([\w$]+)(?:\s*,|$)/)?.[1]; -+ if (defaultName) { -+ exportNames.push("default"); -+ importedLocals.set(defaultName, { sourceId: item.n, exportNames: ["default"] }); -+ } -+ if (exportNames.length > 0 || consumesNamespace) importsBySource.set(item.n, { sourceId: item.n, exportNames: consumesNamespace ? "*" : [...new Set(exportNames)] }); -+ } -+ for (const item of exports) { -+ if (!item.ln) continue; -+ const imported = importedLocals.get(item.ln); -+ if (imported) reexports.push({ ...imported, exportedName: item.n }); -+ } -+ const sourceOrder = [...staticSourceOrder, ...dynamicSourceOrder]; -+ return { staticSourceOrder, dynamicSourceOrder, imports: sourceOrder.flatMap((source) => importsBySource.get(source) ? [importsBySource.get(source)] : []), reexports }; -+} -+function resolveServerReferenceEdges(edges, resolvedIds) { -+ return edges.flatMap((edge) => { -+ const sourceId = resolvedIds.get(edge.sourceId); -+ return sourceId ? [{ ...edge, sourceId }] : []; -+ }); -+} - //#endregion - //#region src/plugins/validate-import.ts - function validateImportPlugin() { -@@ -544,6 +682,7 @@ - clientReferenceMetaMap = {}; - clientReferenceGroups = {}; - serverReferenceMetaMap = {}; -+ serverReferenceModuleEdgeMap = {}; - serverResourcesMetaMap = {}; - environmentImportMetaMap = {}; - stabilize() { -@@ -620,7 +759,7 @@ - } - } - }, -- scanBuildStripPlugin({ manager }), -+ scanBuildStripPlugin({ manager, resetEnvironmentName: rscPluginOptions.environment?.rsc ?? "rsc" }), - vitePluginResolvedIdProxy() - ]; - } -@@ -1208,7 +1347,7 @@ - ...validateImportPlugin(), - apply: () => rscPluginOptions.validateImports !== false - }, -- scanBuildStripPlugin({ manager }), -+ scanBuildStripPlugin({ manager, resetEnvironmentName: rscPluginOptions.environment?.rsc ?? "rsc" }), - ...cjsModuleRunnerPlugin(), - ...globalAsyncLocalStoragePlugin() - ]; ++ onLexed?.(imports, exports); +@@ -546,0 +563 @@ var RscPluginManager = class { ++ scanBuildObservers = /* @__PURE__ */ new Set(); diff --git a/dist/plugin-Bm-Fb4g7.d.ts b/dist/plugin-Bm-Fb4g7.d.ts +index b0fdd93..3ddad9e 100644 --- a/dist/plugin-Bm-Fb4g7.d.ts +++ b/dist/plugin-Bm-Fb4g7.d.ts -@@ -25,6 +25,15 @@ - referenceKey: string; - exportNames: string[]; - }; -+type ServerReferenceModuleEdge = { -+ sourceId: string; -+ exportNames: readonly string[] | "*"; -+ exportedName?: string; -+}; -+type ServerReferenceModuleEdges = { -+ imports: readonly ServerReferenceModuleEdge[]; -+ reexports: readonly ServerReferenceModuleEdge[]; -+}; - /** - * @experimental - */ -@@ -37,6 +46,7 @@ - clientReferenceMetaMap: Record; - clientReferenceGroups: Record; - serverReferenceMetaMap: Record; -+ serverReferenceModuleEdgeMap: Record; - serverResourcesMetaMap: Record; -@@ -182,4 +192,4 @@ - output: MagicString; - } | undefined>; - //#endregion +@@ -3 +3,2 @@ import MagicString from "magic-string"; +-import { Plugin, ResolvedConfig, Rollup, ViteDevServer, parseAstAsync } from "vite"; ++import { ImportSpecifier, ExportSpecifier } from "es-module-lexer"; ++import { ModuleInfo, Plugin, ResolvedConfig, Rollup, ViteDevServer, parseAstAsync } from "vite"; +@@ -27,0 +29,11 @@ type ServerRerferenceMeta = { ++type ScanBuildObserver = (event: { ++ type: "reset"; ++ environmentName: string; ++} | { ++ type: "module"; ++ environmentName: string; ++ code: string; ++ imports: readonly ImportSpecifier[]; ++ exports: readonly ExportSpecifier[]; ++ info: ModuleInfo; ++}) => void; +@@ -39,0 +52 @@ declare class RscPluginManager { ++ scanBuildObservers: Set; +@@ -185 +198 @@ declare function transformRscCssExport(options: { -export { ResolvedAssetsManifest as a, getPluginApi as c, vitePluginRscMinimal as d, ResolvedAssetDeps as i, transformRscCssExport as l, AssetsManifest as n, RscPluginManager as o, PluginApi as r, RscPluginOptions as s, AssetDeps as t, vitePluginRsc as u }; \ No newline at end of file +export { ResolvedAssetsManifest as a, getPluginApi as c, vitePluginRscMinimal as d, ResolvedAssetDeps as i, transformRscCssExport as l, AssetsManifest as n, RscPluginManager as o, PluginApi as r, RscPluginOptions as s, AssetDeps as t, vitePluginRsc as u }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b90a9dd502..6f5c44b4e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,7 +250,7 @@ overrides: packageExtensionsChecksum: sha256-DELwn26pds5KeVDWtpKSByRpbPdf/D1CYmAVTGs16Cc= patchedDependencies: - '@vitejs/plugin-rsc@0.5.27': 97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7 + '@vitejs/plugin-rsc@0.5.27': 5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80 importers: @@ -289,7 +289,7 @@ importers: version: 7.0.0-dev.20260217.1 '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -380,7 +380,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) drizzle-kit: specifier: 'catalog:' version: 0.31.10 @@ -423,7 +423,7 @@ importers: devDependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.1 version: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)' @@ -444,7 +444,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -579,7 +579,7 @@ importers: version: 19.2.3(@types/react@19.2.16) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) postcss: specifier: 'catalog:' version: 8.5.10 @@ -618,7 +618,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -701,7 +701,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) postcss: specifier: 'catalog:' version: 8.5.10 @@ -734,7 +734,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) ms: specifier: 'catalog:' version: 3.0.0-canary.1 @@ -805,7 +805,7 @@ importers: version: 19.2.3(@types/react@19.2.16) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) typescript: specifier: 'catalog:' version: 5.9.3 @@ -900,7 +900,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -946,7 +946,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1041,7 +1041,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) am-i-vibing: specifier: 'catalog:' version: 0.5.0 @@ -1056,7 +1056,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) fake-context-lib: specifier: file:./__test_packages__/fake-context-lib version: file:tests/fixtures/app-basic/__test_packages__/fake-context-lib @@ -1090,7 +1090,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1115,7 +1115,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1142,7 +1142,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1173,7 +1173,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1210,7 +1210,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1276,7 +1276,7 @@ importers: version: 1.9.1 '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) better-auth: specifier: 'catalog:' version: 1.5.6(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.6.2)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.6.2)(kysely@0.28.15))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) @@ -1307,7 +1307,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-intl: specifier: 'catalog:' version: 4.11.1(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7)(typescript@5.9.3) @@ -1335,7 +1335,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1363,7 +1363,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-view-transitions: specifier: 'catalog:' version: 0.3.5(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1391,7 +1391,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) nuqs: specifier: 'catalog:' version: 2.8.8(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7) @@ -1428,7 +1428,7 @@ importers: version: 1.2.4(@types/react@19.2.16)(react@19.2.7) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) class-variance-authority: specifier: 'catalog:' version: 0.7.1 @@ -1484,7 +1484,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1509,7 +1509,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1534,7 +1534,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1621,7 +1621,7 @@ importers: version: 1.31.0(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(workerd@1.20260401.1)(wrangler@4.80.0) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1652,7 +1652,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1699,7 +1699,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1720,7 +1720,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -10559,7 +10559,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)' - '@vitejs/plugin-rsc@0.5.27(patch_hash=97d375cf7e491791f5c87c5ff510ed1c9cd27619857908e264ae96547544a9f7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + '@vitejs/plugin-rsc@0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': dependencies: '@rolldown/pluginutils': 1.0.1 es-module-lexer: 2.1.0 diff --git a/tests/action-owner-manifest.test.ts b/tests/action-owner-manifest.test.ts index 1bc5fc6fdb..350e7dbd32 100644 --- a/tests/action-owner-manifest.test.ts +++ b/tests/action-owner-manifest.test.ts @@ -3,6 +3,7 @@ import type { AppRoute } from "../packages/vinext/src/routing/app-route-graph.js import { actionOwnerRouteEntryIds, buildActionOwnerManifest, + createActionOwnerScanObserver, injectActionOwnerManifest, } from "../packages/vinext/src/build/action-owner-manifest.js"; @@ -28,6 +29,88 @@ function route(pattern: string, pagePath: string) { } describe("server action owner manifest", () => { + it("collects resolved edges across scan environments and resets at the RSC boundary", () => { + const scan = createActionOwnerScanObserver(); + const moduleEvent = { + code: `import { submit as run } from "./actions"; export { run as submit };`, + environmentName: "ssr", + exports: [{ ln: "run", n: "submit" }], + imports: [{ d: -1, n: "./actions", s: 31, se: 41, ss: 0 }], + info: { + dynamicallyImportedIds: [], + id: "/app/button.tsx", + importedIds: ["/app/actions.ts"], + }, + type: "module" as const, + }; + + scan.observe(moduleEvent); + expect(scan.moduleEdges).toEqual({ + "/app/button.tsx": { + imports: [{ exportNames: ["submit"], sourceId: "/app/actions.ts" }], + reexports: [ + { + exportedName: "submit", + exportNames: ["submit"], + sourceId: "/app/actions.ts", + }, + ], + }, + }); + + scan.observe({ environmentName: "ssr", type: "reset" }); + expect(scan.moduleEdges).not.toEqual({}); + scan.observe({ environmentName: "rsc", type: "reset" }); + expect(scan.moduleEdges).toEqual({}); + }); + + it("merges repeated named imports from the same action module", () => { + const scan = createActionOwnerScanObserver(); + scan.observe({ + code: `import { first } from "./actions"; import { second } from "./actions";`, + environmentName: "rsc", + exports: [], + imports: [ + { d: -1, n: "./actions", s: 23, se: 33, ss: 0 }, + { d: -1, n: "./actions", s: 59, se: 69, ss: 35 }, + ], + info: { + dynamicallyImportedIds: [], + id: "/app/page.tsx", + importedIds: ["/app/actions.ts"], + }, + type: "module", + }); + + expect(scan.moduleEdges["/app/page.tsx"]?.imports).toEqual([ + { exportNames: ["first", "second"], sourceId: "/app/actions.ts" }, + ]); + }); + + it("pairs mixed static and dynamic imports with scan-strip module ids", () => { + const scan = createActionOwnerScanObserver(); + scan.observe({ + code: `import { first } from "./actions"; import("./lazy-actions");`, + environmentName: "rsc", + exports: [], + imports: [ + { d: -1, n: "./actions", s: 23, se: 33, ss: 0 }, + { d: 42, n: "./lazy-actions", s: 43, se: 59, ss: 36 }, + ], + info: { + dynamicallyImportedIds: [], + id: "/app/page.tsx", + importedIds: ["/app/actions.ts", "/app/lazy-actions.ts"], + }, + type: "module", + }); + + expect(scan.moduleEdges["/app/page.tsx"]?.imports).toEqual([ + { exportNames: ["first"], sourceId: "/app/actions.ts" }, + { exportNames: "*", sourceId: "/app/lazy-actions.ts" }, + ]); + }); + it("escapes action owner manifests embedded in generated JavaScript", () => { const injected = injectActionOwnerManifest( `function __VINEXT_ACTION_OWNERS() { return "__VINEXT_ACTION_OWNERS_STUB__"; }`, From 13f9f0cd8d0f0226638d5d437432d49034663c49 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 6 Jul 2026 19:07:14 +0100 Subject: [PATCH 11/17] fix(app-router): finalize action scan observer integration --- packages/vinext/src/index.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 95da895853..c6e58b40de 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1323,6 +1323,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let rscClassificationManifest: RouteClassificationManifest | null = null; let rscActionOwnerRoutes: Awaited> | null = null; const actionOwnerScan = createActionOwnerScanObserver(); + let hasActionOwnerScanObserver = false; // Resolve shim paths - works both from source (.ts) and built (.js). // Normalize to forward slashes so every downstream `path.posix.join` keeps @@ -3198,12 +3199,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const manager = pluginApi.manager as typeof pluginApi.manager & { scanBuildObservers?: Set; }; - if (!(manager.scanBuildObservers instanceof Set)) { - throw new Error( - "[vinext] The installed @vitejs/plugin-rsc does not expose scan-build observers required for production server action ownership. Install a compatible release before building.", - ); + if (manager.scanBuildObservers instanceof Set) { + manager.scanBuildObservers.add(actionOwnerScan.observe); + hasActionOwnerScanObserver = true; } - manager.scanBuildObservers.add(actionOwnerScan.observe); } } const cacheDirPrefix = getCacheDirPrefix(config.cacheDir); @@ -3727,6 +3726,11 @@ export const loadServerActionClient = ${ const serverReferences = Object.values( pluginApi?.manager.serverReferenceMetaMap ?? {}, ); + if (!hasActionOwnerScanObserver && serverReferences.length > 0) { + this.error( + "[vinext] The installed @vitejs/plugin-rsc does not expose scan-build observers required to build an app with server actions. Install a compatible release before building.", + ); + } const discoveredActionOwners = buildActionOwnerManifest({ canonicalizeModuleId: canonicalize, moduleInfo, From e3630a89c1eef7c2e541a0269ad570122e073325 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 6 Jul 2026 19:19:29 +0100 Subject: [PATCH 12/17] fix(app-router): preserve original action scan metadata --- patches/@vitejs__plugin-rsc@0.5.27.patch | 78 ++++++++++++++++++------ pnpm-lock.yaml | 66 ++++++++++---------- 2 files changed, 91 insertions(+), 53 deletions(-) diff --git a/patches/@vitejs__plugin-rsc@0.5.27.patch b/patches/@vitejs__plugin-rsc@0.5.27.patch index fe5027bab9..1a17642728 100644 --- a/patches/@vitejs__plugin-rsc@0.5.27.patch +++ b/patches/@vitejs__plugin-rsc@0.5.27.patch @@ -1,49 +1,75 @@ diff --git a/dist/plugin-BK29Va7z.js b/dist/plugin-BK29Va7z.js -index 548c631..b34b86e 100644 +index 548c63135..4a1c68bb6 100644 --- a/dist/plugin-BK29Va7z.js +++ b/dist/plugin-BK29Va7z.js -@@ -408,0 +409 @@ function vitePluginResolvedIdProxy() { -+ -@@ -409,0 +411 @@ function scanBuildStripPlugin({ manager }) { -+ const lexedModules = /* @__PURE__ */ new Map(); -@@ -413,0 +416,6 @@ function scanBuildStripPlugin({ manager }) { +@@ -411,21 +411,37 @@ function scanBuildStripPlugin({ manager }) { + name: "rsc:scan-strip", + apply: "build", + enforce: "post", + buildStart() { + if (manager.isScanBuild && manager.scanBuildObservers.size > 0) { -+ lexedModules.clear(); ++ manager.scanBuildModules.clear(); + for (const observer of manager.scanBuildObservers) observer({ type: "reset", environmentName: this.environment.name }); + } + }, -@@ -416 +424 @@ function scanBuildStripPlugin({ manager }) { + transform: { + filter: { id: { exclude: exactRegex("\0rolldown/runtime.js") } }, - async handler(code, _id, _options) { + async handler(code, id, _options) { -@@ -419 +427 @@ function scanBuildStripPlugin({ manager }) { + if (!manager.isScanBuild) return; + return { - code: await transformScanBuildStrip(code), -+ code: await transformScanBuildStrip(code, manager.scanBuildObservers.size > 0 ? (imports, exports) => lexedModules.set(id, { code, imports, exports }) : void 0), -@@ -422,0 +431,7 @@ function scanBuildStripPlugin({ manager }) { ++ code: await transformScanBuildStrip(code, manager.scanBuildObservers.size > 0 ? (imports, exports) => { ++ if (!manager.scanBuildModules.has(id)) manager.scanBuildModules.set(id, { code, imports, exports }); ++ } : void 0), + map: { mappings: "" } + }; + } + }, + moduleParsed(info) { + if (!manager.isScanBuild || manager.scanBuildObservers.size === 0) return; -+ const lexed = lexedModules.get(info.id); ++ const lexed = manager.scanBuildModules.get(info.id); + if (!lexed) return; -+ lexedModules.delete(info.id); ++ manager.scanBuildModules.delete(info.id); + for (const observer of manager.scanBuildObservers) observer({ type: "module", environmentName: this.environment.name, info, ...lexed }); -@@ -427,2 +442,3 @@ const importGlobRE = /\bimport\.meta\.glob(?:<\w+>)?\s*\(/g; + } + }; + } + const importGlobRE = /\bimport\.meta\.glob(?:<\w+>)?\s*\(/g; -async function transformScanBuildStrip(code) { - const [imports] = esModuleLexer.parse(code); +async function transformScanBuildStrip(code, onLexed) { + const [imports, exports] = esModuleLexer.parse(code); + onLexed?.(imports, exports); -@@ -546,0 +563 @@ var RscPluginManager = class { + let output = imports.map((e) => e.n && `import ${JSON.stringify(e.n)};\n`).filter(Boolean).join(""); + if (importGlobRE.test(code)) { + walk(await parseAstAsync(code), { enter(node) { +@@ -544,6 +560,8 @@ var RscPluginManager = class { + clientReferenceMetaMap = {}; + clientReferenceGroups = {}; + serverReferenceMetaMap = {}; + scanBuildObservers = /* @__PURE__ */ new Set(); ++ scanBuildModules = /* @__PURE__ */ new Map(); + serverResourcesMetaMap = {}; + environmentImportMetaMap = {}; + stabilize() { diff --git a/dist/plugin-Bm-Fb4g7.d.ts b/dist/plugin-Bm-Fb4g7.d.ts -index b0fdd93..3ddad9e 100644 +index b0fdd9374..3ddad9eed 100644 --- a/dist/plugin-Bm-Fb4g7.d.ts +++ b/dist/plugin-Bm-Fb4g7.d.ts -@@ -3 +3,2 @@ import MagicString from "magic-string"; +@@ -1,6 +1,7 @@ + import { f as TransformWrapExportFilter } from "./index-DViteJjw.js"; + import MagicString from "magic-string"; -import { Plugin, ResolvedConfig, Rollup, ViteDevServer, parseAstAsync } from "vite"; +import { ImportSpecifier, ExportSpecifier } from "es-module-lexer"; +import { ModuleInfo, Plugin, ResolvedConfig, Rollup, ViteDevServer, parseAstAsync } from "vite"; -@@ -27,0 +29,11 @@ type ServerRerferenceMeta = { + + //#region src/plugins/import-environment.d.ts + type EnvironmentImportMeta = { +@@ -25,6 +26,17 @@ type ServerRerferenceMeta = { + referenceKey: string; + exportNames: string[]; + }; +type ScanBuildObserver = (event: { + type: "reset"; + environmentName: string; @@ -55,9 +81,21 @@ index b0fdd93..3ddad9e 100644 + exports: readonly ExportSpecifier[]; + info: ModuleInfo; +}) => void; -@@ -39,0 +52 @@ declare class RscPluginManager { + /** + * @experimental + */ +@@ -37,6 +49,7 @@ declare class RscPluginManager { + clientReferenceMetaMap: Record; + clientReferenceGroups: Record; + serverReferenceMetaMap: Record; + scanBuildObservers: Set; -@@ -185 +198 @@ declare function transformRscCssExport(options: { + serverResourcesMetaMap: Record; +@@ -182,4 +195,4 @@ declare function transformRscCssExport(options: { + output: MagicString; + } | undefined>; + //#endregion -export { ResolvedAssetsManifest as a, getPluginApi as c, vitePluginRscMinimal as d, ResolvedAssetDeps as i, transformRscCssExport as l, AssetsManifest as n, RscPluginManager as o, PluginApi as r, RscPluginOptions as s, AssetDeps as t, vitePluginRsc as u }; \ No newline at end of file +export { ResolvedAssetsManifest as a, getPluginApi as c, vitePluginRscMinimal as d, ResolvedAssetDeps as i, transformRscCssExport as l, AssetsManifest as n, RscPluginManager as o, PluginApi as r, RscPluginOptions as s, AssetDeps as t, vitePluginRsc as u }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f5c44b4e5..7a2149c1ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,7 +250,7 @@ overrides: packageExtensionsChecksum: sha256-DELwn26pds5KeVDWtpKSByRpbPdf/D1CYmAVTGs16Cc= patchedDependencies: - '@vitejs/plugin-rsc@0.5.27': 5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80 + '@vitejs/plugin-rsc@0.5.27': 9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419 importers: @@ -289,7 +289,7 @@ importers: version: 7.0.0-dev.20260217.1 '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -380,7 +380,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) drizzle-kit: specifier: 'catalog:' version: 0.31.10 @@ -423,7 +423,7 @@ importers: devDependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.1 version: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)' @@ -444,7 +444,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -579,7 +579,7 @@ importers: version: 19.2.3(@types/react@19.2.16) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) postcss: specifier: 'catalog:' version: 8.5.10 @@ -618,7 +618,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -701,7 +701,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) postcss: specifier: 'catalog:' version: 8.5.10 @@ -734,7 +734,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) ms: specifier: 'catalog:' version: 3.0.0-canary.1 @@ -805,7 +805,7 @@ importers: version: 19.2.3(@types/react@19.2.16) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) typescript: specifier: 'catalog:' version: 5.9.3 @@ -900,7 +900,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -946,7 +946,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1041,7 +1041,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) am-i-vibing: specifier: 'catalog:' version: 0.5.0 @@ -1056,7 +1056,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) fake-context-lib: specifier: file:./__test_packages__/fake-context-lib version: file:tests/fixtures/app-basic/__test_packages__/fake-context-lib @@ -1090,7 +1090,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1115,7 +1115,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1142,7 +1142,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1173,7 +1173,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1210,7 +1210,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1276,7 +1276,7 @@ importers: version: 1.9.1 '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) better-auth: specifier: 'catalog:' version: 1.5.6(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.6.2)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.6.2)(kysely@0.28.15))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) @@ -1307,7 +1307,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-intl: specifier: 'catalog:' version: 4.11.1(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7)(typescript@5.9.3) @@ -1335,7 +1335,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1363,7 +1363,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-view-transitions: specifier: 'catalog:' version: 0.3.5(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1391,7 +1391,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) nuqs: specifier: 'catalog:' version: 2.8.8(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7) @@ -1428,7 +1428,7 @@ importers: version: 1.2.4(@types/react@19.2.16)(react@19.2.7) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) class-variance-authority: specifier: 'catalog:' version: 0.7.1 @@ -1484,7 +1484,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1509,7 +1509,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1534,7 +1534,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1621,7 +1621,7 @@ importers: version: 1.31.0(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(workerd@1.20260401.1)(wrangler@4.80.0) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1652,7 +1652,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1699,7 +1699,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1720,7 +1720,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -10559,7 +10559,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)' - '@vitejs/plugin-rsc@0.5.27(patch_hash=5585e7d4193cefa972d23c706ce87b1bec24805eefce734734425045613f1d80)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + '@vitejs/plugin-rsc@0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': dependencies: '@rolldown/pluginutils': 1.0.1 es-module-lexer: 2.1.0 From 2dc5a3aed4beaa60a236fbcd5b0fd59d8297a450 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 6 Jul 2026 20:17:07 +0100 Subject: [PATCH 13/17] fix(app-router): retain imported inline action ownership --- .gitattributes | 2 + .../vinext/src/build/action-owner-manifest.ts | 9 ++- patches/@vitejs__plugin-rsc@0.5.27.patch | 22 +++++-- pnpm-lock.yaml | 66 +++++++++---------- tests/action-owner-manifest.test.ts | 53 +++++++++++++++ tests/action-runtime-security.test.ts | 18 ++++- 6 files changed, 130 insertions(+), 40 deletions(-) diff --git a/.gitattributes b/.gitattributes index 317a8019f7..5c7eebae21 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,5 @@ # Prevents Windows clones (with core.autocrlf=true) from converting LF→CRLF # and breaking oxfmt, which only accepts LF. * text=auto eol=lf + +patches/*.patch whitespace=-space-before-tab diff --git a/packages/vinext/src/build/action-owner-manifest.ts b/packages/vinext/src/build/action-owner-manifest.ts index a6fe8a444e..71ad07b589 100644 --- a/packages/vinext/src/build/action-owner-manifest.ts +++ b/packages/vinext/src/build/action-owner-manifest.ts @@ -24,6 +24,7 @@ type ActionOwnerRoute = Pick< type ServerReferenceMeta = { exportNames: readonly string[]; importId: string; + inlineExportNames?: readonly string[]; referenceKey: string; }; @@ -437,10 +438,16 @@ export function buildActionOwnerManifest(options: { for (const reference of options.serverReferences) { const referenceId = canonicalizeModuleId(reference.importId); const isRouteComponent = routeComponentIds.has(referenceId); + const isReachableModule = reachableIds.has(referenceId); + const inlineExportNames = new Set(reference.inlineExportNames ?? []); if (isRouteComponent) addOwner(manifest, reference.referenceKey, route.pattern); for (const exportName of reference.exportNames) { const actionId = `${reference.referenceKey}#${exportName}`; - if (isRouteComponent || consumedActions.has(actionId)) { + if ( + isRouteComponent || + consumedActions.has(actionId) || + (isReachableModule && inlineExportNames.has(exportName)) + ) { addOwner(manifest, actionId, route.pattern); } } diff --git a/patches/@vitejs__plugin-rsc@0.5.27.patch b/patches/@vitejs__plugin-rsc@0.5.27.patch index 1a17642728..0327430ea9 100644 --- a/patches/@vitejs__plugin-rsc@0.5.27.patch +++ b/patches/@vitejs__plugin-rsc@0.5.27.patch @@ -1,5 +1,5 @@ diff --git a/dist/plugin-BK29Va7z.js b/dist/plugin-BK29Va7z.js -index 548c63135..4a1c68bb6 100644 +index 548c63135..3257d79d7 100644 --- a/dist/plugin-BK29Va7z.js +++ b/dist/plugin-BK29Va7z.js @@ -411,21 +411,37 @@ function scanBuildStripPlugin({ manager }) { @@ -53,8 +53,18 @@ index 548c63135..4a1c68bb6 100644 serverResourcesMetaMap = {}; environmentImportMetaMap = {}; stabilize() { +@@ -1606,7 +1624,8 @@ function vitePluginUseServer(useServerPluginOptions, manager) { + manager.serverReferenceMetaMap[id] = { + importId: id, + referenceKey: getNormalizedId(), +- exportNames: "names" in result ? result.names : result.exportNames ++ exportNames: "names" in result ? result.names : result.exportNames, ++ inlineExportNames: "names" in result ? result.names : [] + }; + const importSource = resolvePackage(`${PKG_NAME}/react/rsc`); + output.prepend(`import * as $$ReactServer from "${importSource}";\n`); diff --git a/dist/plugin-Bm-Fb4g7.d.ts b/dist/plugin-Bm-Fb4g7.d.ts -index b0fdd9374..3ddad9eed 100644 +index b0fdd9374..498a7509c 100644 --- a/dist/plugin-Bm-Fb4g7.d.ts +++ b/dist/plugin-Bm-Fb4g7.d.ts @@ -1,6 +1,7 @@ @@ -66,9 +76,11 @@ index b0fdd9374..3ddad9eed 100644 //#region src/plugins/import-environment.d.ts type EnvironmentImportMeta = { -@@ -25,6 +26,17 @@ type ServerRerferenceMeta = { +@@ -24,7 +25,19 @@ type ServerRerferenceMeta = { + importId: string; referenceKey: string; exportNames: string[]; ++ inlineExportNames?: string[]; }; +type ScanBuildObserver = (event: { + type: "reset"; @@ -84,7 +96,7 @@ index b0fdd9374..3ddad9eed 100644 /** * @experimental */ -@@ -37,6 +49,7 @@ declare class RscPluginManager { +@@ -37,6 +50,7 @@ declare class RscPluginManager { clientReferenceMetaMap: Record; clientReferenceGroups: Record; serverReferenceMetaMap: Record; @@ -92,7 +104,7 @@ index b0fdd9374..3ddad9eed 100644 serverResourcesMetaMap: Record; -@@ -182,4 +195,4 @@ declare function transformRscCssExport(options: { +@@ -182,4 +196,4 @@ declare function transformRscCssExport(options: { output: MagicString; } | undefined>; //#endregion diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a2149c1ad..d688969941 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,7 +250,7 @@ overrides: packageExtensionsChecksum: sha256-DELwn26pds5KeVDWtpKSByRpbPdf/D1CYmAVTGs16Cc= patchedDependencies: - '@vitejs/plugin-rsc@0.5.27': 9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419 + '@vitejs/plugin-rsc@0.5.27': 193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe importers: @@ -289,7 +289,7 @@ importers: version: 7.0.0-dev.20260217.1 '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -380,7 +380,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) drizzle-kit: specifier: 'catalog:' version: 0.31.10 @@ -423,7 +423,7 @@ importers: devDependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.1 version: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)' @@ -444,7 +444,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -579,7 +579,7 @@ importers: version: 19.2.3(@types/react@19.2.16) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) postcss: specifier: 'catalog:' version: 8.5.10 @@ -618,7 +618,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -701,7 +701,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) postcss: specifier: 'catalog:' version: 8.5.10 @@ -734,7 +734,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) ms: specifier: 'catalog:' version: 3.0.0-canary.1 @@ -805,7 +805,7 @@ importers: version: 19.2.3(@types/react@19.2.16) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) typescript: specifier: 'catalog:' version: 5.9.3 @@ -900,7 +900,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -946,7 +946,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1041,7 +1041,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) am-i-vibing: specifier: 'catalog:' version: 0.5.0 @@ -1056,7 +1056,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) fake-context-lib: specifier: file:./__test_packages__/fake-context-lib version: file:tests/fixtures/app-basic/__test_packages__/fake-context-lib @@ -1090,7 +1090,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1115,7 +1115,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1142,7 +1142,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1173,7 +1173,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1210,7 +1210,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1276,7 +1276,7 @@ importers: version: 1.9.1 '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) better-auth: specifier: 'catalog:' version: 1.5.6(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.6.2)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.6.2)(kysely@0.28.15))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) @@ -1307,7 +1307,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-intl: specifier: 'catalog:' version: 4.11.1(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7)(typescript@5.9.3) @@ -1335,7 +1335,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1363,7 +1363,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-view-transitions: specifier: 'catalog:' version: 0.3.5(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1391,7 +1391,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) nuqs: specifier: 'catalog:' version: 2.8.8(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7) @@ -1428,7 +1428,7 @@ importers: version: 1.2.4(@types/react@19.2.16)(react@19.2.7) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) class-variance-authority: specifier: 'catalog:' version: 0.7.1 @@ -1484,7 +1484,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1509,7 +1509,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1534,7 +1534,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1621,7 +1621,7 @@ importers: version: 1.31.0(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(workerd@1.20260401.1)(wrangler@4.80.0) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1652,7 +1652,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1699,7 +1699,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1720,7 +1720,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -10559,7 +10559,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)' - '@vitejs/plugin-rsc@0.5.27(patch_hash=9c07ab961d36927f8efc65b92f6143141e7eeed66fec7ebb9409dc355c5c7419)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + '@vitejs/plugin-rsc@0.5.27(patch_hash=193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': dependencies: '@rolldown/pluginutils': 1.0.1 es-module-lexer: 2.1.0 diff --git a/tests/action-owner-manifest.test.ts b/tests/action-owner-manifest.test.ts index 350e7dbd32..913218c367 100644 --- a/tests/action-owner-manifest.test.ts +++ b/tests/action-owner-manifest.test.ts @@ -29,6 +29,59 @@ function route(pattern: string, pagePath: string) { } describe("server action owner manifest", () => { + it("owns inline actions declared by reachable imported Server Components", () => { + expect( + buildActionOwnerManifest({ + moduleInfo: { + getModuleInfo(id) { + return id === "/app/page.tsx" + ? { importedIds: ["/app/imported-form.tsx"] } + : { importedIds: [] }; + }, + }, + routes: [route("/", "/app/page.tsx")], + serverReferenceModuleEdges: {}, + serverReferences: [ + { + exportNames: ["$$hoist_0_submit"], + importId: "/app/imported-form.tsx", + inlineExportNames: ["$$hoist_0_submit"], + referenceKey: "imported-form", + }, + ], + }), + ).toEqual({ "imported-form#$$hoist_0_submit": ["/"] }); + }); + + it("does not infer inline action provenance from user-defined export names", () => { + expect( + buildActionOwnerManifest({ + moduleInfo: { + getModuleInfo(id) { + return id === "/app/page.tsx" + ? { importedIds: ["/app/actions.ts"] } + : { importedIds: [] }; + }, + }, + routes: [route("/", "/app/page.tsx")], + serverReferenceModuleEdges: { + "/app/page.tsx": { + imports: [{ exportNames: ["publicAction"], sourceId: "/app/actions.ts" }], + reexports: [], + }, + }, + serverReferences: [ + { + exportNames: ["publicAction", "$$hoist_0_adminOnly"], + importId: "/app/actions.ts", + inlineExportNames: [], + referenceKey: "actions", + }, + ], + }), + ).toEqual({ "actions#publicAction": ["/"] }); + }); + it("collects resolved edges across scan environments and resets at the RSC boundary", () => { const scan = createActionOwnerScanObserver(); const moduleEvent = { diff --git a/tests/action-runtime-security.test.ts b/tests/action-runtime-security.test.ts index c6b25109ee..7d1d275ad4 100644 --- a/tests/action-runtime-security.test.ts +++ b/tests/action-runtime-security.test.ts @@ -122,8 +122,17 @@ export async function goTo(target) { redirect(target); } await write( "app/page.tsx", `import { goTo } from "./actions"; +import { ImportedActionForm } from "./imported-action-form"; export default function Page() { - return
; + return <>
; +} +`, + ); + await write( + "app/imported-action-form.tsx", + `export function ImportedActionForm() { + const importedInlineAction = async () => { "use server"; return "IMPORTED_INLINE_ACTION_EXECUTED"; }; + return
; } `, ); @@ -310,6 +319,13 @@ export default function Page() { expect(await response.text()).toBe("{}"); }); + it("retains inline action ownership in imported Server Components", async () => { + const importedActionId = findActionId(builtSource, "$$hoist_0_importedInlineAction"); + const response = await handler(actionRequest("/", importedActionId, [])); + expect(response.status).toBe(200); + expect(await response.text()).toContain("IMPORTED_INLINE_ACTION_EXECUTED"); + }); + it("blocks cross-action closure replay and the composed redirect chain before action decode", async () => { const encryptedBoundArgs = await encryptBoundArgsFixture('0:["victim-carol"]\n'); const encryptedBytes = Buffer.from(encryptedBoundArgs, "base64"); From 81b60d092656d3f184bec7303fd9b1e49d6e1feb Mon Sep 17 00:00:00 2001 From: James Date: Mon, 6 Jul 2026 21:44:06 +0100 Subject: [PATCH 14/17] test(actions): cover production action ownership edge cases --- .../action-owner-production.browser.spec.ts | 406 ++++++++++++++++++ .../app-action-ownership/app/layout.tsx | 9 + .../app/ownership/action-button.tsx | 21 + .../app/ownership/actions/admin.ts | 7 + .../app/ownership/actions/alias.ts | 4 + .../app/ownership/actions/barrel.ts | 4 + .../app/ownership/actions/client.ts | 4 + .../app/ownership/actions/cycle.ts | 4 + .../app/ownership/actions/default.ts | 4 + .../app/ownership/actions/direct.ts | 4 + .../app/ownership/actions/duplicate.ts | 7 + .../app/ownership/actions/dynamic.ts | 4 + .../app/ownership/actions/mixed.ts | 9 + .../app/ownership/actions/namespace-barrel.ts | 5 + .../app/ownership/actions/namespace.ts | 4 + .../app/ownership/actions/side-effect.ts | 5 + .../app/ownership/actions/star.ts | 4 + .../app/ownership/admin/page.tsx | 5 + .../app/ownership/alias/page.tsx | 5 + .../app/ownership/barrel/page.tsx | 5 + .../app/ownership/barrels/cycle-a.ts | 2 + .../app/ownership/barrels/cycle-b.ts | 2 + .../app/ownership/barrels/named.ts | 1 + .../app/ownership/barrels/namespace.ts | 1 + .../app/ownership/barrels/star.ts | 1 + .../app/ownership/client/client-button.tsx | 19 + .../app/ownership/client/page.tsx | 4 + .../app/ownership/cycle/page.tsx | 5 + .../app/ownership/default/page.tsx | 5 + .../app/ownership/direct/page.tsx | 5 + .../app/ownership/duplicate/page.tsx | 11 + .../app/ownership/dynamic/page.tsx | 5 + .../imported-inline/imported-form.tsx | 8 + .../app/ownership/imported-inline/page.tsx | 4 + .../app/ownership/layout-owner/layout.tsx | 15 + .../app/ownership/layout-owner/page.tsx | 3 + .../app/ownership/mixed/page.tsx | 12 + .../app/ownership/namespace-barrel/page.tsx | 6 + .../app/ownership/namespace/page.tsx | 5 + .../app/ownership/public/page.tsx | 5 + .../app/ownership/report/admin/actions.ts | 5 + .../app/ownership/report/admin/page.tsx | 6 + .../ownership/report/admin/replay/page.tsx | 11 + .../ownership/report/admin/secret/page.tsx | 3 + .../ownership/report/admin/shared/page.tsx | 6 + .../ownership/report/client-owner/actions.ts | 5 + .../report/client-owner/client-button.tsx | 7 + .../ownership/report/client-owner/page.tsx | 5 + .../ownership/report/cookie-owner/page.tsx | 13 + .../ownership/report/cookie-source/page.tsx | 3 + .../ownership/report/dynamic/[id]/page.tsx | 10 + .../app/ownership/report/loop/page.tsx | 10 + .../app/ownership/report/oracle/page.tsx | 15 + .../app/ownership/report/public/page.tsx | 9 + .../app/ownership/report/redirect/actions.ts | 7 + .../app/ownership/report/redirect/page.tsx | 9 + .../app/ownership/report/shared-actions.ts | 9 + .../app/ownership/report/shared/page.tsx | 6 + .../app/ownership/route-inline/page.tsx | 8 + .../app/ownership/side-effect/page.tsx | 5 + .../app/ownership/star/page.tsx | 5 + .../app-action-ownership/middleware.ts | 32 ++ .../app-action-ownership/package.json | 5 + 63 files changed, 833 insertions(+) create mode 100644 tests/e2e/app-router/action-owner-production.browser.spec.ts create mode 100644 tests/fixtures/app-action-ownership/app/layout.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/action-button.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/admin.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/alias.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/barrel.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/client.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/cycle.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/default.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/direct.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/duplicate.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/dynamic.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/mixed.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/namespace-barrel.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/namespace.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/side-effect.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/star.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/admin/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/alias/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/barrel/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/barrels/cycle-a.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/barrels/cycle-b.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/barrels/named.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/barrels/namespace.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/barrels/star.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/client/client-button.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/client/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/cycle/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/default/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/direct/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/duplicate/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/dynamic/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/imported-inline/imported-form.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/imported-inline/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/layout-owner/layout.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/layout-owner/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/mixed/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/namespace-barrel/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/namespace/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/public/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/admin/actions.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/admin/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/admin/replay/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/admin/secret/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/admin/shared/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/client-owner/actions.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/client-owner/client-button.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/client-owner/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/cookie-owner/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/cookie-source/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/dynamic/[id]/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/loop/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/oracle/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/public/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/redirect/actions.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/redirect/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/shared-actions.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/report/shared/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/route-inline/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/side-effect/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/star/page.tsx create mode 100644 tests/fixtures/app-action-ownership/middleware.ts create mode 100644 tests/fixtures/app-action-ownership/package.json diff --git a/tests/e2e/app-router/action-owner-production.browser.spec.ts b/tests/e2e/app-router/action-owner-production.browser.spec.ts new file mode 100644 index 0000000000..4e8f737282 --- /dev/null +++ b/tests/e2e/app-router/action-owner-production.browser.spec.ts @@ -0,0 +1,406 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { expect, test, type Page } from "@playwright/test"; +import { createBuilder } from "vite"; +import { + startChildProductionServer, + stopChildProductionServer, + type ChildProductionServer, +} from "../production-server"; +import { waitForAppRouterHydration } from "../helpers"; + +const FIXTURE_DIR = path.resolve(process.cwd(), "tests/fixtures/app-action-ownership"); + +type ProductionApp = { + actionIds: { + adminOnly: string; + adminShared: string; + cookieOwner: string; + dynamicProtected: string; + loop: string; + protected: string; + protectedClient: string; + redirectTo: string; + sideEffectSecret: string; + }; + deleteAccountIds: string[]; + baseUrl: string; + fixtureRoot: string; + offlineProtectedActionId: string; + server: ChildProductionServer; +}; + +async function linkFixtureNodeModules(fixtureRoot: string): Promise { + const sourceNodeModules = path.resolve(process.cwd(), "tests/fixtures/app-basic/node_modules"); + const targetNodeModules = path.join(fixtureRoot, "node_modules"); + await fs.mkdir(targetNodeModules, { recursive: true }); + for (const entry of await fs.readdir(sourceNodeModules, { withFileTypes: true })) { + if (entry.name === ".vite" || entry.name === ".vite-temp") continue; + await fs.symlink( + path.join(sourceNodeModules, entry.name), + path.join(targetNodeModules, entry.name), + entry.isDirectory() ? "junction" : "file", + ); + } +} + +async function readBuiltJavaScript(directory: string): Promise { + let output = ""; + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) output += await readBuiltJavaScript(entryPath); + else if (entry.name.endsWith(".js")) output += await fs.readFile(entryPath, "utf8"); + } + return output; +} + +function findActionId(source: string, exportName: string): string { + const escapedExportName = exportName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = source.match( + new RegExp(`["'\`]([0-9a-f]{12})["'\`]\\s*,\\s*["'\`]${escapedExportName}["'\`]`), + ); + if (!match) throw new Error(`Missing built action id for ${exportName}`); + return `${match[1]}#${exportName}`; +} + +function findActionIds(source: string, exportName: string): string[] { + const escapedExportName = exportName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const matches = [ + ...source.matchAll( + new RegExp(`["'\`]([0-9a-f]{12})["'\`]\\s*,\\s*["'\`]${escapedExportName}["'\`]`, "g"), + ), + ].map((match) => `${match[1]}#${exportName}`); + if (matches.length === 0) throw new Error(`Missing built action id for ${exportName}`); + return [...new Set(matches)]; +} + +async function buildAndServeFixture(): Promise { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-action-owner-e2e-")); + await fs.cp(FIXTURE_DIR, fixtureRoot, { recursive: true }); + await linkFixtureNodeModules(fixtureRoot); + + const vinext = (await import("../../../packages/vinext/src/index.js")).default; + const builder = await createBuilder({ + root: fixtureRoot, + configFile: false, + plugins: [vinext({ appDir: fixtureRoot })], + logLevel: "silent", + }); + await builder.buildApp(); + + const builtSource = await readBuiltJavaScript(path.join(fixtureRoot, "dist", "server")); + const protectedActionPath = await fs.realpath( + path.join(fixtureRoot, "app/ownership/report/admin/actions.ts"), + ); + const server = await startChildProductionServer(fixtureRoot); + return { + actionIds: { + adminOnly: findActionId(builtSource, "$$hoist_0_adminOnly"), + adminShared: findActionId(builtSource, "adminSharedAction"), + cookieOwner: findActionId(builtSource, "$$hoist_0_readForwardedCredentials"), + dynamicProtected: findActionId(builtSource, "$$hoist_0_dynamicProtectedAction"), + loop: findActionId(builtSource, "$$hoist_0_loopAction"), + protected: findActionId(builtSource, "protectedAction"), + protectedClient: findActionId(builtSource, "protectedClientAction"), + redirectTo: findActionId(builtSource, "redirectTo"), + sideEffectSecret: findActionId(builtSource, "sideEffectSecretAction"), + }, + baseUrl: `http://127.0.0.1:${server.port}`, + deleteAccountIds: findActionIds(builtSource, "$$hoist_0_deleteAccount"), + fixtureRoot, + offlineProtectedActionId: `${createHash("sha256") + .update(path.relative(fixtureRoot, protectedActionPath)) + .digest("hex") + .slice(0, 12)}#protectedAction`, + server, + }; +} + +async function executeAction(page: Page, route: string, id: string) { + await page.goto(`${app.baseUrl}/ownership/${route}`); + await waitForAppRouterHydration(page); + await page.getByTestId(id).click(); + return page.getByTestId(`${id}-result`); +} + +async function postAction(page: Page, pathname: string, actionId: string, args: unknown[] = []) { + await page.goto(`${app.baseUrl}${pathname}`); + return page.evaluate( + async ({ actionId, args, pathname }) => { + const response = await fetch(pathname, { + body: JSON.stringify(args), + headers: { + "content-type": "text/plain;charset=UTF-8", + "next-action": actionId, + }, + method: "POST", + }); + return { + body: await response.text(), + headers: Object.fromEntries(response.headers), + status: response.status, + }; + }, + { actionId, args, pathname }, + ); +} + +async function postRawAction( + page: Page, + pathname: string, + actionId: string, + body: string, + contentType: string, +) { + await page.goto(`${app.baseUrl}${pathname}`); + return page.evaluate( + async ({ actionId, body, contentType, pathname }) => { + const response = await fetch(pathname, { + body, + headers: { "content-type": contentType, "next-action": actionId }, + method: "POST", + }); + return { + body: await response.text(), + headers: Object.fromEntries(response.headers), + status: response.status, + }; + }, + { actionId, body, contentType, pathname }, + ); +} + +let app: ProductionApp; + +test.beforeAll(async () => { + test.setTimeout(120_000); + app = await buildAndServeFixture(); +}); + +test.afterAll(async () => { + if (!app) return; + await stopChildProductionServer(app.server); + await fs.rm(app.fixtureRoot, { recursive: true, force: true }); +}); + +test.setTimeout(60_000); + +test.describe("production server action ownership", () => { + test("executes actions through every supported ownership topology", async ({ page }) => { + const cases = [ + ["direct", "direct", "DIRECT_OK"], + ["alias", "alias", "ALIAS_OK"], + ["barrel", "barrel", "BARREL_OK"], + ["star", "star", "STAR_OK"], + ["namespace", "namespace", "NAMESPACE_OK"], + ["namespace-barrel", "namespace-barrel", "NAMESPACE_BARREL_OK"], + ["dynamic", "dynamic", "DYNAMIC_OK"], + ["default", "default", "DEFAULT_OK"], + ["cycle", "cycle", "CYCLE_OK"], + ["client", "client", "CLIENT_OK"], + ["imported-inline", "imported-inline", "IMPORTED_INLINE_OK"], + ["route-inline", "route-inline", "ROUTE_INLINE_OK"], + ["layout-owner", "layout-owner", "LAYOUT_OK"], + ["public", "public", "PUBLIC_OK"], + ["duplicate", "duplicate-first", "DUPLICATE_FIRST_OK"], + ["duplicate", "duplicate-second", "DUPLICATE_SECOND_OK"], + ["mixed", "mixed-static", "MIXED_STATIC_OK"], + ["mixed", "mixed-dynamic", "MIXED_DYNAMIC_OK"], + ["report/dynamic/example", "dynamic-protected", "DYNAMIC_PROTECTED_ACTION_EXECUTED"], + ["report/shared", "public-shared", "PUBLIC_SHARED_ACTION_EXECUTED"], + ] as const; + + const expectedAssertions = 20; + let completedAssertions = 0; + for (const [route, id, expected] of cases) { + await expect(await executeAction(page, route, id)).toHaveText(expected); + completedAssertions++; + } + if (completedAssertions !== expectedAssertions) { + throw new Error( + `Expected ${expectedAssertions} action assertions, completed ${completedAssertions}`, + ); + } + }); + + test("does not infer ownership from a user-shaped hoist name", async ({ page }) => { + const directOwnerResponse = await page.goto(`${app.baseUrl}/ownership/admin`); + expect(directOwnerResponse?.status()).toBe(401); + await expect(page.locator("body")).toHaveText("ADMIN_BLOCKED"); + + await page.goto(`${app.baseUrl}/ownership/public`); + const response = await page.evaluate( + async ({ actionId }) => { + const result = await fetch("/ownership/public", { + body: "[]", + headers: { + "content-type": "text/plain;charset=UTF-8", + "next-action": actionId, + }, + method: "POST", + }); + return { body: await result.text(), status: result.status }; + }, + { actionId: app.actionIds.adminOnly }, + ); + expect(response.status).toBe(200); + expect(response.body).not.toContain("ADMIN_ONLY_EXECUTED"); + }); + + test("re-enters protected owner middleware for forged public-path requests", async ({ page }) => { + const directOwnerResponse = await page.goto(`${app.baseUrl}/ownership/report/admin`); + expect(directOwnerResponse?.status()).toBe(401); + await expect(page.locator("body")).toHaveText("ADMIN_BLOCKED"); + + const response = await postAction(page, "/ownership/report/public", app.actionIds.protected); + expect(response.status).toBe(200); + expect(response.body).toBe("{}"); + expect(response.body).not.toContain("PROTECTED_ACTION_EXECUTED"); + }); + + test("retains protected ownership through client component boundaries", async ({ page }) => { + const directOwnerResponse = await page.goto(`${app.baseUrl}/ownership/report/client-owner`); + expect(directOwnerResponse?.status()).toBe(401); + + const response = await postAction( + page, + "/ownership/report/public", + app.actionIds.protectedClient, + ); + expect(response.status).toBe(200); + expect(response.body).toBe("{}"); + expect(response.body).not.toContain("PROTECTED_CLIENT_ACTION_EXECUTED"); + }); + + test("keeps offline-computable production action ids harmless", async ({ page }) => { + expect(app.offlineProtectedActionId).toBe(app.actionIds.protected); + + const response = await postAction( + page, + "/ownership/report/public", + app.offlineProtectedActionId, + ); + expect(response.status).toBe(200); + expect(response.body).toBe("{}"); + expect(response.body).not.toContain("PROTECTED_ACTION_EXECUTED"); + }); + + test("does not inline middleware-gated redirect targets", async ({ page }) => { + const directTarget = await page.goto(`${app.baseUrl}/ownership/report/admin/secret`); + expect(directTarget?.status()).toBe(401); + await expect(page.locator("body")).toHaveText("ADMIN_BLOCKED"); + + const response = await postAction(page, "/ownership/report/public", app.actionIds.redirectTo, [ + "/ownership/report/admin/secret", + ]); + expect(response.status).toBe(303); + expect(response.headers["x-action-redirect"]).toBe("/ownership/report/admin/secret"); + expect(response.body).toBe(""); + expect(response.body).not.toContain("ADMIN_SECRET_MARKER_42"); + }); + + test("blocks encrypted closure replay across actions", async ({ page }) => { + await page.goto(`${app.baseUrl}/ownership/report/oracle?value=victim-carol`); + await waitForAppRouterHydration(page); + const oracleRequestPromise = page.waitForRequest( + (request) => request.method() === "POST" && Boolean(request.headers()["next-action"]), + ); + await page.getByTestId("oracle").click(); + await expect(page.getByTestId("oracle-result")).toHaveText("ORACLE:victim-carol"); + const oracleRequest = await oracleRequestPromise; + const replayBody = oracleRequest.postData(); + const contentType = oracleRequest.headers()["content-type"]; + expect(replayBody).not.toBeNull(); + expect(contentType).toBeTruthy(); + + for (const actionId of app.deleteAccountIds) { + const response = await postRawAction( + page, + "/ownership/report/public", + actionId, + replayBody!, + contentType!, + ); + expect(response.status).toBe(200); + expect(response.headers["x-action-redirect"]).toBeUndefined(); + expect(response.body).toBe("{}"); + expect(response.body).not.toContain("ADMIN_SECRET_MARKER_42"); + expect(response.body).not.toContain("victim-carol"); + } + }); + + test("tracks ownership per export in shared action modules", async ({ page }) => { + const directAdminOwner = await page.goto(`${app.baseUrl}/ownership/report/admin/shared`); + expect(directAdminOwner?.status()).toBe(401); + const response = await postAction(page, "/ownership/report/shared", app.actionIds.adminShared); + expect(response.status).toBe(200); + expect(response.body).toBe("{}"); + expect(response.body).not.toContain("ADMIN_SHARED_ACTION_EXECUTED"); + }); + + test("forwards dynamic owners using their route pattern", async ({ page }) => { + const response = await postAction( + page, + "/ownership/report/public", + app.actionIds.dynamicProtected, + ); + expect(response.status).toBe(200); + expect(response.body).toBe("{}"); + expect(response.body).not.toContain("DYNAMIC_PROTECTED_ACTION_EXECUTED"); + }); + + test("propagates middleware request headers and response cookies", async ({ page }) => { + const response = await postAction( + page, + "/ownership/report/cookie-source", + app.actionIds.cookieOwner, + ); + expect(response.status).toBe(200); + expect(response.body).toContain("trusted:present"); + }); + + test("stops recursive forwarding after an owner rewrite", async ({ page }) => { + const response = await postAction(page, "/ownership/report/public", app.actionIds.loop); + expect(response.status).toBe(200); + expect(response.body).toBe("{}"); + expect(response.body).not.toContain("LOOP_ACTION_EXECUTED"); + }); + + test("fails closed for unknown action ids", async ({ page }) => { + await page.goto(`${app.baseUrl}/ownership/direct`); + const status = await page.evaluate(async () => { + const response = await fetch("/ownership/direct", { + body: "[]", + headers: { + "content-type": "text/plain;charset=UTF-8", + "next-action": "000000000000#missing", + }, + method: "POST", + }); + return response.status; + }); + expect(status).toBe(404); + }); + + test("does not grant ownership through side-effect-only imports", async ({ page }) => { + await page.goto(`${app.baseUrl}/ownership/side-effect`); + const response = await page.evaluate( + async ({ actionId }) => { + const result = await fetch("/ownership/side-effect", { + body: "[]", + headers: { + "content-type": "text/plain;charset=UTF-8", + "next-action": actionId, + }, + method: "POST", + }); + return { body: await result.text(), status: result.status }; + }, + { actionId: app.actionIds.sideEffectSecret }, + ); + expect(response.status).toBe(404); + expect(response.body).not.toContain("SIDE_EFFECT_SECRET_EXECUTED"); + }); +}); diff --git a/tests/fixtures/app-action-ownership/app/layout.tsx b/tests/fixtures/app-action-ownership/app/layout.tsx new file mode 100644 index 0000000000..9d7e99c961 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +export default function Layout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/action-button.tsx b/tests/fixtures/app-action-ownership/app/ownership/action-button.tsx new file mode 100644 index 0000000000..af9eafd1b8 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/action-button.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useState, useTransition } from "react"; + +export function ActionButton({ action, id }: { action: () => Promise; id: string }) { + const [result, setResult] = useState(""); + const [pending, startTransition] = useTransition(); + + return ( +
+ + {result} +
+ ); +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/admin.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/admin.ts new file mode 100644 index 0000000000..75bdc07250 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/admin.ts @@ -0,0 +1,7 @@ +"use server"; +export async function publicAction() { + return "PUBLIC_OK"; +} +export async function $$hoist_0_adminOnly() { + return "ADMIN_ONLY_EXECUTED"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/alias.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/alias.ts new file mode 100644 index 0000000000..c4aaf84fba --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/alias.ts @@ -0,0 +1,4 @@ +"use server"; +export async function originalAliasAction() { + return "ALIAS_OK"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/barrel.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/barrel.ts new file mode 100644 index 0000000000..57326eb82d --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/barrel.ts @@ -0,0 +1,4 @@ +"use server"; +export async function barrelAction() { + return "BARREL_OK"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/client.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/client.ts new file mode 100644 index 0000000000..3e5cf38876 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/client.ts @@ -0,0 +1,4 @@ +"use server"; +export async function clientImportedAction() { + return "CLIENT_OK"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/cycle.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/cycle.ts new file mode 100644 index 0000000000..36e81afbf5 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/cycle.ts @@ -0,0 +1,4 @@ +"use server"; +export async function cycleAction() { + return "CYCLE_OK"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/default.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/default.ts new file mode 100644 index 0000000000..6712bbac57 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/default.ts @@ -0,0 +1,4 @@ +"use server"; +export default async function defaultAction() { + return "DEFAULT_OK"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/direct.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/direct.ts new file mode 100644 index 0000000000..bc185b38ff --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/direct.ts @@ -0,0 +1,4 @@ +"use server"; +export async function directAction() { + return "DIRECT_OK"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/duplicate.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/duplicate.ts new file mode 100644 index 0000000000..4d02422a91 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/duplicate.ts @@ -0,0 +1,7 @@ +"use server"; +export async function duplicateFirstAction() { + return "DUPLICATE_FIRST_OK"; +} +export async function duplicateSecondAction() { + return "DUPLICATE_SECOND_OK"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/dynamic.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/dynamic.ts new file mode 100644 index 0000000000..94cc5a2be5 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/dynamic.ts @@ -0,0 +1,4 @@ +"use server"; +export async function dynamicAction() { + return "DYNAMIC_OK"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/mixed.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/mixed.ts new file mode 100644 index 0000000000..b74801b39d --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/mixed.ts @@ -0,0 +1,9 @@ +"use server"; + +export async function mixedStaticAction() { + return "MIXED_STATIC_OK"; +} + +export async function mixedDynamicAction() { + return "MIXED_DYNAMIC_OK"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/namespace-barrel.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/namespace-barrel.ts new file mode 100644 index 0000000000..0fef2fefe2 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/namespace-barrel.ts @@ -0,0 +1,5 @@ +"use server"; + +export async function namespaceBarrelAction() { + return "NAMESPACE_BARREL_OK"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/namespace.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/namespace.ts new file mode 100644 index 0000000000..ca138805b0 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/namespace.ts @@ -0,0 +1,4 @@ +"use server"; +export async function namespaceAction() { + return "NAMESPACE_OK"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/side-effect.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/side-effect.ts new file mode 100644 index 0000000000..c4690ef090 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/side-effect.ts @@ -0,0 +1,5 @@ +"use server"; + +export async function sideEffectSecretAction() { + return "SIDE_EFFECT_SECRET_EXECUTED"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/star.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/star.ts new file mode 100644 index 0000000000..9f8f323dc7 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/star.ts @@ -0,0 +1,4 @@ +"use server"; +export async function starAction() { + return "STAR_OK"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/admin/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/admin/page.tsx new file mode 100644 index 0000000000..95d587662f --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/admin/page.tsx @@ -0,0 +1,5 @@ +import { ActionButton } from "../action-button"; +import { $$hoist_0_adminOnly } from "../actions/admin"; +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/alias/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/alias/page.tsx new file mode 100644 index 0000000000..5425238d4f --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/alias/page.tsx @@ -0,0 +1,5 @@ +import { ActionButton } from "../action-button"; +import { originalAliasAction as action } from "../actions/alias"; +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/barrel/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/barrel/page.tsx new file mode 100644 index 0000000000..697d9e7c48 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/barrel/page.tsx @@ -0,0 +1,5 @@ +import { ActionButton } from "../action-button"; +import { renamedBarrelAction as action } from "../barrels/named"; +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/barrels/cycle-a.ts b/tests/fixtures/app-action-ownership/app/ownership/barrels/cycle-a.ts new file mode 100644 index 0000000000..de4882998c --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/barrels/cycle-a.ts @@ -0,0 +1,2 @@ +export { cycleAction } from "../actions/cycle"; +export { cycleMarker } from "./cycle-b"; diff --git a/tests/fixtures/app-action-ownership/app/ownership/barrels/cycle-b.ts b/tests/fixtures/app-action-ownership/app/ownership/barrels/cycle-b.ts new file mode 100644 index 0000000000..04636b76da --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/barrels/cycle-b.ts @@ -0,0 +1,2 @@ +export const cycleMarker = "cycle"; +export { cycleAction } from "./cycle-a"; diff --git a/tests/fixtures/app-action-ownership/app/ownership/barrels/named.ts b/tests/fixtures/app-action-ownership/app/ownership/barrels/named.ts new file mode 100644 index 0000000000..6c811f1107 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/barrels/named.ts @@ -0,0 +1 @@ +export { barrelAction as renamedBarrelAction } from "../actions/barrel"; diff --git a/tests/fixtures/app-action-ownership/app/ownership/barrels/namespace.ts b/tests/fixtures/app-action-ownership/app/ownership/barrels/namespace.ts new file mode 100644 index 0000000000..cb7279f9f2 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/barrels/namespace.ts @@ -0,0 +1 @@ +export * as groupedActions from "../actions/namespace-barrel"; diff --git a/tests/fixtures/app-action-ownership/app/ownership/barrels/star.ts b/tests/fixtures/app-action-ownership/app/ownership/barrels/star.ts new file mode 100644 index 0000000000..4a70494260 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/barrels/star.ts @@ -0,0 +1 @@ +export * from "../actions/star"; diff --git a/tests/fixtures/app-action-ownership/app/ownership/client/client-button.tsx b/tests/fixtures/app-action-ownership/app/ownership/client/client-button.tsx new file mode 100644 index 0000000000..f92bdb8211 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/client/client-button.tsx @@ -0,0 +1,19 @@ +"use client"; +import { useState, useTransition } from "react"; +import { clientImportedAction } from "../actions/client"; +export function ClientButton() { + const [result, setResult] = useState(""); + const [pending, startTransition] = useTransition(); + return ( + <> + + {result} + + ); +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/client/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/client/page.tsx new file mode 100644 index 0000000000..e192730137 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/client/page.tsx @@ -0,0 +1,4 @@ +import { ClientButton } from "./client-button"; +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/cycle/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/cycle/page.tsx new file mode 100644 index 0000000000..f5d021f575 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/cycle/page.tsx @@ -0,0 +1,5 @@ +import { ActionButton } from "../action-button"; +import { cycleAction as action } from "../barrels/cycle-b"; +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/default/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/default/page.tsx new file mode 100644 index 0000000000..7c0445e9e4 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/default/page.tsx @@ -0,0 +1,5 @@ +import { ActionButton } from "../action-button"; +import action from "../actions/default"; +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/direct/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/direct/page.tsx new file mode 100644 index 0000000000..598d242a7d --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/direct/page.tsx @@ -0,0 +1,5 @@ +import { ActionButton } from "../action-button"; +import { directAction } from "../actions/direct"; +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/duplicate/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/duplicate/page.tsx new file mode 100644 index 0000000000..a1299edb78 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/duplicate/page.tsx @@ -0,0 +1,11 @@ +import { ActionButton } from "../action-button"; +import { duplicateFirstAction } from "../actions/duplicate"; +import { duplicateSecondAction } from "../actions/duplicate"; +export default function Page() { + return ( + <> + + + + ); +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/dynamic/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/dynamic/page.tsx new file mode 100644 index 0000000000..c296224a7c --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/dynamic/page.tsx @@ -0,0 +1,5 @@ +import { ActionButton } from "../action-button"; +export default async function Page() { + const { dynamicAction } = await import("../actions/dynamic"); + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/imported-inline/imported-form.tsx b/tests/fixtures/app-action-ownership/app/ownership/imported-inline/imported-form.tsx new file mode 100644 index 0000000000..393e963b60 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/imported-inline/imported-form.tsx @@ -0,0 +1,8 @@ +import { ActionButton } from "../action-button"; +export function ImportedForm() { + const importedInlineAction = async () => { + "use server"; + return "IMPORTED_INLINE_OK"; + }; + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/imported-inline/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/imported-inline/page.tsx new file mode 100644 index 0000000000..1a1c199cfd --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/imported-inline/page.tsx @@ -0,0 +1,4 @@ +import { ImportedForm } from "./imported-form"; +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/layout-owner/layout.tsx b/tests/fixtures/app-action-ownership/app/ownership/layout-owner/layout.tsx new file mode 100644 index 0000000000..6223fbad9a --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/layout-owner/layout.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; +import { ActionButton } from "../action-button"; + +export default function Layout({ children }: { children: ReactNode }) { + const layoutAction = async () => { + "use server"; + return "LAYOUT_OK"; + }; + return ( + <> + + {children} + + ); +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/layout-owner/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/layout-owner/page.tsx new file mode 100644 index 0000000000..cf72e27b4d --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/layout-owner/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

layout owner page

; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/mixed/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/mixed/page.tsx new file mode 100644 index 0000000000..d6467f69a9 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/mixed/page.tsx @@ -0,0 +1,12 @@ +import { ActionButton } from "../action-button"; +import { mixedStaticAction } from "../actions/mixed"; + +export default async function Page() { + const { mixedDynamicAction } = await import("../actions/mixed"); + return ( + <> + + + + ); +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/namespace-barrel/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/namespace-barrel/page.tsx new file mode 100644 index 0000000000..80682ecfb0 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/namespace-barrel/page.tsx @@ -0,0 +1,6 @@ +import { ActionButton } from "../action-button"; +import { groupedActions } from "../barrels/namespace"; + +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/namespace/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/namespace/page.tsx new file mode 100644 index 0000000000..42277bb127 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/namespace/page.tsx @@ -0,0 +1,5 @@ +import { ActionButton } from "../action-button"; +import * as actions from "../actions/namespace"; +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/public/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/public/page.tsx new file mode 100644 index 0000000000..fd0e297b83 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/public/page.tsx @@ -0,0 +1,5 @@ +import { ActionButton } from "../action-button"; +import { publicAction } from "../actions/admin"; +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/admin/actions.ts b/tests/fixtures/app-action-ownership/app/ownership/report/admin/actions.ts new file mode 100644 index 0000000000..1ca92bc2cf --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/admin/actions.ts @@ -0,0 +1,5 @@ +"use server"; + +export async function protectedAction() { + return "PROTECTED_ACTION_EXECUTED"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/admin/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/report/admin/page.tsx new file mode 100644 index 0000000000..ce2a6915f5 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/admin/page.tsx @@ -0,0 +1,6 @@ +import { ActionButton } from "../../action-button"; +import { protectedAction } from "./actions"; + +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/admin/replay/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/report/admin/replay/page.tsx new file mode 100644 index 0000000000..d3034aef6b --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/admin/replay/page.tsx @@ -0,0 +1,11 @@ +import { redirect } from "next/navigation"; + +export default function Page() { + const accountToDelete = "alice"; + const deleteAccount = async () => { + "use server"; + redirect(`/ownership/report/admin/secret?deleted=${accountToDelete}`); + }; + + return
; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/admin/secret/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/report/admin/secret/page.tsx new file mode 100644 index 0000000000..d8191d0a71 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/admin/secret/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
ADMIN_SECRET_MARKER_42
; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/admin/shared/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/report/admin/shared/page.tsx new file mode 100644 index 0000000000..f739a06d4f --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/admin/shared/page.tsx @@ -0,0 +1,6 @@ +import { ActionButton } from "../../../action-button"; +import { adminSharedAction } from "../../shared-actions"; + +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/client-owner/actions.ts b/tests/fixtures/app-action-ownership/app/ownership/report/client-owner/actions.ts new file mode 100644 index 0000000000..e605d527e4 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/client-owner/actions.ts @@ -0,0 +1,5 @@ +"use server"; + +export async function protectedClientAction() { + return "PROTECTED_CLIENT_ACTION_EXECUTED"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/client-owner/client-button.tsx b/tests/fixtures/app-action-ownership/app/ownership/report/client-owner/client-button.tsx new file mode 100644 index 0000000000..d18d25a677 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/client-owner/client-button.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { protectedClientAction } from "./actions"; + +export function ClientButton() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/client-owner/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/report/client-owner/page.tsx new file mode 100644 index 0000000000..040209dc9f --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/client-owner/page.tsx @@ -0,0 +1,5 @@ +import { ClientButton } from "./client-button"; + +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/cookie-owner/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/report/cookie-owner/page.tsx new file mode 100644 index 0000000000..07b7dfabf4 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/cookie-owner/page.tsx @@ -0,0 +1,13 @@ +import { cookies, headers } from "next/headers"; +import { ActionButton } from "../../action-button"; + +export default function Page() { + const readForwardedCredentials = async () => { + "use server"; + const auth = (await headers()).get("x-forwarded-auth") ?? "missing"; + const cookie = (await cookies()).get("forwarded-cookie")?.value ?? "missing"; + return `${auth}:${cookie}`; + }; + + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/cookie-source/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/report/cookie-source/page.tsx new file mode 100644 index 0000000000..509928b270 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/cookie-source/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Cookie forwarding source
; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/dynamic/[id]/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/report/dynamic/[id]/page.tsx new file mode 100644 index 0000000000..5a5d90b373 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/dynamic/[id]/page.tsx @@ -0,0 +1,10 @@ +import { ActionButton } from "../../../action-button"; + +export default function Page() { + const dynamicProtectedAction = async () => { + "use server"; + return "DYNAMIC_PROTECTED_ACTION_EXECUTED"; + }; + + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/loop/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/report/loop/page.tsx new file mode 100644 index 0000000000..1ca4fc8522 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/loop/page.tsx @@ -0,0 +1,10 @@ +import { ActionButton } from "../../action-button"; + +export default function Page() { + const loopAction = async () => { + "use server"; + return "LOOP_ACTION_EXECUTED"; + }; + + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/oracle/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/report/oracle/page.tsx new file mode 100644 index 0000000000..a95d9f1cb8 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/oracle/page.tsx @@ -0,0 +1,15 @@ +import { ActionButton } from "../../action-button"; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise<{ value?: string }>; +}) { + const value = (await searchParams).value ?? "public"; + const oracleAction = async () => { + "use server"; + return `ORACLE:${value}`; + }; + + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/public/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/report/public/page.tsx new file mode 100644 index 0000000000..1b87769fba --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/public/page.tsx @@ -0,0 +1,9 @@ +import { redirectTo } from "../redirect/actions"; + +export default function Page() { + return ( + + + + ); +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/redirect/actions.ts b/tests/fixtures/app-action-ownership/app/ownership/report/redirect/actions.ts new file mode 100644 index 0000000000..9b967da94c --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/redirect/actions.ts @@ -0,0 +1,7 @@ +"use server"; + +import { redirect } from "next/navigation"; + +export async function redirectTo(target: string) { + redirect(target); +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/redirect/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/report/redirect/page.tsx new file mode 100644 index 0000000000..e8517c1e0b --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/redirect/page.tsx @@ -0,0 +1,9 @@ +import { redirectTo } from "./actions"; + +export default function Page() { + return ( +
+ +
+ ); +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/shared-actions.ts b/tests/fixtures/app-action-ownership/app/ownership/report/shared-actions.ts new file mode 100644 index 0000000000..18bf048169 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/shared-actions.ts @@ -0,0 +1,9 @@ +"use server"; + +export async function publicSharedAction() { + return "PUBLIC_SHARED_ACTION_EXECUTED"; +} + +export async function adminSharedAction() { + return "ADMIN_SHARED_ACTION_EXECUTED"; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/report/shared/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/report/shared/page.tsx new file mode 100644 index 0000000000..c4c590e19f --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/report/shared/page.tsx @@ -0,0 +1,6 @@ +import { ActionButton } from "../../action-button"; +import { publicSharedAction } from "../shared-actions"; + +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/route-inline/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/route-inline/page.tsx new file mode 100644 index 0000000000..783ed67c45 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/route-inline/page.tsx @@ -0,0 +1,8 @@ +import { ActionButton } from "../action-button"; +export default function Page() { + const routeInlineAction = async () => { + "use server"; + return "ROUTE_INLINE_OK"; + }; + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/side-effect/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/side-effect/page.tsx new file mode 100644 index 0000000000..2288bf6a7c --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/side-effect/page.tsx @@ -0,0 +1,5 @@ +import "../actions/side-effect"; + +export default function Page() { + return

side effect import

; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/star/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/star/page.tsx new file mode 100644 index 0000000000..5c1b5d072b --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/star/page.tsx @@ -0,0 +1,5 @@ +import { ActionButton } from "../action-button"; +import { starAction as action } from "../barrels/star"; +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/middleware.ts b/tests/fixtures/app-action-ownership/middleware.ts new file mode 100644 index 0000000000..a0f2aed5cd --- /dev/null +++ b/tests/fixtures/app-action-ownership/middleware.ts @@ -0,0 +1,32 @@ +import { NextResponse, type NextRequest } from "next/server"; +export function middleware(request: NextRequest) { + if ( + request.nextUrl.pathname === "/ownership/admin" || + request.nextUrl.pathname === "/ownership/report/client-owner" || + request.nextUrl.pathname.startsWith("/ownership/report/admin") + ) + return new NextResponse("ADMIN_BLOCKED", { status: 401 }); + if (request.nextUrl.pathname === "/ownership/report/dynamic/[id]") + return new NextResponse("DYNAMIC_BLOCKED", { status: 401 }); + if (request.nextUrl.pathname === "/ownership/report/cookie-source") { + const headers = new Headers(request.headers); + headers.set("x-forwarded-auth", "trusted"); + const response = NextResponse.next({ request: { headers } }); + response.cookies.set("forwarded-cookie", "present"); + return response; + } + if (request.nextUrl.pathname === "/ownership/report/loop") { + return NextResponse.rewrite(new URL("/ownership/report/public", request.url)); + } + return NextResponse.next(); +} +export const config = { + matcher: [ + "/ownership/admin", + "/ownership/report/admin/:path*", + "/ownership/report/client-owner", + "/ownership/report/dynamic/:path*", + "/ownership/report/cookie-source", + "/ownership/report/loop", + ], +}; diff --git a/tests/fixtures/app-action-ownership/package.json b/tests/fixtures/app-action-ownership/package.json new file mode 100644 index 0000000000..59fa9b5a13 --- /dev/null +++ b/tests/fixtures/app-action-ownership/package.json @@ -0,0 +1,5 @@ +{ + "name": "app-action-ownership-fixture", + "private": true, + "type": "module" +} From efcbf32f2bb8168ddb44c0134aca53283577b570 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 6 Jul 2026 21:56:41 +0100 Subject: [PATCH 15/17] fix(build): resolve action runtimes from generated entries --- packages/vinext/package.json | 8 -------- packages/vinext/src/entries/app-rsc-entry.ts | 12 ++++++++++-- tests/action-runtime-security.test.ts | 5 +---- tests/entry-templates.test.ts | 3 +++ 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/vinext/package.json b/packages/vinext/package.json index d089a2aeaa..5998b3fa6c 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -121,14 +121,6 @@ "types": "./dist/server/app-rsc-handler.d.ts", "import": "./dist/server/app-rsc-handler.js" }, - "./internal/server/app-action-forwarding": { - "types": "./dist/server/app-action-forwarding.d.ts", - "import": "./dist/server/app-action-forwarding.js" - }, - "./internal/server/app-server-action-execution": { - "types": "./dist/server/app-server-action-execution.d.ts", - "import": "./dist/server/app-server-action-execution.js" - }, "./config/config-matchers": { "types": "./dist/config/config-matchers.d.ts", "import": "./dist/config/config-matchers.js" diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 2b5ef755b2..ff4f69297d 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -45,6 +45,14 @@ const appRouteHandlerResponsePath = resolveEntryPath( "../server/app-route-handler-response.js", import.meta.url, ); +const appServerActionExecutionPath = resolveEntryPath( + "../server/app-server-action-execution.js", + import.meta.url, +); +const appActionForwardingPath = resolveEntryPath( + "../server/app-action-forwarding.js", + import.meta.url, +); const appMiddlewarePath = resolveEntryPath("../server/app-middleware.js", import.meta.url); const metadataRouteResponsePath = resolveEntryPath( "../server/metadata-route-response.js", @@ -333,8 +341,8 @@ ${ const __loadAppRouteHandlerDispatch = () => import(${JSON.stringify(appRouteHandlerDispatchPath)}); ${ hasServerActions - ? `const __loadAppServerActionExecution = () => import("vinext/internal/server/app-server-action-execution"); -const __loadAppActionForwarding = () => import("vinext/internal/server/app-action-forwarding");` + ? `const __loadAppServerActionExecution = () => import(${JSON.stringify(appServerActionExecutionPath)}); +const __loadAppActionForwarding = () => import(${JSON.stringify(appActionForwardingPath)});` : "" } ${ diff --git a/tests/action-runtime-security.test.ts b/tests/action-runtime-security.test.ts index d0b57e0f63..7d1d275ad4 100644 --- a/tests/action-runtime-security.test.ts +++ b/tests/action-runtime-security.test.ts @@ -75,10 +75,7 @@ async function encryptBoundArgsFixture(serializedFlightValue: string): Promise { beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-action-runtime-security-")); - await fs.symlink( - path.resolve("tests/fixtures/app-basic/node_modules"), - path.join(fixtureRoot, "node_modules"), - ); + await fs.symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules")); await write("package.json", '{"type":"module"}\n'); await write( "middleware.ts", diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index bf05bcd017..3986a16aaf 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -965,6 +965,9 @@ describe("App Router entry templates", () => { expect(code).toContain('const __loadAppRouteHandlerDispatch = () => import("'); expect(code).toContain('const __loadAppServerActionExecution = () => import("'); + expect(code).toContain("/server/app-server-action-execution.js"); + expect(code).toContain("/server/app-action-forwarding.js"); + expect(code).not.toContain('import("vinext/internal/server/'); expect(code).toContain("await __loadAppRouteHandlerDispatch()"); expect(code).toContain("await __loadAppServerActionExecution()"); expect(code).not.toMatch(/import \{\s*dispatchAppRouteHandler as __dispatchAppRouteHandler,/); From 2c444f7cf7d2dd0eaa7c9ac149a668098547cf37 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 6 Jul 2026 22:16:08 +0100 Subject: [PATCH 16/17] test(actions): cover nested client form actions --- .../action-owner-production.browser.spec.ts | 3 ++- .../app/ownership/actions/client-form.ts | 5 +++++ .../app/ownership/barrels/client-form.ts | 1 + .../client-form/components/client-form.tsx | 18 ++++++++++++++++++ .../client-form/components/form-section.tsx | 9 +++++++++ .../client-form/components/page-content.tsx | 9 +++++++++ .../app/ownership/client-form/page.tsx | 5 +++++ 7 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/app-action-ownership/app/ownership/actions/client-form.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/barrels/client-form.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/client-form/components/client-form.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/client-form/components/form-section.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/client-form/components/page-content.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/client-form/page.tsx diff --git a/tests/e2e/app-router/action-owner-production.browser.spec.ts b/tests/e2e/app-router/action-owner-production.browser.spec.ts index 4e8f737282..4e10df1453 100644 --- a/tests/e2e/app-router/action-owner-production.browser.spec.ts +++ b/tests/e2e/app-router/action-owner-production.browser.spec.ts @@ -200,6 +200,7 @@ test.describe("production server action ownership", () => { ["default", "default", "DEFAULT_OK"], ["cycle", "cycle", "CYCLE_OK"], ["client", "client", "CLIENT_OK"], + ["client-form", "client-form", "CLIENT_FORM_OK:nested"], ["imported-inline", "imported-inline", "IMPORTED_INLINE_OK"], ["route-inline", "route-inline", "ROUTE_INLINE_OK"], ["layout-owner", "layout-owner", "LAYOUT_OK"], @@ -212,7 +213,7 @@ test.describe("production server action ownership", () => { ["report/shared", "public-shared", "PUBLIC_SHARED_ACTION_EXECUTED"], ] as const; - const expectedAssertions = 20; + const expectedAssertions = 21; let completedAssertions = 0; for (const [route, id, expected] of cases) { await expect(await executeAction(page, route, id)).toHaveText(expected); diff --git a/tests/fixtures/app-action-ownership/app/ownership/actions/client-form.ts b/tests/fixtures/app-action-ownership/app/ownership/actions/client-form.ts new file mode 100644 index 0000000000..4afcf6abbe --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/actions/client-form.ts @@ -0,0 +1,5 @@ +"use server"; + +export async function submitClientForm(_previousState: string, formData: FormData) { + return `CLIENT_FORM_OK:${formData.get("message")}`; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/barrels/client-form.ts b/tests/fixtures/app-action-ownership/app/ownership/barrels/client-form.ts new file mode 100644 index 0000000000..2714c34cc0 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/barrels/client-form.ts @@ -0,0 +1 @@ +export { submitClientForm } from "../actions/client-form"; diff --git a/tests/fixtures/app-action-ownership/app/ownership/client-form/components/client-form.tsx b/tests/fixtures/app-action-ownership/app/ownership/client-form/components/client-form.tsx new file mode 100644 index 0000000000..6da4519e03 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/client-form/components/client-form.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useActionState } from "react"; +import { submitClientForm } from "../../barrels/client-form"; + +export function ClientForm() { + const [result, formAction, pending] = useActionState(submitClientForm, ""); + + return ( +
+ + + {result} +
+ ); +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/client-form/components/form-section.tsx b/tests/fixtures/app-action-ownership/app/ownership/client-form/components/form-section.tsx new file mode 100644 index 0000000000..daecc188f1 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/client-form/components/form-section.tsx @@ -0,0 +1,9 @@ +import { ClientForm } from "./client-form"; + +export function FormSection() { + return ( +
+ +
+ ); +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/client-form/components/page-content.tsx b/tests/fixtures/app-action-ownership/app/ownership/client-form/components/page-content.tsx new file mode 100644 index 0000000000..6fffb01910 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/client-form/components/page-content.tsx @@ -0,0 +1,9 @@ +import { FormSection } from "./form-section"; + +export function PageContent() { + return ( +
+ +
+ ); +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/client-form/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/client-form/page.tsx new file mode 100644 index 0000000000..7a98c46c6a --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/client-form/page.tsx @@ -0,0 +1,5 @@ +import { PageContent } from "./components/page-content"; + +export default function Page() { + return ; +} From d596a09aaa1b4ae2b0d87b9f729ba28b105e68e4 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 6 Jul 2026 22:25:19 +0100 Subject: [PATCH 17/17] test(actions): cover same-named non-action exports --- .../action-owner-production.browser.spec.ts | 21 ++++++++++++++++++- .../ownership/same-name/action-owner/page.tsx | 11 ++++++++++ .../app/ownership/same-name/action.ts | 5 +++++ .../same-name/components/action-form.tsx | 18 ++++++++++++++++ .../same-name/components/helper-result.tsx | 5 +++++ .../ownership/same-name/helper-only/page.tsx | 5 +++++ .../app/ownership/same-name/helper.ts | 3 +++ .../app-action-ownership/middleware.ts | 7 +++++++ 8 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/app-action-ownership/app/ownership/same-name/action-owner/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/same-name/action.ts create mode 100644 tests/fixtures/app-action-ownership/app/ownership/same-name/components/action-form.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/same-name/components/helper-result.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/same-name/helper-only/page.tsx create mode 100644 tests/fixtures/app-action-ownership/app/ownership/same-name/helper.ts diff --git a/tests/e2e/app-router/action-owner-production.browser.spec.ts b/tests/e2e/app-router/action-owner-production.browser.spec.ts index 4e10df1453..b0b4ba74c7 100644 --- a/tests/e2e/app-router/action-owner-production.browser.spec.ts +++ b/tests/e2e/app-router/action-owner-production.browser.spec.ts @@ -23,6 +23,7 @@ type ProductionApp = { protected: string; protectedClient: string; redirectTo: string; + sameNameSubmit: string; sideEffectSecret: string; }; deleteAccountIds: string[]; @@ -105,6 +106,7 @@ async function buildAndServeFixture(): Promise { protected: findActionId(builtSource, "protectedAction"), protectedClient: findActionId(builtSource, "protectedClientAction"), redirectTo: findActionId(builtSource, "redirectTo"), + sameNameSubmit: findActionId(builtSource, "submit"), sideEffectSecret: findActionId(builtSource, "sideEffectSecretAction"), }, baseUrl: `http://127.0.0.1:${server.port}`, @@ -211,9 +213,10 @@ test.describe("production server action ownership", () => { ["mixed", "mixed-dynamic", "MIXED_DYNAMIC_OK"], ["report/dynamic/example", "dynamic-protected", "DYNAMIC_PROTECTED_ACTION_EXECUTED"], ["report/shared", "public-shared", "PUBLIC_SHARED_ACTION_EXECUTED"], + ["same-name/action-owner", "same-name-action", "SAME_NAME_ACTION_OK:distinct"], ] as const; - const expectedAssertions = 21; + const expectedAssertions = 22; let completedAssertions = 0; for (const [route, id, expected] of cases) { await expect(await executeAction(page, route, id)).toHaveText(expected); @@ -341,6 +344,22 @@ test.describe("production server action ownership", () => { expect(response.body).not.toContain("ADMIN_SHARED_ACTION_EXECUTED"); }); + test("keeps same-named non-action exports isolated from action ownership", async ({ page }) => { + await page.goto(`${app.baseUrl}/ownership/same-name/action-owner`); + await expect(page.getByTestId("same-name-helper-result")).toHaveText("SAME_NAME_HELPER_OK"); + + await page.goto(`${app.baseUrl}/ownership/same-name/helper-only`); + await expect(page.getByTestId("same-name-helper-result")).toHaveText("SAME_NAME_HELPER_OK"); + const response = await postAction( + page, + "/ownership/same-name/helper-only", + app.actionIds.sameNameSubmit, + ); + expect(response.status).toBe(200); + expect(response.body).toBe("{}"); + expect(response.body).not.toContain("SAME_NAME_ACTION_OK"); + }); + test("forwards dynamic owners using their route pattern", async ({ page }) => { const response = await postAction( page, diff --git a/tests/fixtures/app-action-ownership/app/ownership/same-name/action-owner/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/same-name/action-owner/page.tsx new file mode 100644 index 0000000000..3621a66d93 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/same-name/action-owner/page.tsx @@ -0,0 +1,11 @@ +import { ActionForm } from "../components/action-form"; +import { HelperResult } from "../components/helper-result"; + +export default function Page() { + return ( +
+ + +
+ ); +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/same-name/action.ts b/tests/fixtures/app-action-ownership/app/ownership/same-name/action.ts new file mode 100644 index 0000000000..947f094d5d --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/same-name/action.ts @@ -0,0 +1,5 @@ +"use server"; + +export async function submit(_previousState: string, formData: FormData) { + return `SAME_NAME_ACTION_OK:${formData.get("message")}`; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/same-name/components/action-form.tsx b/tests/fixtures/app-action-ownership/app/ownership/same-name/components/action-form.tsx new file mode 100644 index 0000000000..a93de8213e --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/same-name/components/action-form.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useActionState } from "react"; +import { submit } from "../action"; + +export function ActionForm() { + const [result, formAction, pending] = useActionState(submit, ""); + + return ( +
+ + + {result} +
+ ); +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/same-name/components/helper-result.tsx b/tests/fixtures/app-action-ownership/app/ownership/same-name/components/helper-result.tsx new file mode 100644 index 0000000000..cd57422307 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/same-name/components/helper-result.tsx @@ -0,0 +1,5 @@ +import { submit } from "../helper"; + +export function HelperResult() { + return {submit()}; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/same-name/helper-only/page.tsx b/tests/fixtures/app-action-ownership/app/ownership/same-name/helper-only/page.tsx new file mode 100644 index 0000000000..51dbec0b32 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/same-name/helper-only/page.tsx @@ -0,0 +1,5 @@ +import { HelperResult } from "../components/helper-result"; + +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-action-ownership/app/ownership/same-name/helper.ts b/tests/fixtures/app-action-ownership/app/ownership/same-name/helper.ts new file mode 100644 index 0000000000..5440359862 --- /dev/null +++ b/tests/fixtures/app-action-ownership/app/ownership/same-name/helper.ts @@ -0,0 +1,3 @@ +export function submit() { + return "SAME_NAME_HELPER_OK"; +} diff --git a/tests/fixtures/app-action-ownership/middleware.ts b/tests/fixtures/app-action-ownership/middleware.ts index a0f2aed5cd..c3f3f9e845 100644 --- a/tests/fixtures/app-action-ownership/middleware.ts +++ b/tests/fixtures/app-action-ownership/middleware.ts @@ -18,6 +18,12 @@ export function middleware(request: NextRequest) { if (request.nextUrl.pathname === "/ownership/report/loop") { return NextResponse.rewrite(new URL("/ownership/report/public", request.url)); } + if ( + request.nextUrl.pathname === "/ownership/same-name/action-owner" && + request.headers.get("x-action-forwarded") + ) { + return new NextResponse("FORWARDED_SAME_NAME_ACTION_BLOCKED", { status: 401 }); + } return NextResponse.next(); } export const config = { @@ -28,5 +34,6 @@ export const config = { "/ownership/report/dynamic/:path*", "/ownership/report/cookie-source", "/ownership/report/loop", + "/ownership/same-name/action-owner", ], };