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:
@@ -2,11 +2,6 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
experimental: {
|
|
||||||
serverActions: {
|
|
||||||
bodySizeLimit: '250mb',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
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 { execFile } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { randomUUID } from "crypto";
|
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) {
|
async function cleanup(dir: string) {
|
||||||
try {
|
try {
|
||||||
const { readdir } = await import("fs/promises");
|
const { readdir } = await import("fs/promises");
|
||||||
@@ -102,30 +152,36 @@ export async function POST(req: NextRequest) {
|
|||||||
const gsOutputPath = join(tmpDir, "gs-output.pdf");
|
const gsOutputPath = join(tmpDir, "gs-output.pdf");
|
||||||
const finalOutputPath = join(tmpDir, "final.pdf");
|
const finalOutputPath = join(tmpDir, "final.pdf");
|
||||||
|
|
||||||
// Use standard formData() — works reliably for large files in Next.js 16
|
// Collect raw body via arrayBuffer() — more reliable than formData() for
|
||||||
let pdfBuffer: Buffer;
|
// large files, and more reliable than Readable.fromWeb streaming to disk.
|
||||||
try {
|
if (!req.body) {
|
||||||
const formData = await req.formData();
|
|
||||||
const fileField = formData.get("fileInput");
|
|
||||||
if (!fileField || !(fileField instanceof Blob)) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Lipsește fișierul PDF." },
|
{ error: "Lipsește fișierul PDF." },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
pdfBuffer = Buffer.from(await fileField.arrayBuffer());
|
|
||||||
} catch (parseErr) {
|
const rawBuf = Buffer.from(await req.arrayBuffer());
|
||||||
const msg =
|
|
||||||
parseErr instanceof Error ? parseErr.message : "Parse error";
|
// 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(
|
return NextResponse.json(
|
||||||
{ error: `Nu s-a putut citi fișierul: ${msg}` },
|
{ error: "Invalid request — missing multipart boundary." },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pdfBuffer.length < 100) {
|
const pdfBuffer = extractFileFromMultipart(rawBuf, boundary);
|
||||||
|
|
||||||
|
if (!pdfBuffer || pdfBuffer.length < 100) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Fișierul PDF este gol sau prea mic." },
|
{ error: "Fișierul PDF este gol sau nu a putut fi extras." },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -174,9 +230,7 @@ export async function POST(req: NextRequest) {
|
|||||||
// Verify GS output is a valid non-empty PDF
|
// Verify GS output is a valid non-empty PDF
|
||||||
let gsSize = 0;
|
let gsSize = 0;
|
||||||
try {
|
try {
|
||||||
const gsStat = await import("fs/promises").then((fs) =>
|
const gsStat = await stat(gsOutputPath);
|
||||||
fs.stat(gsOutputPath),
|
|
||||||
);
|
|
||||||
gsSize = gsStat.size;
|
gsSize = gsStat.size;
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -7,41 +7,22 @@ const STIRLING_PDF_API_KEY =
|
|||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Parse incoming form data — get file + optimizeLevel
|
// Buffer the full body then forward to Stirling — streaming passthrough
|
||||||
let formData: FormData;
|
// (req.body + duplex:half) is unreliable for large files in Next.js.
|
||||||
try {
|
const bodyBytes = await req.arrayBuffer();
|
||||||
formData = await req.formData();
|
const contentType = req.headers.get("content-type") || "";
|
||||||
} catch (parseErr) {
|
|
||||||
const msg =
|
|
||||||
parseErr instanceof Error ? parseErr.message : "Parse error";
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Nu s-a putut citi formularul: ${msg}` },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileField = formData.get("fileInput");
|
// Extract original file size from the multipart body for the response header
|
||||||
if (!fileField || !(fileField instanceof Blob)) {
|
// (rough estimate — the overhead of multipart framing is negligible for large PDFs)
|
||||||
return NextResponse.json(
|
const originalSize = bodyBytes.byteLength;
|
||||||
{ 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));
|
|
||||||
|
|
||||||
const res = await fetch(`${STIRLING_PDF_URL}/api/v1/misc/compress-pdf`, {
|
const res = await fetch(`${STIRLING_PDF_URL}/api/v1/misc/compress-pdf`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"X-API-KEY": STIRLING_PDF_API_KEY,
|
"X-API-KEY": STIRLING_PDF_API_KEY,
|
||||||
|
"Content-Type": contentType,
|
||||||
},
|
},
|
||||||
body: stirlingForm,
|
body: bodyBytes,
|
||||||
signal: AbortSignal.timeout(300_000), // 5 min for large files
|
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 blob = await res.blob();
|
||||||
const buffer = Buffer.from(await blob.arrayBuffer());
|
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||||
|
|
||||||
return new NextResponse(buffer, {
|
return new NextResponse(new Uint8Array(buffer), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/pdf",
|
"Content-Type": "application/pdf",
|
||||||
|
|||||||
Reference in New Issue
Block a user