import "server-only"; import type { ReadableStream as WebReadableStream } from "stream/web"; import { type NextRequest, NextResponse } from "next/server"; import { Readable } from "node:stream"; import busboy from "busboy"; import { requireApiSession } from "@/lib/server/modules/auth/api"; import { createRequestId, logServerAction, withServerTiming, } from "@/lib/server/logging/logger"; import { FileServiceError, uploadFiles, } from "@/lib/server/modules/files/service"; import type { FileUploadResponse } from "@/lib/shared/contracts/files"; export const runtime = "nodejs"; export const maxDuration = 300; /** * Stream-parse a multipart upload and pipe each file directly to disk via * uploadFiles, so the full file body is never held in memory at once. * * Fields (path, includeHidden) MUST appear before file parts in the * multipart body — this is the convention and what the client sends. */ function streamingUpload(req: NextRequest): Promise { return new Promise((resolve, reject) => { const contentType = req.headers.get("content-type") ?? ""; let bb: ReturnType; try { bb = busboy({ headers: { "content-type": contentType } }); } catch (err) { reject(new Error(`Invalid multipart request: ${String(err)}`)); return; } let destinationPath = ""; let includeHidden = false; const filePromises: Promise[] = []; bb.on("field", (name, value) => { if (name === "path") destinationPath = value; if (name === "includeHidden") includeHidden = value === "true"; }); bb.on("file", (_field, fileStream, info) => { // Capture field values at the moment the file part begins. // The client sends fields first so these are already populated. const dest = destinationPath; const hidden = includeHidden; // Wrap the live busboy Node.js Readable as a web ReadableStream so it // matches the uploadFiles interface. uploadFiles calls stream() and // immediately pipes the result — data flows directly to disk without // any intermediate in-memory buffer. let byteCount = 0; fileStream.on("data", (chunk: Buffer) => { byteCount += chunk.length; }); const webStream = Readable.toWeb(fileStream) as ReadableStream; filePromises.push( uploadFiles({ destinationPath: dest, includeHidden: hidden, files: [ { name: info.filename, get size() { return byteCount; }, stream: () => webStream, }, ], }), ); }); bb.on("finish", () => { Promise.all(filePromises) .then((results) => { const merged: FileUploadResponse = { uploaded: [], skipped: [] }; for (const r of results) { merged.uploaded.push(...r.uploaded); merged.skipped.push(...r.skipped); } resolve(merged); }) .catch(reject); }); bb.on("error", reject); if (!req.body) { reject(new Error("No request body")); return; } Readable.fromWeb(req.body as WebReadableStream).pipe(bb); }); } export async function POST(request: NextRequest) { const apiSession = await requireApiSession(request); if (apiSession.response) return apiSession.response; const requestId = createRequestId(); try { return await withServerTiming( { layer: "api", action: "files.upload.post", requestId, }, async () => { const data = await streamingUpload(request); if (data.uploaded.length === 0 && data.skipped.length === 0) { return NextResponse.json( { error: "No files provided" }, { status: 400 }, ); } return NextResponse.json({ data }); }, ); } catch (error) { if (error instanceof FileServiceError) { return NextResponse.json( { error: error.message, code: error.code }, { status: error.statusCode }, ); } logServerAction({ layer: "api", action: "files.upload.post", requestId, error: error instanceof Error ? error : new Error(String(error)), }); return NextResponse.json( { error: "Failed to upload files" }, { status: 500 }, ); } }