fix(pdf-compress): use arrayBuffer() instead of formData() for large files
formData() fails with "Failed to parse body as FormData" on large PDFs in Next.js route handlers. Switch to req.arrayBuffer() which reliably reads the full body, then manually extract the PDF from multipart. Extreme mode: arrayBuffer + multipart extraction + GS + qpdf pipeline. Stirling mode: arrayBuffer forwarding to Stirling with proper headers. Revert serverActions.bodySizeLimit (doesn't apply to route handlers). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { writeFile, readFile, unlink, mkdir } from "fs/promises";
|
||||
import { writeFile, readFile, unlink, mkdir, stat } from "fs/promises";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { randomUUID } from "crypto";
|
||||
@@ -79,6 +79,56 @@ function qpdfArgs(input: string, output: string): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the file binary from a raw multipart/form-data buffer.
|
||||
* Finds the part whose Content-Disposition contains `filename=`,
|
||||
* then returns the bytes between the header-end and the closing boundary.
|
||||
*/
|
||||
function extractFileFromMultipart(
|
||||
raw: Buffer,
|
||||
boundary: string,
|
||||
): Buffer | null {
|
||||
const boundaryBuf = Buffer.from(`--${boundary}`);
|
||||
const headerSep = Buffer.from("\r\n\r\n");
|
||||
const crlf = Buffer.from("\r\n");
|
||||
|
||||
let searchFrom = 0;
|
||||
|
||||
while (searchFrom < raw.length) {
|
||||
const partStart = raw.indexOf(boundaryBuf, searchFrom);
|
||||
if (partStart === -1) break;
|
||||
|
||||
// Find end of boundary line
|
||||
const lineEnd = raw.indexOf(crlf, partStart);
|
||||
if (lineEnd === -1) break;
|
||||
|
||||
// Find blank line separating headers from body
|
||||
const headerEnd = raw.indexOf(headerSep, lineEnd);
|
||||
if (headerEnd === -1) break;
|
||||
|
||||
// Check if this part has a filename
|
||||
const headers = raw.subarray(lineEnd + 2, headerEnd).toString("utf8");
|
||||
if (headers.includes("filename=")) {
|
||||
const fileStart = headerEnd + 4; // skip \r\n\r\n
|
||||
|
||||
// Find closing boundary — search from end to avoid false matches inside PDF
|
||||
const closingMarker = Buffer.from(`\r\n--${boundary}`);
|
||||
const fileEnd = raw.lastIndexOf(closingMarker);
|
||||
|
||||
if (fileEnd > fileStart) {
|
||||
return raw.subarray(fileStart, fileEnd);
|
||||
}
|
||||
// Fallback: no closing boundary found, take everything after headers
|
||||
return raw.subarray(fileStart);
|
||||
}
|
||||
|
||||
// Skip past this part
|
||||
searchFrom = headerEnd + 4;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function cleanup(dir: string) {
|
||||
try {
|
||||
const { readdir } = await import("fs/promises");
|
||||
@@ -102,30 +152,36 @@ export async function POST(req: NextRequest) {
|
||||
const gsOutputPath = join(tmpDir, "gs-output.pdf");
|
||||
const finalOutputPath = join(tmpDir, "final.pdf");
|
||||
|
||||
// Use standard formData() — works reliably for large files in Next.js 16
|
||||
let pdfBuffer: Buffer;
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const fileField = formData.get("fileInput");
|
||||
if (!fileField || !(fileField instanceof Blob)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Lipsește fișierul PDF." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
pdfBuffer = Buffer.from(await fileField.arrayBuffer());
|
||||
} catch (parseErr) {
|
||||
const msg =
|
||||
parseErr instanceof Error ? parseErr.message : "Parse error";
|
||||
// Collect raw body via arrayBuffer() — more reliable than formData() for
|
||||
// large files, and more reliable than Readable.fromWeb streaming to disk.
|
||||
if (!req.body) {
|
||||
return NextResponse.json(
|
||||
{ error: `Nu s-a putut citi fișierul: ${msg}` },
|
||||
{ error: "Lipsește fișierul PDF." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (pdfBuffer.length < 100) {
|
||||
const rawBuf = Buffer.from(await req.arrayBuffer());
|
||||
|
||||
// Extract PDF from multipart body
|
||||
const contentType = req.headers.get("content-type") || "";
|
||||
const boundaryMatch = contentType.match(
|
||||
/boundary=(?:"([^"]+)"|([^\s;]+))/,
|
||||
);
|
||||
const boundary = boundaryMatch?.[1] ?? boundaryMatch?.[2] ?? "";
|
||||
|
||||
if (!boundary) {
|
||||
return NextResponse.json(
|
||||
{ error: "Fișierul PDF este gol sau prea mic." },
|
||||
{ error: "Invalid request — missing multipart boundary." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const pdfBuffer = extractFileFromMultipart(rawBuf, boundary);
|
||||
|
||||
if (!pdfBuffer || pdfBuffer.length < 100) {
|
||||
return NextResponse.json(
|
||||
{ error: "Fișierul PDF este gol sau nu a putut fi extras." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
@@ -174,9 +230,7 @@ export async function POST(req: NextRequest) {
|
||||
// Verify GS output is a valid non-empty PDF
|
||||
let gsSize = 0;
|
||||
try {
|
||||
const gsStat = await import("fs/promises").then((fs) =>
|
||||
fs.stat(gsOutputPath),
|
||||
);
|
||||
const gsStat = await stat(gsOutputPath);
|
||||
gsSize = gsStat.size;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
|
||||
Reference in New Issue
Block a user