408 lines
19 KiB
JavaScript
408 lines
19 KiB
JavaScript
import { ACTION, RSC, RSC_CONTENT_TYPE_HEADER } from "../../client/components/app-router-headers";
|
|
import { isNotFoundError } from "../../client/components/not-found";
|
|
import { getURLFromRedirectError, isRedirectError } from "../../client/components/redirect";
|
|
import RenderResult from "../render-result";
|
|
import { FlightRenderResult } from "./flight-render-result";
|
|
import { filterReqHeaders, actionsForbiddenHeaders } from "../lib/server-ipc/utils";
|
|
import { appendMutableCookies, getModifiedCookieValues } from "../web/spec-extension/adapters/request-cookies";
|
|
import { NEXT_CACHE_REVALIDATED_TAGS_HEADER, NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER } from "../../lib/constants";
|
|
function nodeToWebReadableStream(nodeReadable) {
|
|
if (process.env.NEXT_RUNTIME !== "edge") {
|
|
const { Readable } = require("stream");
|
|
if ("toWeb" in Readable && typeof Readable.toWeb === "function") {
|
|
return Readable.toWeb(nodeReadable);
|
|
}
|
|
return new ReadableStream({
|
|
start (controller) {
|
|
nodeReadable.on("data", (chunk)=>{
|
|
controller.enqueue(chunk);
|
|
});
|
|
nodeReadable.on("end", ()=>{
|
|
controller.close();
|
|
});
|
|
nodeReadable.on("error", (error)=>{
|
|
controller.error(error);
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
throw new Error("Invalid runtime");
|
|
}
|
|
}
|
|
function formDataFromSearchQueryString(query) {
|
|
const searchParams = new URLSearchParams(query);
|
|
const formData = new FormData();
|
|
for (const [key, value] of searchParams){
|
|
formData.append(key, value);
|
|
}
|
|
return formData;
|
|
}
|
|
function nodeHeadersToRecord(headers) {
|
|
const record = {};
|
|
for (const [key, value] of Object.entries(headers)){
|
|
if (value !== undefined) {
|
|
record[key] = Array.isArray(value) ? value.join(", ") : `${value}`;
|
|
}
|
|
}
|
|
return record;
|
|
}
|
|
function getForwardedHeaders(req, res) {
|
|
// Get request headers and cookies
|
|
const requestHeaders = req.headers;
|
|
const requestCookies = requestHeaders["cookie"] ?? "";
|
|
// Get response headers and Set-Cookie header
|
|
const responseHeaders = res.getHeaders();
|
|
const rawSetCookies = responseHeaders["set-cookie"];
|
|
const setCookies = (Array.isArray(rawSetCookies) ? rawSetCookies : [
|
|
rawSetCookies
|
|
]).map((setCookie)=>{
|
|
// remove the suffixes like 'HttpOnly' and 'SameSite'
|
|
const [cookie] = `${setCookie}`.split(";");
|
|
return cookie;
|
|
});
|
|
// Merge request and response headers
|
|
const mergedHeaders = filterReqHeaders({
|
|
...nodeHeadersToRecord(requestHeaders),
|
|
...nodeHeadersToRecord(responseHeaders)
|
|
}, actionsForbiddenHeaders);
|
|
// Merge cookies
|
|
const mergedCookies = requestCookies.split("; ").concat(setCookies).join("; ");
|
|
// Update the 'cookie' header with the merged cookies
|
|
mergedHeaders["cookie"] = mergedCookies;
|
|
// Remove headers that should not be forwarded
|
|
delete mergedHeaders["transfer-encoding"];
|
|
return new Headers(mergedHeaders);
|
|
}
|
|
async function addRevalidationHeader(res, { staticGenerationStore, requestStore }) {
|
|
var _staticGenerationStore_revalidatedTags;
|
|
await Promise.all(staticGenerationStore.pendingRevalidates || []);
|
|
// If a tag was revalidated, the client router needs to invalidate all the
|
|
// client router cache as they may be stale. And if a path was revalidated, the
|
|
// client needs to invalidate all subtrees below that path.
|
|
// To keep the header size small, we use a tuple of
|
|
// [[revalidatedPaths], isTagRevalidated ? 1 : 0, isCookieRevalidated ? 1 : 0]
|
|
// instead of a JSON object.
|
|
// TODO-APP: Currently the prefetch cache doesn't have subtree information,
|
|
// so we need to invalidate the entire cache if a path was revalidated.
|
|
// TODO-APP: Currently paths are treated as tags, so the second element of the tuple
|
|
// is always empty.
|
|
const isTagRevalidated = ((_staticGenerationStore_revalidatedTags = staticGenerationStore.revalidatedTags) == null ? void 0 : _staticGenerationStore_revalidatedTags.length) ? 1 : 0;
|
|
const isCookieRevalidated = getModifiedCookieValues(requestStore.mutableCookies).length ? 1 : 0;
|
|
res.setHeader("x-action-revalidated", JSON.stringify([
|
|
[],
|
|
isTagRevalidated,
|
|
isCookieRevalidated
|
|
]));
|
|
}
|
|
async function createRedirectRenderResult(req, res, redirectUrl, staticGenerationStore) {
|
|
res.setHeader("x-action-redirect", redirectUrl);
|
|
// if we're redirecting to a relative path, we'll try to stream the response
|
|
if (redirectUrl.startsWith("/")) {
|
|
var _staticGenerationStore_incrementalCache;
|
|
const forwardedHeaders = getForwardedHeaders(req, res);
|
|
forwardedHeaders.set(RSC, "1");
|
|
const host = req.headers["host"];
|
|
const proto = ((_staticGenerationStore_incrementalCache = staticGenerationStore.incrementalCache) == null ? void 0 : _staticGenerationStore_incrementalCache.requestProtocol) || "https";
|
|
const fetchUrl = new URL(`${proto}://${host}${redirectUrl}`);
|
|
if (staticGenerationStore.revalidatedTags) {
|
|
var _staticGenerationStore_incrementalCache_prerenderManifest_preview, _staticGenerationStore_incrementalCache_prerenderManifest, _staticGenerationStore_incrementalCache1;
|
|
forwardedHeaders.set(NEXT_CACHE_REVALIDATED_TAGS_HEADER, staticGenerationStore.revalidatedTags.join(","));
|
|
forwardedHeaders.set(NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER, ((_staticGenerationStore_incrementalCache1 = staticGenerationStore.incrementalCache) == null ? void 0 : (_staticGenerationStore_incrementalCache_prerenderManifest = _staticGenerationStore_incrementalCache1.prerenderManifest) == null ? void 0 : (_staticGenerationStore_incrementalCache_prerenderManifest_preview = _staticGenerationStore_incrementalCache_prerenderManifest.preview) == null ? void 0 : _staticGenerationStore_incrementalCache_prerenderManifest_preview.previewModeId) || "");
|
|
}
|
|
// Ensures that when the path was revalidated we don't return a partial response on redirects
|
|
// if (staticGenerationStore.pathWasRevalidated) {
|
|
forwardedHeaders.delete("next-router-state-tree");
|
|
// }
|
|
try {
|
|
const headResponse = await fetch(fetchUrl, {
|
|
method: "HEAD",
|
|
headers: forwardedHeaders,
|
|
next: {
|
|
// @ts-ignore
|
|
internal: 1
|
|
}
|
|
});
|
|
if (headResponse.headers.get("content-type") === RSC_CONTENT_TYPE_HEADER) {
|
|
const response = await fetch(fetchUrl, {
|
|
method: "GET",
|
|
headers: forwardedHeaders,
|
|
next: {
|
|
// @ts-ignore
|
|
internal: 1
|
|
}
|
|
});
|
|
// copy the headers from the redirect response to the response we're sending
|
|
for (const [key, value] of response.headers){
|
|
if (!actionsForbiddenHeaders.includes(key)) {
|
|
res.setHeader(key, value);
|
|
}
|
|
}
|
|
return new FlightRenderResult(response.body);
|
|
}
|
|
} catch (err) {
|
|
// we couldn't stream the redirect response, so we'll just do a normal redirect
|
|
console.error(`failed to get redirect response`, err);
|
|
}
|
|
}
|
|
return new RenderResult(JSON.stringify({}));
|
|
}
|
|
export async function handleAction({ req, res, ComponentMod, page, serverActionsManifest, generateFlight, staticGenerationStore, requestStore, serverActionsBodySizeLimit, ctx }) {
|
|
let actionId = req.headers[ACTION.toLowerCase()];
|
|
const contentType = req.headers["content-type"];
|
|
const isURLEncodedAction = req.method === "POST" && contentType === "application/x-www-form-urlencoded";
|
|
const isMultipartAction = req.method === "POST" && (contentType == null ? void 0 : contentType.startsWith("multipart/form-data"));
|
|
const isFetchAction = actionId !== undefined && typeof actionId === "string" && req.method === "POST";
|
|
// If it's not a Server Action, skip handling.
|
|
if (!(isFetchAction || isURLEncodedAction || isMultipartAction)) {
|
|
return;
|
|
}
|
|
const originHostname = typeof req.headers["origin"] === "string" ? new URL(req.headers["origin"]).host : undefined;
|
|
const host = req.headers["x-forwarded-host"] || req.headers["host"];
|
|
// This is to prevent CSRF attacks. If `x-forwarded-host` is set, we need to
|
|
// ensure that the request is coming from the same host.
|
|
if (!originHostname) {
|
|
// This might be an old browser that doesn't send `host` header. We ignore
|
|
// this case.
|
|
console.warn("Missing `origin` header from a forwarded Server Actions request.");
|
|
} else if (!host || originHostname !== host) {
|
|
// This is an attack. We should not proceed the action.
|
|
console.error("`x-forwarded-host` and `host` headers do not match `origin` header from a forwarded Server Actions request. Aborting the action.");
|
|
const error = new Error("Invalid Server Actions request.");
|
|
if (isFetchAction) {
|
|
res.statusCode = 500;
|
|
await Promise.all(staticGenerationStore.pendingRevalidates || []);
|
|
const promise = Promise.reject(error);
|
|
try {
|
|
await promise;
|
|
} catch {}
|
|
return {
|
|
type: "done",
|
|
result: await generateFlight(ctx, {
|
|
actionResult: promise,
|
|
// if the page was not revalidated, we can skip the rendering the flight tree
|
|
skipFlight: !staticGenerationStore.pathWasRevalidated
|
|
})
|
|
};
|
|
}
|
|
throw error;
|
|
}
|
|
// ensure we avoid caching server actions unexpectedly
|
|
res.setHeader("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate");
|
|
let bound = [];
|
|
const workerName = "app" + page;
|
|
const serverModuleMap = new Proxy({}, {
|
|
get: (_, id)=>{
|
|
return {
|
|
id: serverActionsManifest[process.env.NEXT_RUNTIME === "edge" ? "edge" : "node"][id].workers[workerName],
|
|
name: id,
|
|
chunks: []
|
|
};
|
|
}
|
|
});
|
|
const { actionAsyncStorage } = ComponentMod;
|
|
let actionResult;
|
|
let formState;
|
|
try {
|
|
await actionAsyncStorage.run({
|
|
isAction: true
|
|
}, async ()=>{
|
|
if (process.env.NEXT_RUNTIME === "edge") {
|
|
// Use react-server-dom-webpack/server.edge
|
|
const { decodeReply, decodeAction, decodeFormState } = ComponentMod;
|
|
const webRequest = req;
|
|
if (!webRequest.body) {
|
|
throw new Error("invariant: Missing request body.");
|
|
}
|
|
if (isMultipartAction) {
|
|
// TODO-APP: Add streaming support
|
|
const formData = await webRequest.request.formData();
|
|
if (isFetchAction) {
|
|
bound = await decodeReply(formData, serverModuleMap);
|
|
} else {
|
|
const action = await decodeAction(formData, serverModuleMap);
|
|
const actionReturnedState = await action();
|
|
formState = decodeFormState(actionReturnedState, formData);
|
|
// Skip the fetch path
|
|
return;
|
|
}
|
|
} else {
|
|
let actionData = "";
|
|
const reader = webRequest.body.getReader();
|
|
while(true){
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
actionData += new TextDecoder().decode(value);
|
|
}
|
|
if (isURLEncodedAction) {
|
|
const formData = formDataFromSearchQueryString(actionData);
|
|
bound = await decodeReply(formData, serverModuleMap);
|
|
} else {
|
|
bound = await decodeReply(actionData, serverModuleMap);
|
|
}
|
|
}
|
|
} else {
|
|
// Use react-server-dom-webpack/server.node which supports streaming
|
|
const { decodeReply, decodeReplyFromBusboy, decodeAction, decodeFormState } = require(`./react-server.node`);
|
|
if (isMultipartAction) {
|
|
if (isFetchAction) {
|
|
const busboy = require("busboy");
|
|
const bb = busboy({
|
|
headers: req.headers
|
|
});
|
|
req.pipe(bb);
|
|
bound = await decodeReplyFromBusboy(bb, serverModuleMap);
|
|
} else {
|
|
// React doesn't yet publish a busboy version of decodeAction
|
|
// so we polyfill the parsing of FormData.
|
|
const UndiciRequest = require("next/dist/compiled/undici").Request;
|
|
const fakeRequest = new UndiciRequest("http://localhost", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": req.headers["content-type"]
|
|
},
|
|
body: nodeToWebReadableStream(req),
|
|
duplex: "half"
|
|
});
|
|
const formData = await fakeRequest.formData();
|
|
const action = await decodeAction(formData, serverModuleMap);
|
|
const actionReturnedState = await action();
|
|
formState = await decodeFormState(actionReturnedState, formData);
|
|
// Skip the fetch path
|
|
return;
|
|
}
|
|
} else {
|
|
const { parseBody } = require("../api-utils/node/parse-body");
|
|
let actionData;
|
|
try {
|
|
actionData = await parseBody(req, serverActionsBodySizeLimit ?? "1mb") || "";
|
|
} catch (e) {
|
|
if (e && e.statusCode === 413) {
|
|
// Exceeded the size limit
|
|
e.message = e.message + "\nTo configure the body size limit for Server Actions, see: https://nextjs.org/docs/app/api-reference/server-actions#size-limitation";
|
|
}
|
|
throw e;
|
|
}
|
|
if (isURLEncodedAction) {
|
|
const formData = formDataFromSearchQueryString(actionData);
|
|
bound = await decodeReply(formData, serverModuleMap);
|
|
} else {
|
|
bound = await decodeReply(actionData, serverModuleMap);
|
|
}
|
|
}
|
|
}
|
|
// actions.js
|
|
// app/page.js
|
|
// action worker1
|
|
// appRender1
|
|
// app/foo/page.js
|
|
// action worker2
|
|
// appRender
|
|
// / -> fire action -> POST / -> appRender1 -> modId for the action file
|
|
// /foo -> fire action -> POST /foo -> appRender2 -> modId for the action file
|
|
// Get all workers that include this action
|
|
const actionWorkers = serverActionsManifest[process.env.NEXT_RUNTIME === "edge" ? "edge" : "node"][actionId];
|
|
if (!actionWorkers) {
|
|
// When this happens, it could be a deployment skew where the action came
|
|
// from a different deployment. We'll just return a 404 with a message logged.
|
|
console.error(`Failed to find Server Action "${actionId}". This request might be from an older or newer deployment.`);
|
|
return {
|
|
type: "not-found"
|
|
};
|
|
}
|
|
const actionModId = actionWorkers.workers[workerName];
|
|
const actionHandler = ComponentMod.__next_app__.require(actionModId)[actionId];
|
|
const returnVal = await actionHandler.apply(null, bound);
|
|
// For form actions, we need to continue rendering the page.
|
|
if (isFetchAction) {
|
|
await addRevalidationHeader(res, {
|
|
staticGenerationStore,
|
|
requestStore
|
|
});
|
|
actionResult = await generateFlight(ctx, {
|
|
actionResult: Promise.resolve(returnVal),
|
|
// if the page was not revalidated, we can skip the rendering the flight tree
|
|
skipFlight: !staticGenerationStore.pathWasRevalidated
|
|
});
|
|
}
|
|
});
|
|
return {
|
|
type: "done",
|
|
result: actionResult,
|
|
formState
|
|
};
|
|
} catch (err) {
|
|
if (isRedirectError(err)) {
|
|
const redirectUrl = getURLFromRedirectError(err);
|
|
// if it's a fetch action, we don't want to mess with the status code
|
|
// and we'll handle it on the client router
|
|
await addRevalidationHeader(res, {
|
|
staticGenerationStore,
|
|
requestStore
|
|
});
|
|
if (isFetchAction) {
|
|
return {
|
|
type: "done",
|
|
result: await createRedirectRenderResult(req, res, redirectUrl, staticGenerationStore)
|
|
};
|
|
}
|
|
if (err.mutableCookies) {
|
|
const headers = new Headers();
|
|
// If there were mutable cookies set, we need to set them on the
|
|
// response.
|
|
if (appendMutableCookies(headers, err.mutableCookies)) {
|
|
res.setHeader("set-cookie", Array.from(headers.values()));
|
|
}
|
|
}
|
|
res.setHeader("Location", redirectUrl);
|
|
res.statusCode = 303;
|
|
return {
|
|
type: "done",
|
|
result: new RenderResult("")
|
|
};
|
|
} else if (isNotFoundError(err)) {
|
|
res.statusCode = 404;
|
|
await addRevalidationHeader(res, {
|
|
staticGenerationStore,
|
|
requestStore
|
|
});
|
|
if (isFetchAction) {
|
|
const promise = Promise.reject(err);
|
|
try {
|
|
await promise;
|
|
} catch {}
|
|
return {
|
|
type: "done",
|
|
result: await generateFlight(ctx, {
|
|
skipFlight: false,
|
|
actionResult: promise,
|
|
asNotFound: true
|
|
})
|
|
};
|
|
}
|
|
return {
|
|
type: "not-found"
|
|
};
|
|
}
|
|
if (isFetchAction) {
|
|
res.statusCode = 500;
|
|
await Promise.all(staticGenerationStore.pendingRevalidates || []);
|
|
const promise = Promise.reject(err);
|
|
try {
|
|
await promise;
|
|
} catch {}
|
|
return {
|
|
type: "done",
|
|
result: await generateFlight(ctx, {
|
|
actionResult: promise,
|
|
// if the page was not revalidated, we can skip the rendering the flight tree
|
|
skipFlight: !staticGenerationStore.pathWasRevalidated
|
|
})
|
|
};
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
//# sourceMappingURL=action-handler.js.map
|