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:
AI Assistant
2026-03-13 16:32:05 +02:00
parent 194ddf0849
commit 9e73dc3cb9
3 changed files with 86 additions and 56 deletions
-5
View File
@@ -2,11 +2,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
experimental: {
serverActions: {
bodySizeLimit: '250mb',
},
},
};
export default nextConfig;
+76 -22
View File
@@ -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(
+10 -29
View File
@@ -7,41 +7,22 @@ const STIRLING_PDF_API_KEY =
export async function POST(req: NextRequest) {
try {
// Parse incoming form data — get file + optimizeLevel
let formData: FormData;
try {
formData = await req.formData();
} catch (parseErr) {
const msg =
parseErr instanceof Error ? parseErr.message : "Parse error";
return NextResponse.json(
{ error: `Nu s-a putut citi formularul: ${msg}` },
{ status: 400 },
);
}
// Buffer the full body then forward to Stirling — streaming passthrough
// (req.body + duplex:half) is unreliable for large files in Next.js.
const bodyBytes = await req.arrayBuffer();
const contentType = req.headers.get("content-type") || "";
const fileField = formData.get("fileInput");
if (!fileField || !(fileField instanceof Blob)) {
return NextResponse.json(
{ error: "Lipsește fișierul PDF." },
{ status: 400 },
);
}
const optimizeLevel = formData.get("optimizeLevel") ?? "3";
const originalSize = fileField.size;
// Build fresh FormData for Stirling
const stirlingForm = new FormData();
stirlingForm.append("fileInput", fileField, "input.pdf");
stirlingForm.append("optimizeLevel", String(optimizeLevel));
// Extract original file size from the multipart body for the response header
// (rough estimate — the overhead of multipart framing is negligible for large PDFs)
const originalSize = bodyBytes.byteLength;
const res = await fetch(`${STIRLING_PDF_URL}/api/v1/misc/compress-pdf`, {
method: "POST",
headers: {
"X-API-KEY": STIRLING_PDF_API_KEY,
"Content-Type": contentType,
},
body: stirlingForm,
body: bodyBytes,
signal: AbortSignal.timeout(300_000), // 5 min for large files
});
@@ -56,7 +37,7 @@ export async function POST(req: NextRequest) {
const blob = await res.blob();
const buffer = Buffer.from(await blob.arrayBuffer());
return new NextResponse(buffer, {
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
"Content-Type": "application/pdf",