From 9e73dc3cb947ababb0c36c51fa883a4a14345ac1 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Fri, 13 Mar 2026 16:32:05 +0200 Subject: [PATCH] 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 --- next.config.ts | 5 -- src/app/api/compress-pdf/extreme/route.ts | 98 ++++++++++++++++++----- src/app/api/compress-pdf/route.ts | 39 +++------ 3 files changed, 86 insertions(+), 56 deletions(-) diff --git a/next.config.ts b/next.config.ts index 053acd6..225e495 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,11 +2,6 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: 'standalone', - experimental: { - serverActions: { - bodySizeLimit: '250mb', - }, - }, }; export default nextConfig; diff --git a/src/app/api/compress-pdf/extreme/route.ts b/src/app/api/compress-pdf/extreme/route.ts index a592c54..675645f 100644 --- a/src/app/api/compress-pdf/extreme/route.ts +++ b/src/app/api/compress-pdf/extreme/route.ts @@ -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( diff --git a/src/app/api/compress-pdf/route.ts b/src/app/api/compress-pdf/route.ts index 55c00ba..8f69363 100644 --- a/src/app/api/compress-pdf/route.ts +++ b/src/app/api/compress-pdf/route.ts @@ -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",