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
26 changes: 22 additions & 4 deletions packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ import { safeJsonStringify } from "vinext/html";
import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local";
import { parseCookies } from ${JSON.stringify(path.resolve(__dirname, "../config/config-matchers.js").replace(/\\/g, "/"))};
import { decode as decodeQueryString } from "node:querystring";
import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(_requestContextShimPath)};
${instrumentationImportCode}
${middlewareImportCode}
Expand Down Expand Up @@ -324,6 +325,16 @@ function isrCacheKey(router, pathname) {
return prefix + ":__hash:" + fnv1a64(normalized);
}

function getMediaType(contentType) {
var type = (contentType || "text/plain").split(";")[0];
type = type && type.trim().toLowerCase();
return type || "text/plain";
}

function isJsonMediaType(mediaType) {
return mediaType === "application/json" || mediaType === "application/ld+json";
}

async function renderToStringAsync(element) {
const stream = await renderToReadableStream(element);
await stream.allReady;
Expand Down Expand Up @@ -1039,14 +1050,21 @@ export async function handleApiRoute(request, url) {
return new Response("Request body too large", { status: 413 });
}
let body;
const ct = request.headers.get("content-type") || "";
const mediaType = getMediaType(request.headers.get("content-type"));
let rawBody;
try { rawBody = await readBodyWithLimit(request, 1 * 1024 * 1024); }
catch { return new Response("Request body too large", { status: 413 }); }
if (!rawBody) {
body = undefined;
} else if (ct.includes("application/json")) {
try { body = JSON.parse(rawBody); } catch { body = rawBody; }
body = isJsonMediaType(mediaType)
? {}
: mediaType === "application/x-www-form-urlencoded"
? decodeQueryString(rawBody)
: undefined;
} else if (isJsonMediaType(mediaType)) {
try { body = JSON.parse(rawBody); }
catch { return new Response("Invalid JSON", { status: 400, statusText: "Invalid JSON" }); }
} else if (mediaType === "application/x-www-form-urlencoded") {
body = decodeQueryString(rawBody);
} else {
body = rawBody;
}
Expand Down
50 changes: 39 additions & 11 deletions packages/vinext/src/server/api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/
import type { ViteDevServer } from "vite";
import type { IncomingMessage, ServerResponse } from "node:http";
import { decode as decodeQueryString } from "node:querystring";
import { type Route, matchRoute } from "../routing/pages-router.js";
import { reportRequestError } from "./instrumentation.js";
import { addQueryParam } from "../utils/query.js";
Expand Down Expand Up @@ -39,6 +40,25 @@ interface NextApiResponse extends ServerResponse {
*/
const MAX_BODY_SIZE = 1 * 1024 * 1024;

class ApiBodyParseError extends Error {
constructor(
message: string,
readonly statusCode: number,
) {
super(message);
this.name = "ApiBodyParseError";
}
}

function getMediaType(contentType: string | undefined): string {
const [type] = (contentType ?? "text/plain").split(";");
return type?.trim().toLowerCase() || "text/plain";
}

function isJsonMediaType(mediaType: string): boolean {
return mediaType === "application/json" || mediaType === "application/ld+json";
}

/**
* Parse the request body based on content-type.
* Enforces a size limit to prevent memory exhaustion attacks.
Expand Down Expand Up @@ -68,24 +88,25 @@ async function parseBody(req: IncomingMessage): Promise<unknown> {
if (settled) return;
settled = true;
const raw = Buffer.concat(chunks).toString("utf-8");
const mediaType = getMediaType(req.headers["content-type"]);
if (!raw) {
resolve(undefined);
resolve(
isJsonMediaType(mediaType)
? {}
: mediaType === "application/x-www-form-urlencoded"
? decodeQueryString(raw)
: undefined,
);
return;
}
const contentType = req.headers["content-type"] ?? "";
if (contentType.includes("application/json")) {
if (isJsonMediaType(mediaType)) {
try {
resolve(JSON.parse(raw));
} catch {
resolve(raw);
}
} else if (contentType.includes("application/x-www-form-urlencoded")) {
const params = new URLSearchParams(raw);
const obj: Record<string, string> = {};
for (const [key, value] of params) {
obj[key] = value;
reject(new ApiBodyParseError("Invalid JSON", 400));
}
resolve(obj);
} else if (mediaType === "application/x-www-form-urlencoded") {
resolve(decodeQueryString(raw));
} else {
resolve(raw);
}
Expand Down Expand Up @@ -206,6 +227,13 @@ export async function handleApiRoute(
await handler(apiReq, apiRes);
return true;
} catch (e) {
if (e instanceof ApiBodyParseError) {
res.statusCode = e.statusCode;
res.statusMessage = e.message;
res.end(e.message);
return true;
}

server.ssrFixStacktrace(e as Error);
console.error(e);
reportRequestError(
Expand Down
43 changes: 36 additions & 7 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,20 @@ function sendCompressed(
statusCode: number,
extraHeaders: Record<string, string | string[]> = {},
compress: boolean = true,
statusText: string | undefined = undefined,
): void {
const buf = typeof body === "string" ? Buffer.from(body) : body;
const baseType = contentType.split(";")[0].trim();
const encoding = compress ? negotiateEncoding(req) : null;

const writeHead = (headers: Record<string, string | string[]>) => {
if (statusText) {
res.writeHead(statusCode, statusText, headers);
} else {
res.writeHead(statusCode, headers);
}
};

if (encoding && COMPRESSIBLE_TYPES.has(baseType) && buf.length >= COMPRESS_THRESHOLD) {
const compressor = createCompressor(encoding);
// Merge Accept-Encoding into existing Vary header from extraHeaders instead
Expand All @@ -187,7 +196,7 @@ function sendCompressed(
} else {
varyValue = "Accept-Encoding";
}
res.writeHead(statusCode, {
writeHead({
...extraHeaders,
"Content-Type": contentType,
"Content-Encoding": encoding,
Expand All @@ -198,7 +207,7 @@ function sendCompressed(
/* ignore pipeline errors on closed connections */
});
} else {
res.writeHead(statusCode, {
writeHead({
...extraHeaders,
"Content-Type": contentType,
"Content-Length": String(buf.length),
Expand Down Expand Up @@ -393,6 +402,14 @@ async function sendWebResponse(
compress: boolean,
): Promise<void> {
const status = webResponse.status;
const statusText = webResponse.statusText || undefined;
const writeHead = (headers: Record<string, string | string[]>) => {
if (statusText) {
res.writeHead(status, statusText, headers);
} else {
res.writeHead(status, headers);
}
};

// Collect headers, handling multi-value headers (e.g. Set-Cookie)
const nodeHeaders: Record<string, string | string[]> = {};
Expand All @@ -406,7 +423,7 @@ async function sendWebResponse(
});

if (!webResponse.body) {
res.writeHead(status, nodeHeaders);
writeHead(nodeHeaders);
res.end();
return;
}
Expand Down Expand Up @@ -437,7 +454,7 @@ async function sendWebResponse(
}
}

res.writeHead(status, nodeHeaders);
writeHead(nodeHeaders);

// HEAD requests: send headers only, skip the body
if (req.method === "HEAD") {
Expand Down Expand Up @@ -879,7 +896,11 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
});
const setCookies = result.response.headers.getSetCookie?.() ?? [];
if (setCookies.length > 0) respHeaders["set-cookie"] = setCookies;
res.writeHead(result.response.status, respHeaders);
if (result.response.statusText) {
res.writeHead(result.response.status, result.response.statusText, respHeaders);
} else {
res.writeHead(result.response.status, respHeaders);
}
res.end(body);
return;
}
Expand Down Expand Up @@ -1005,15 +1026,19 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
// the handler doesn't set an explicit Content-Type.
const ct = response.headers.get("content-type") ?? "application/octet-stream";
const responseHeaders = mergeResponseHeaders(middlewareHeaders, response);
const finalStatus = middlewareRewriteStatus ?? response.status;
const finalStatusText =
finalStatus === response.status ? response.statusText || undefined : undefined;

sendCompressed(
req,
res,
responseBody,
ct,
middlewareRewriteStatus ?? response.status,
finalStatus,
responseHeaders,
compress,
finalStatusText,
);
return;
}
Expand Down Expand Up @@ -1065,15 +1090,19 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
const responseBody = Buffer.from(await response.arrayBuffer());
const ct = response.headers.get("content-type") ?? "text/html";
const responseHeaders = mergeResponseHeaders(middlewareHeaders, response);
const finalStatus = middlewareRewriteStatus ?? response.status;
const finalStatusText =
finalStatus === response.status ? response.statusText || undefined : undefined;

sendCompressed(
req,
res,
responseBody,
ct,
middlewareRewriteStatus ?? response.status,
finalStatus,
responseHeaders,
compress,
finalStatusText,
);
} catch (e) {
console.error("[vinext] Server error:", e);
Expand Down
32 changes: 26 additions & 6 deletions tests/__snapshots__/entry-templates.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -17518,6 +17518,7 @@ import { safeJsonStringify } from "vinext/html";
import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local";
import { parseCookies } from "<ROOT>/packages/vinext/src/config/config-matchers.js";
import { decode as decodeQueryString } from "node:querystring";
import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "<ROOT>/packages/vinext/src/shims/request-context.js";
import * as _instrumentation from "<ROOT>/tests/fixtures/pages-basic/instrumentation.ts";
import * as middlewareModule from "<ROOT>/tests/fixtures/pages-basic/middleware.ts";
Expand Down Expand Up @@ -17591,6 +17592,16 @@ function isrCacheKey(router, pathname) {
return prefix + ":__hash:" + fnv1a64(normalized);
}

function getMediaType(contentType) {
var type = (contentType || "text/plain").split(";")[0];
type = type && type.trim().toLowerCase();
return type || "text/plain";
}

function isJsonMediaType(mediaType) {
return mediaType === "application/json" || mediaType === "application/ld+json";
}

async function renderToStringAsync(element) {
const stream = await renderToReadableStream(element);
await stream.allReady;
Expand Down Expand Up @@ -17635,7 +17646,8 @@ import * as api_2 from "<ROOT>/tests/fixtures/pages-basic/pages/api/hello.ts";
import * as api_3 from "<ROOT>/tests/fixtures/pages-basic/pages/api/instrumentation-test.ts";
import * as api_4 from "<ROOT>/tests/fixtures/pages-basic/pages/api/middleware-test.ts";
import * as api_5 from "<ROOT>/tests/fixtures/pages-basic/pages/api/no-content-type.ts";
import * as api_6 from "<ROOT>/tests/fixtures/pages-basic/pages/api/users/[id].ts";
import * as api_6 from "<ROOT>/tests/fixtures/pages-basic/pages/api/parse.ts";
import * as api_7 from "<ROOT>/tests/fixtures/pages-basic/pages/api/users/[id].ts";

import { default as AppComponent } from "<ROOT>/tests/fixtures/pages-basic/pages/_app.tsx";
import { default as DocumentComponent } from "<ROOT>/tests/fixtures/pages-basic/pages/_document.tsx";
Expand Down Expand Up @@ -17682,7 +17694,8 @@ const apiRoutes = [
{ pattern: "/api/instrumentation-test", patternParts: ["api","instrumentation-test"], isDynamic: false, params: [], module: api_3 },
{ pattern: "/api/middleware-test", patternParts: ["api","middleware-test"], isDynamic: false, params: [], module: api_4 },
{ pattern: "/api/no-content-type", patternParts: ["api","no-content-type"], isDynamic: false, params: [], module: api_5 },
{ pattern: "/api/users/:id", patternParts: ["api","users",":id"], isDynamic: true, params: ["id"], module: api_6 }
{ pattern: "/api/parse", patternParts: ["api","parse"], isDynamic: false, params: [], module: api_6 },
{ pattern: "/api/users/:id", patternParts: ["api","users",":id"], isDynamic: true, params: ["id"], module: api_7 }
];

function matchRoute(url, routes) {
Expand Down Expand Up @@ -18380,14 +18393,21 @@ export async function handleApiRoute(request, url) {
return new Response("Request body too large", { status: 413 });
}
let body;
const ct = request.headers.get("content-type") || "";
const mediaType = getMediaType(request.headers.get("content-type"));
let rawBody;
try { rawBody = await readBodyWithLimit(request, 1 * 1024 * 1024); }
catch { return new Response("Request body too large", { status: 413 }); }
if (!rawBody) {
body = undefined;
} else if (ct.includes("application/json")) {
try { body = JSON.parse(rawBody); } catch { body = rawBody; }
body = isJsonMediaType(mediaType)
? {}
: mediaType === "application/x-www-form-urlencoded"
? decodeQueryString(rawBody)
: undefined;
} else if (isJsonMediaType(mediaType)) {
try { body = JSON.parse(rawBody); }
catch { return new Response("Invalid JSON", { status: 400, statusText: "Invalid JSON" }); }
} else if (mediaType === "application/x-www-form-urlencoded") {
body = decodeQueryString(rawBody);
} else {
body = rawBody;
}
Expand Down
Loading
Loading