Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jobs:
name: Run zizmor
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout repository
Expand Down
8 changes: 8 additions & 0 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,13 @@ function __resolveRouteFetchCacheMode(route) {
});
}

function __resolveRouteRuntime(route) {
return __resolveAppPageSegmentConfig({
layouts: route.layouts,
page: route.page,
}).runtime ?? null;
}

${imports.join("\n")}

${
Expand Down Expand Up @@ -796,6 +803,7 @@ export default __createAppRscHandler({
readFormDataWithLimit: __readFormDataWithLimit,
renderToReadableStream,
reportRequestError: _reportRequestError,
resolveRouteRuntime: __resolveRouteRuntime,
request,
sanitizeErrorForClient(error) {
return __sanitizeErrorForClient(error);
Expand Down
61 changes: 51 additions & 10 deletions packages/vinext/src/entries/app-rsc-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,34 @@ type BuildAppRscManifestCodeOptions = {
globalNotFoundPath?: string | null;
};

function findRootBoundaryRoute(routes: readonly AppRoute[]): AppRoute | undefined {
return (
routes.find((route) => route.pattern === "/") ??
routes.find((route) => route.layouts.length > 0 && route.layoutTreePositions.length > 0)
);
}

function rootRouteLayoutPaths(route: AppRoute | undefined): readonly string[] {
if (!route) return [];
if (route.pattern === "/") return route.layouts;

const rootPosition = route.layoutTreePositions[0];
return route.layouts.filter((_, index) => route.layoutTreePositions[index] === rootPosition);
}

function rootRouteBoundaryPath(
route: AppRoute | undefined,
boundaryPaths: readonly (string | null)[] | undefined,
fallbackPath: string | null | undefined,
): string | null {
if (!route) return null;
if (route.pattern === "/") return fallbackPath ?? null;

const rootPosition = route.layoutTreePositions[0];
const rootLayoutIndex = route.layoutTreePositions.indexOf(rootPosition);
return boundaryPaths?.[rootLayoutIndex] ?? fallbackPath ?? null;
}

type ImportAllocator = {
getImportVar(filePath: string): string;
importMap: ReadonlyMap<string, string>;
Expand Down Expand Up @@ -305,17 +333,30 @@ export function buildAppRscManifestCode(
registerRouteModules(options.routes, imports);
const routeEntries = buildRouteEntries(options.routes, imports);

const rootRoute = options.routes.find((r) => r.pattern === "/");
const rootNotFoundVar = rootRoute?.notFoundPath
? imports.getImportVar(rootRoute.notFoundPath)
: null;
const rootForbiddenVar = rootRoute?.forbiddenPath
? imports.getImportVar(rootRoute.forbiddenPath)
: null;
const rootUnauthorizedVar = rootRoute?.unauthorizedPath
? imports.getImportVar(rootRoute.unauthorizedPath)
const rootRoute = findRootBoundaryRoute(options.routes);
const rootNotFoundPath = rootRouteBoundaryPath(
rootRoute,
rootRoute?.notFoundPaths,
rootRoute?.notFoundPath,
);
const rootForbiddenPath = rootRouteBoundaryPath(
rootRoute,
rootRoute?.forbiddenPaths,
rootRoute?.forbiddenPath,
);
const rootUnauthorizedPath = rootRouteBoundaryPath(
rootRoute,
rootRoute?.unauthorizedPaths,
rootRoute?.unauthorizedPath,
);
const rootNotFoundVar = rootNotFoundPath ? imports.getImportVar(rootNotFoundPath) : null;
const rootForbiddenVar = rootForbiddenPath ? imports.getImportVar(rootForbiddenPath) : null;
const rootUnauthorizedVar = rootUnauthorizedPath
? imports.getImportVar(rootUnauthorizedPath)
: null;
const rootLayoutVars = rootRoute ? rootRoute.layouts.map((l) => imports.getImportVar(l)) : [];
const rootLayoutVars = rootRouteLayoutPaths(rootRoute).map((layoutPath) =>
imports.getImportVar(layoutPath),
);
const globalErrorVar = options.globalErrorPath
? imports.getImportVar(options.globalErrorPath)
: null;
Expand Down
63 changes: 63 additions & 0 deletions packages/vinext/src/server/app-browser-action-result.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { ACTION_REVALIDATED_HEADER } from "./headers.js";
import {
isRscCompatibilityIdCompatible,
VINEXT_RSC_COMPATIBILITY_ID_HEADER,
VINEXT_RSC_CONTENT_TYPE,
} from "./app-rsc-cache-busting.js";

export type AppBrowserServerActionResult<TRoot> = {
root?: TRoot;
Expand Down Expand Up @@ -70,6 +75,64 @@ export function parseServerActionRevalidationHeader(
}
}

type DigestError = Error & { digest: string };

function createServerActionHttpFallbackError(status: number): Error | null {
if (status < 400 || status > 599) return null;

const error = new Error(status === 404 ? "NEXT_NOT_FOUND" : `NEXT_HTTP_ERROR_FALLBACK;${status}`);
(error as DigestError).digest =
status === 404 ? "NEXT_HTTP_ERROR_FALLBACK;404" : `NEXT_HTTP_ERROR_FALLBACK;${status}`;
return error;
}

export function normalizeServerActionThrownValue(data: unknown, responseStatus: number): unknown {
return createServerActionHttpFallbackError(responseStatus) ?? data;
}

export async function readInvalidServerActionResponseError(
response: Pick<Response, "headers" | "status" | "text">,
hasRedirectLocation: boolean,
): Promise<Error | null> {
const contentType = response.headers.get("content-type") ?? "";
const isRscResponse = contentType.startsWith(VINEXT_RSC_CONTENT_TYPE);
if (isRscResponse || hasRedirectLocation) return null;

// Parity with Next.js' server-action reducer: non-RSC action responses are
// surfaced to the action caller, using a plain text 4xx/5xx body when one is
// available and otherwise falling back to a stable generic message.
const message =
response.status >= 400 && contentType.toLowerCase().startsWith("text/plain")
? await response.text()
: "An unexpected response was received from the server.";

return new Error(message || "An unexpected response was received from the server.");
}

export function shouldCheckRscCompatibilityForServerActionResponse(
response: Pick<Response, "headers">,
): boolean {
return (response.headers.get("content-type") ?? "").startsWith(VINEXT_RSC_CONTENT_TYPE);
}

export function resolveServerActionRedirectCompatibilityHardNavigationTarget(options: {
actionRedirectHref: string | null;
clientCompatibilityId: string | null | undefined;
response: Pick<Response, "headers">;
}): string | null {
if (!options.actionRedirectHref) return null;
if (!shouldCheckRscCompatibilityForServerActionResponse(options.response)) return null;
if (
isRscCompatibilityIdCompatible(
options.response.headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER),
options.clientCompatibilityId,
)
) {
return null;
}
return options.actionRedirectHref;
}

export function shouldScheduleRefreshForDiscardedServerAction(
revalidation: ServerActionRevalidationKind,
): boolean {
Expand Down
Loading
Loading