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 new file mode 100644 index 0000000000..71ad07b589 --- /dev/null +++ b/packages/vinext/src/build/action-owner-manifest.ts @@ -0,0 +1,472 @@ +import type { AppRoute } from "../routing/app-route-graph.js"; +import { safeJsonStringify } from "../server/html.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; + inlineExportNames?: readonly string[]; + referenceKey: string; +}; + +type ServerReferenceModuleEdge = { + exportNames: readonly string[] | "*"; + exportedName?: string; + sourceId: string; +}; + +type ServerReferenceModuleEdges = { + imports: readonly ServerReferenceModuleEdge[]; + 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[]; + importedIds: readonly string[]; + } | null; +}; + +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 addOwner(manifest: Record, key: string, pattern: string): void { + const owners = (manifest[key] ??= []); + 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>; + 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[]; + 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); + 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 serverReferenceConsumers[reachableId] ?? []) { + consumedActions.add(actionId); + } + } + + 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) || + (isReachableModule && inlineExportNames.has(exportName)) + ) { + 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 ${safeJsonStringify(manifest)}; }`, + ); +} 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 bfcabea919..ff4f69297d 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"; @@ -44,15 +45,19 @@ 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", import.meta.url, ); -const appServerActionExecutionPath = resolveEntryPath( - "../server/app-server-action-execution.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 +123,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 | null; redirects?: NextRedirect[]; rewrites?: { beforeFiles: NextRewrite[]; @@ -232,6 +238,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 ?? []; @@ -303,6 +310,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"; ${ @@ -333,7 +341,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)});` : "" } ${ @@ -423,7 +432,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 +714,8 @@ ${rootParamNameEntries.join("\n")} }; __setPagesClientAssets(__pagesClientAssets); -export default createAppRscHandler({ +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, ensureRouteLoaded: __ensureRouteLoaded, @@ -1050,12 +1059,40 @@ export default createAppRscHandler({ request, searchParams, }) { + const { forwardServerActionIfNeeded: __forwardServerActionIfNeeded } = + await __loadAppActionForwarding(); + const __currentActionMatch = matchRoute(cleanPathname); + const __forwardResponse = await __forwardServerActionIfNeeded({ + actionId, + actionOwners: __VINEXT_ACTION_OWNERS(), + allowedOrigins: __allowedOrigins, + basePath: __basePath, + clearRequestContext: __clearRequestContext, + currentRoutePattern: __currentActionMatch?.route.pattern ?? null, + dispatch(__forwardRequest) { + 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, + }); + if (__forwardResponse) return __forwardResponse; const { handleServerActionRscRequest: __handleServerActionRscRequest, 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)) @@ -1113,6 +1150,9 @@ export default createAppRscHandler({ }, createTemporaryReferenceSet, decodeReply, + dispatchRedirectRequest(redirectRequest) { + return __appRscHandler(redirectRequest, undefined); + }, draftModeSecret: __draftModeSecret, findIntercept(pathnameToMatch) { return findIntercept(pathnameToMatch, interceptionContext); @@ -1237,6 +1277,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 967074dfe4..c6e58b40de 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -69,6 +69,11 @@ import { } from "./build/route-classification-manifest.js"; import { extractMiddlewareMatcherConfig, hasExportedName } from "./build/report.js"; import { planRouteClassificationInjection } from "./build/route-classification-injector.js"; +import { + buildActionOwnerManifest, + createActionOwnerScanObserver, + injectActionOwnerManifest, +} from "./build/action-owner-manifest.js"; import { normalizePathnameForRouteMatchStrict } from "./routing/utils.js"; import { createRscCompatibilityId, @@ -1316,6 +1321,9 @@ 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; + 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 @@ -1451,6 +1459,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( @@ -1460,9 +1471,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({ @@ -3183,6 +3192,19 @@ 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) { + manager.scanBuildObservers.add(actionOwnerScan.observe); + hasActionOwnerScanObserver = true; + } + } + } const cacheDirPrefix = getCacheDirPrefix(config.cacheDir); typeofWindowIdFilter.exclude = new RegExp(`^${escapeRegExp(cacheDirPrefix)}`); if (isServeCommand && hasCloudflarePlugin && hasPagesDir && !hasAppDir) { @@ -3502,6 +3524,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 +3553,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 +3661,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 +3706,60 @@ 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 ?? {}, + ); + 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, + routes: rscActionOwnerRoutes, + serverReferenceModuleEdges: actionOwnerScan.moduleEdges, + 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/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/packages/vinext/src/server/app-action-forwarding.ts b/packages/vinext/src/server/app-action-forwarding.ts new file mode 100644 index 0000000000..0e1fbb05d9 --- /dev/null +++ b/packages/vinext/src/server/app-action-forwarding.ts @@ -0,0 +1,181 @@ +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", + "keepalive", + "keep-alive", + "content-encoding", + "transfer-encoding", + "connection", + "expect", + "content-length", + "set-cookie", +]); + +type ActionOwnerManifest = Readonly>; + +type ForwardServerActionOptions = { + actionId: string; + actionOwners: ActionOwnerManifest | null; + allowedOrigins: string[]; + basePath: string; + clearRequestContext: () => void; + currentRoutePattern: string | null; + dispatch: (request: Request) => Promise; + middlewareContext: AppMiddlewareContext; + 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 (!deletedCookieNames.has(cookie.name)) 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 csrfResponse = validateCsrfOrigin(options.request, options.allowedOrigins); + if (csrfResponse) return csrfResponse; + + const ownerPatterns = actionOwnerPatterns(options.actionOwners, options.actionId); + if ( + ownerPatterns && + options.currentRoutePattern && + ownerPatterns.includes(options.currentRoutePattern) + ) { + return null; + } + + const ownerPath = ownerPatterns?.[0]; + 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 = + 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, + 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-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index da5477302f..e7efb0dff5 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -961,6 +961,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 c4270b8062..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,6 +1314,9 @@ 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 (isForwardedActionContext(ctx)) { + 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 da1fee7d3e..becbdf6bd8 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"; @@ -255,6 +256,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 @@ -441,11 +443,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"), @@ -456,8 +458,15 @@ function createActionRedirectRenderRequest(options: { } else { 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", }); @@ -470,6 +479,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; } @@ -1285,7 +1314,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] : []), @@ -1293,6 +1322,48 @@ export async function handleServerActionRscRequest< request: options.request, url: redirectTarget, }); + if (options.dispatchRedirectRequest) { + 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/patches/@vitejs__plugin-rsc@0.5.27.patch b/patches/@vitejs__plugin-rsc@0.5.27.patch new file mode 100644 index 0000000000..0327430ea9 --- /dev/null +++ b/patches/@vitejs__plugin-rsc@0.5.27.patch @@ -0,0 +1,113 @@ +diff --git a/dist/plugin-BK29Va7z.js b/dist/plugin-BK29Va7z.js +index 548c63135..3257d79d7 100644 +--- a/dist/plugin-BK29Va7z.js ++++ b/dist/plugin-BK29Va7z.js +@@ -411,21 +411,37 @@ function scanBuildStripPlugin({ manager }) { + name: "rsc:scan-strip", + apply: "build", + enforce: "post", ++ buildStart() { ++ if (manager.isScanBuild && manager.scanBuildObservers.size > 0) { ++ manager.scanBuildModules.clear(); ++ for (const observer of manager.scanBuildObservers) observer({ type: "reset", environmentName: this.environment.name }); ++ } ++ }, + 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, 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 = manager.scanBuildModules.get(info.id); ++ if (!lexed) return; ++ manager.scanBuildModules.delete(info.id); ++ for (const observer of manager.scanBuildObservers) observer({ type: "module", environmentName: this.environment.name, info, ...lexed }); + } + }; + } + 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); + 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() { +@@ -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..498a7509c 100644 +--- a/dist/plugin-Bm-Fb4g7.d.ts ++++ b/dist/plugin-Bm-Fb4g7.d.ts +@@ -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"; + + //#region src/plugins/import-environment.d.ts + type EnvironmentImportMeta = { +@@ -24,7 +25,19 @@ type ServerRerferenceMeta = { + importId: string; + referenceKey: string; + exportNames: string[]; ++ inlineExportNames?: string[]; + }; ++type ScanBuildObserver = (event: { ++ type: "reset"; ++ environmentName: string; ++} | { ++ type: "module"; ++ environmentName: string; ++ code: string; ++ imports: readonly ImportSpecifier[]; ++ exports: readonly ExportSpecifier[]; ++ info: ModuleInfo; ++}) => void; + /** + * @experimental + */ +@@ -37,6 +50,7 @@ declare class RscPluginManager { + clientReferenceMetaMap: Record; + clientReferenceGroups: Record; + serverReferenceMetaMap: Record; ++ scanBuildObservers: Set; + serverResourcesMetaMap: Record; +@@ -182,4 +196,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 bc00f52d99..8dc8902171 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': 193a6ca50a7b8cf9fd463438938414997d554aa599f26653262912239d5e6bfe + 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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2 version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(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.2(@types/node@25.9.2)(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1044,7 +1047,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1055,11 +1058,13 @@ importers: specifier: 'catalog:' version: 0.2.2(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.9(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@5.9.3))(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0) + tests/fixtures/app-action-ownership: {} + tests/fixtures/app-basic: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1093,7 +1098,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1118,7 +1123,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1145,7 +1150,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1176,7 +1181,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1213,7 +1218,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1279,7 +1284,7 @@ importers: version: 1.9.1 '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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) @@ -1310,7 +1315,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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) @@ -1338,7 +1343,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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) @@ -1366,7 +1371,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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) @@ -1394,7 +1399,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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) @@ -1431,7 +1436,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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1487,7 +1492,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1512,7 +1517,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1537,7 +1542,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1624,7 +1629,7 @@ importers: version: 1.31.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1655,7 +1660,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1702,7 +1707,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -1723,7 +1728,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 @@ -10617,7 +10622,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(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 46cb091f59..4f875a41ed 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 new file mode 100644 index 0000000000..913218c367 --- /dev/null +++ b/tests/action-owner-manifest.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, it } from "vite-plus/test"; +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"; + +function route(pattern: string, pagePath: string) { + return { + errorPath: null, + errorPaths: [], + forbiddenPath: null, + forbiddenPaths: [], + layoutErrorPaths: [], + layouts: [], + loadingPath: null, + notFoundPath: null, + notFoundPaths: [], + pagePath, + parallelSlots: [], + pattern, + siblingIntercepts: [], + templates: [], + unauthorizedPath: null, + unauthorizedPaths: [], + }; +} + +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 = { + 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__"; }`, + { "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("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"], + }; + + expect( + buildActionOwnerManifest({ + moduleInfo: { + getModuleInfo(id) { + return { importedIds: moduleGraph[id] ?? [] }; + }, + }, + routes: [route("/", "/app/page.tsx"), route("/admin", "/app/admin/page.tsx")], + 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: [ + { + exportNames: ["publicOnly", "adminOnly", "unrelated"], + importId: "/app/actions.ts", + referenceKey: "action-key", + }, + ], + }), + ).toEqual({ + "action-key#adminOnly": ["/admin"], + "action-key#publicOnly": ["/"], + }); + }); + + it("uses final plugin metadata only for actions defined in route components", () => { + expect( + buildActionOwnerManifest({ + moduleInfo: { + getModuleInfo() { + return null; + }, + }, + routes: [route("/", "/app/page.tsx")], + serverReferenceModuleEdges: {}, + serverReferences: [ + { + exportNames: ["$$hoist_0_inline"], + importId: "/app/page.tsx", + referenceKey: "inline-key", + }, + { + exportNames: ["importedAction"], + importId: "/app/actions.ts", + referenceKey: "imported-key", + }, + ], + }), + ).toEqual({ + "inline-key": ["/"], + "inline-key#$$hoist_0_inline": ["/"], + }); + }); + + it("accepts exact plugin references imported through client component boundaries", () => { + expect( + buildActionOwnerManifest({ + moduleInfo: { + getModuleInfo(id) { + return id === "/app/page.tsx" ? { importedIds: [] } : null; + }, + }, + routes: [route("/", "/app/page.tsx")], + serverReferenceModuleEdges: { + "/app/page.tsx": { + imports: [{ exportNames: ["adminOnly"], sourceId: "/app/admin-actions.ts" }], + reexports: [], + }, + }, + serverReferences: [ + { + exportNames: ["adminOnly"], + importId: "/app/admin-actions.ts", + referenceKey: "admin-key", + }, + ], + }), + ).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({ + moduleInfo: { + getModuleInfo(id) { + return id === "/app/page.tsx" + ? { dynamicImportedIds: ["/app/actions.ts"], importedIds: [] } + : { importedIds: [] }; + }, + }, + routes: [route("/", "/app/page.tsx")], + serverReferenceModuleEdges: { + "/app/page.tsx": { + imports: [{ exportNames: "*", sourceId: "/app/actions.ts" }], + reexports: [], + }, + }, + serverReferences: [ + { + exportNames: ["submit"], + importId: "/app/actions.ts", + referenceKey: "action-key", + }, + ], + }), + ).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], + ], + siblingIntercepts: [ + { + convention: "(.)", + layoutPaths: ["/app/(.)modal/layout.tsx"], + pagePath: "/app/(.)modal/page.tsx", + params: [], + sourceMatchPattern: "/", + targetPattern: "/modal", + }, + ], + templates: ["/app/template.tsx"], + unauthorizedPath: "/app/unauthorized.tsx", + unauthorizedPaths: ["/app/nested-unauthorized.tsx"], + }); + + 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..7d1d275ad4 --- /dev/null +++ b/tests/action-runtime-security.test.ts @@ -0,0 +1,409 @@ +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 === "/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*", "/client-owner", "/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"; +import { ImportedActionForm } from "./imported-action-form"; +export default function Page() { + return <>
; +} +`, + ); + await write( + "app/imported-action-form.tsx", + `export function ImportedActionForm() { + const importedInlineAction = async () => { "use server"; return "IMPORTED_INLINE_ACTION_EXECUTED"; }; + return
; +} +`, + ); + await write( + "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( + "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 = { + clientOwnedAction: findActionId(builtSource, "clientOwnedAction"), + 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("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("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"); + 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..02658ba7f8 --- /dev/null +++ b/tests/app-action-forwarding.test.ts @@ -0,0 +1,247 @@ +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"] }, + allowedOrigins: [], + 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("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 forwarded = await forwardServerActionIfNeeded( + options({ + clearRequestContext, + request: request({ [ACTION_FORWARDED_HEADER]: "1" }), + }), + ); + + expect(unknown?.status).toBe(404); + expect(await forwarded?.text()).toBe("{}"); + expect(clearRequestContext).toHaveBeenCalledTimes(2); + expect(warning).toHaveBeenCalledTimes(1); + 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-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 c0122826c8..302091e9ed 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 dbf7d44cbd..07b0936400 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 cb969a2fa3..df36097765 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)( @@ -1695,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/action-owner-production.browser.spec.ts b/tests/e2e/app-router/action-owner-production.browser.spec.ts new file mode 100644 index 0000000000..b0b4ba74c7 --- /dev/null +++ b/tests/e2e/app-router/action-owner-production.browser.spec.ts @@ -0,0 +1,426 @@ +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; + sameNameSubmit: 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"), + sameNameSubmit: findActionId(builtSource, "submit"), + 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"], + ["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"], + ["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"], + ["same-name/action-owner", "same-name-action", "SAME_NAME_ACTION_OK:distinct"], + ] as const; + + const expectedAssertions = 22; + 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("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, + "/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/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/entry-templates.test.ts b/tests/entry-templates.test.ts index 3832411954..3986a16aaf 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("); }); @@ -964,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,/); @@ -972,6 +976,29 @@ 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("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, @@ -986,6 +1013,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", () => { 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-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/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/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/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-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 ; +} 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/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/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..c3f3f9e845 --- /dev/null +++ b/tests/fixtures/app-action-ownership/middleware.ts @@ -0,0 +1,39 @@ +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)); + } + 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 = { + matcher: [ + "/ownership/admin", + "/ownership/report/admin/:path*", + "/ownership/report/client-owner", + "/ownership/report/dynamic/:path*", + "/ownership/report/cookie-source", + "/ownership/report/loop", + "/ownership/same-name/action-owner", + ], +}; 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" +} 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

+
+ +
); 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", () => {