From 1147d37ed09375e4c4c00c4ce4c489b1754d41a8 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 24 Jun 2026 15:38:28 +0000 Subject: [PATCH] Add GCS resumable upload support for Builder.io file uploads --- packages/core/src/file-upload/builder.ts | 90 +++++++++ packages/core/src/file-upload/index.ts | 7 +- templates/clips/actions/create-recording.ts | 6 + templates/clips/actions/finalize-recording.ts | 88 +++++++++ .../components/recorder/recorder-engine.ts | 176 +++++++++++++++++- templates/clips/app/routes/record.tsx | 4 + .../uploads/[recordingId]/complete.post.ts | 138 ++++++++++++++ .../[recordingId]/init-resumable.post.ts | 113 +++++++++++ 8 files changed, 620 insertions(+), 2 deletions(-) create mode 100644 templates/clips/server/routes/api/uploads/[recordingId]/complete.post.ts create mode 100644 templates/clips/server/routes/api/uploads/[recordingId]/init-resumable.post.ts diff --git a/packages/core/src/file-upload/builder.ts b/packages/core/src/file-upload/builder.ts index ce8235063d..6afdd3d6b7 100644 --- a/packages/core/src/file-upload/builder.ts +++ b/packages/core/src/file-upload/builder.ts @@ -153,6 +153,96 @@ async function uploadSmallFile(url: URL, init: RequestInit): Promise { ); } +export interface BuilderResumableSession { + resumableSessionUri: string; + assetId: string; +} + +/** + * Server-side: initiate a GCS resumable upload session via Builder.io. + * The browser can then PUT chunks directly to `resumableSessionUri` using + * `Content-Range` headers — no app-server hop per chunk. + * + * The session URI carries its own GCS auth; no Authorization header is needed + * when the browser PUTs chunks to it. + * + * GCS resumable upload rules: + * - Non-final chunks: PUT with `Content-Range: bytes start-end/*` + * (size multiple of 256 KB recommended, minimum 256 KB) + * - Final chunk: PUT with `Content-Range: bytes start-end/totalSize` + * - GCS returns 308 Resume Incomplete for non-final, 200/201 for final + */ +export async function requestBuilderResumableSession( + filename: string, + mimeType: string, + size: number, +): Promise { + const { resolveBuilderPrivateKey } = await import( + "../server/credential-provider.js" + ); + const privateKey = await resolveBuilderPrivateKey(); + if (!privateKey) throw new Error("BUILDER_PRIVATE_KEY is not set"); + + const host = builderUploadHost(); + const url = new URL("/api/v1/upload/signed-url", host); + url.searchParams.set("isResumable", "true"); + + console.log(`[builder-upload] resumable session: ${filename} ${mimeType} ${size} bytes`); + const res = await fetchWithTimeout(url.toString(), { + method: "POST", + headers: { + Authorization: `Bearer ${privateKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ fileName: filename, contentType: mimeType, size }), + }); + await assertOk(res, "Builder.io resumable session request failed"); + + const json = (await res.json()) as { uploadUrl?: string; assetId?: string }; + if (!json.uploadUrl || !json.assetId) { + throw new Error( + `Builder.io resumable session response missing fields: ${JSON.stringify(Object.keys(json))}`, + ); + } + console.log(`[builder-upload] resumable session ok: assetId=${json.assetId}`); + return { resumableSessionUri: json.uploadUrl, assetId: json.assetId }; +} + +/** + * Server-side: register a completed GCS resumable upload with Builder.io and + * return the CDN URL. Call this after all browser chunks are PUT to GCS. + */ +export async function completeBuilderResumableUpload( + assetId: string, + filename: string, +): Promise { + const { resolveBuilderPrivateKey } = await import( + "../server/credential-provider.js" + ); + const privateKey = await resolveBuilderPrivateKey(); + if (!privateKey) throw new Error("BUILDER_PRIVATE_KEY is not set"); + + const host = builderUploadHost(); + console.log(`[builder-upload] completing resumable upload: assetId=${assetId}`); + const res = await fetchWithTimeout( + new URL("/api/v1/upload/complete", host).toString(), + { + method: "POST", + headers: { + Authorization: `Bearer ${privateKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ assetId, name: filename }), + }, + ); + await assertOk(res, "Builder.io resumable upload complete failed"); + + const { url } = (await res.json()) as { url?: string }; + if (!url) throw new Error("Builder.io upload/complete returned no URL"); + console.log(`[builder-upload] resumable upload complete: ${url}`); + return url; +} + /** * Built-in Builder.io file upload provider. * Uses the same BUILDER_PRIVATE_KEY as the browser/background-agent flows, diff --git a/packages/core/src/file-upload/index.ts b/packages/core/src/file-upload/index.ts index 8ae3aa6cd0..7c423adab0 100644 --- a/packages/core/src/file-upload/index.ts +++ b/packages/core/src/file-upload/index.ts @@ -10,7 +10,12 @@ export { getActiveFileUploadProvider, uploadFile, } from "./registry.js"; -export { builderFileUploadProvider } from "./builder.js"; +export { + builderFileUploadProvider, + requestBuilderResumableSession, + completeBuilderResumableUpload, + type BuilderResumableSession, +} from "./builder.js"; export { preUploadImageAttachments, preUploadAttachments, diff --git a/templates/clips/actions/create-recording.ts b/templates/clips/actions/create-recording.ts index 9c923219ff..1a7b97fe0a 100644 --- a/templates/clips/actions/create-recording.ts +++ b/templates/clips/actions/create-recording.ts @@ -75,6 +75,11 @@ export default defineAction({ console.log(`Created recording "${title}" (${id})`); + // Signal that the browser may use the GCS resumable upload path instead of + // the chunk-through-app-server path. Only available when Builder.io is the + // configured storage provider (it's the source of the resumable session URIs). + const supportsResumableUpload = !!process.env.BUILDER_PRIVATE_KEY; + return { id, organizationId, @@ -83,6 +88,7 @@ export default defineAction({ abortUrl: `/api/uploads/${id}/abort`, // Frontend substitutes {index}/{total}/{isFinal} uploadChunkUrlTemplate: `/api/uploads/${id}/chunk?index={index}&total={total}&isFinal={isFinal}`, + supportsResumableUpload, }; }, }); diff --git a/templates/clips/actions/finalize-recording.ts b/templates/clips/actions/finalize-recording.ts index 6f483c3f11..87229f8988 100644 --- a/templates/clips/actions/finalize-recording.ts +++ b/templates/clips/actions/finalize-recording.ts @@ -126,6 +126,16 @@ export default defineAction({ .string() .optional() .describe("MIME type of the assembled blob (e.g. video/webm)"), + videoUrl: z + .string() + .optional() + .describe( + "Pre-uploaded video URL (GCS direct upload path). When provided, chunk assembly and server-side upload are skipped.", + ), + videoSizeBytes: z + .number() + .optional() + .describe("Byte size of the uploaded video file (required with videoUrl)"), }), run: async (args) => { const db = getDb(); @@ -197,6 +207,84 @@ export default defineAction({ ? args.hasCamera : (stateBoolean(uploadState, "hasCamera") ?? existing.hasCamera); + // Direct upload bypass: the video was already uploaded to GCS by the + // browser via a resumable session. Skip chunk assembly and uploadFile(). + if (args.videoUrl) { + const videoUrl = args.videoUrl; + const videoSizeBytes = args.videoSizeBytes ?? 0; + const now = new Date().toISOString(); + debugLog("[finalize] direct upload bypass", { id, videoUrl }); + + await db + .update(schema.recordings) + .set({ + status: "ready", + videoUrl, + videoFormat, + videoSizeBytes, + durationMs: finalDurationMs, + width: finalWidth, + height: finalHeight, + hasAudio: finalHasAudio, + hasCamera: finalHasCamera, + failureReason: null, + uploadProgress: 100, + updatedAt: now, + }) + .where(eq(schema.recordings.id, id)); + + const [existingTranscript] = await db + .select({ recordingId: schema.recordingTranscripts.recordingId }) + .from(schema.recordingTranscripts) + .where(eq(schema.recordingTranscripts.recordingId, id)); + if (!existingTranscript) { + await db.insert(schema.recordingTranscripts).values({ + recordingId: id, + ownerEmail, + status: "pending", + createdAt: now, + updatedAt: now, + }); + } + + await writeAppState(`recording-upload-${id}`, { + recordingId: id, + status: "ready", + progress: 100, + videoUrl, + finishedAt: now, + }); + await writeAppState("refresh-signal", { ts: Date.now() }); + + void Promise.resolve( + requestTranscript.run({ recordingId: id, force: true }), + ).catch((err: unknown) => { + console.error("[finalize] background transcript failed", { + id, + error: err instanceof Error ? err.message : String(err), + }); + }); + + try { + emit( + "clip.created", + { + clipId: id, + title: existing.title, + createdBy: ownerEmail, + duration: finalDurationMs, + url: videoUrl, + }, + { owner: ownerEmail }, + ); + } catch (err) { + console.warn("[finalize] clip.created emit failed:", err); + } + + debugLog("[finalize] direct upload done", { id, videoUrl }); + return { id, status: "ready" as const, videoUrl, videoSizeBytes, durationMs: finalDurationMs }; + } + // The recorder stashes compression metadata at // `recording-compression-{id}` when its browser-side ffmpeg.wasm // pass ran to bring the assembled blob under Builder.io's 100 MB diff --git a/templates/clips/app/components/recorder/recorder-engine.ts b/templates/clips/app/components/recorder/recorder-engine.ts index 828d3880d1..b64ef2274a 100644 --- a/templates/clips/app/components/recorder/recorder-engine.ts +++ b/templates/clips/app/components/recorder/recorder-engine.ts @@ -81,6 +81,13 @@ export interface RecorderEngineOptions { uploadUrl?: string; /** Abort URL. Default `/api/uploads/:id/abort`. */ abortUrl?: string; + /** + * When true, the engine uploads the assembled blob directly to GCS via a + * resumable session (no per-chunk app-server hop). Requires the + * /api/uploads/:id/init-resumable and /api/uploads/:id/complete routes. + * Falls back to the chunk path on any init error. + */ + useResumableUpload?: boolean; /** Fired whenever the state machine transitions. */ onState?: (state: RecorderState, detail?: Record) => void; /** Fired on each uploaded chunk (for progress UI). */ @@ -748,10 +755,12 @@ export class RecorderEngine { recordingId: string; uploadUrl: string; abortUrl: string; + useResumableUpload?: boolean; }): void { this.opts.recordingId = target.recordingId; this.opts.uploadUrl = target.uploadUrl; this.opts.abortUrl = target.abortUrl; + this.opts.useResumableUpload = target.useResumableUpload ?? false; } // ------------------------------------------------------------------------- @@ -1013,11 +1022,23 @@ export class RecorderEngine { try { if ( COMPRESSION_ENABLED && - this.totalRecordedBytes > COMPRESS_THRESHOLD_BYTES + this.totalRecordedBytes > COMPRESS_THRESHOLD_BYTES && + !this.opts.useResumableUpload ) { // Compress before the first server upload so large recordings don't // stage their uncompressed source in SQL. + // Compression is skipped on the resumable path — GCS can accept files + // of any size and there is no per-chunk app-server payload limit. result = await this.compressAndReupload(finalizeMeta); + } else if (this.opts.useResumableUpload && !this.streamChunksDuringRecording) { + this.transition("uploading", { progress: 0 }); + const assembled = new Blob(this.localChunks, { type: this.mimeType }); + result = await this.uploadBlobResumable( + assembled, + this.mimeType, + finalizeMeta, + this.uploadAbort?.signal, + ); } else if (!this.streamChunksDuringRecording) { this.transition("uploading", { progress: 0 }); const assembled = new Blob(this.localChunks, { type: this.mimeType }); @@ -1500,6 +1521,159 @@ export class RecorderEngine { } } + /** + * Upload the assembled recording blob directly to GCS via a resumable + * session URI, bypassing the app server chunk route. + * + * Flow: + * 1. POST /api/uploads/:id/init-resumable → get resumableSessionUri + assetId + * 2. PUT chunks directly to GCS with Content-Range headers + * 3. POST /api/uploads/:id/complete → Builder registers asset, DB updated + */ + private async uploadBlobResumable( + blob: Blob, + mimeType: string, + meta: { + durationMs: number; + dimensions: { width: number; height: number }; + hasAudio: boolean; + hasCamera: boolean; + }, + signal?: AbortSignal, + ): Promise | undefined> { + const { recordingId } = this.opts; + const baseMime = mimeType.split(";")[0].trim() || "video/webm"; + const ext = baseMime.includes("mp4") || baseMime.includes("quicktime") ? "mp4" : "webm"; + const filename = `${recordingId}.${ext}`; + + // Step 1 — init resumable session on the server. + const initRes = await fetch( + `${appBasePath()}/api/uploads/${recordingId}/init-resumable`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ size: blob.size, mimeType: baseMime, filename }), + signal, + }, + ); + + if (!initRes.ok) { + const text = await initRes.text().catch(() => ""); + throw new Error( + `Resumable session init failed (${initRes.status}): ${text || initRes.statusText}`, + ); + } + + const { resumableSessionUri, assetId } = (await initRes.json()) as { + resumableSessionUri?: string; + assetId?: string; + }; + + if (!resumableSessionUri || !assetId) { + throw new Error("init-resumable returned incomplete session data"); + } + + // Step 2 — PUT chunks directly to GCS. + // GCS requires non-final chunk sizes to be a multiple of 256 KB. + const GCS_CHUNK_SIZE = 8 * 256 * 1024; // 8 × 256 KB = 2 MB + const totalChunks = Math.max(1, Math.ceil(blob.size / GCS_CHUNK_SIZE)); + + for (let i = 0; i < totalChunks; i++) { + if (signal?.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new Error("Upload aborted"); + } + + const start = i * GCS_CHUNK_SIZE; + const end = Math.min(start + GCS_CHUNK_SIZE, blob.size); + const chunk = blob.slice(start, end, baseMime); + const isFinal = end >= blob.size; + + // GCS Content-Range: bytes start-end/total (final) or bytes start-end/* (non-final) + const rangeHeader = isFinal + ? `bytes ${start}-${end - 1}/${blob.size}` + : `bytes ${start}-${end - 1}/*`; + + let chunkRes: Response | null = null; + for (let attempt = 1; attempt <= CHUNK_UPLOAD_MAX_ATTEMPTS; attempt++) { + try { + chunkRes = await fetch(resumableSessionUri, { + method: "PUT", + headers: { + "Content-Range": rangeHeader, + "Content-Type": baseMime, + }, + body: chunk, + signal, + }); + } catch (err) { + if ( + attempt >= CHUNK_UPLOAD_MAX_ATTEMPTS || + (err as { name?: string } | null)?.name === "AbortError" + ) { + throw err; + } + await waitForRetry(retryDelayMs(attempt), signal); + continue; + } + + // GCS returns 308 for intermediate chunks, 200/201 for the final one. + const ok = isFinal ? chunkRes.ok : chunkRes.status === 308 || chunkRes.ok; + if (!ok) { + if ( + !isFinal && + attempt < CHUNK_UPLOAD_MAX_ATTEMPTS && + isRetryableChunkUploadStatus(chunkRes.status) + ) { + await chunkRes.text().catch(() => ""); + await waitForRetry(retryDelayMs(attempt), signal); + continue; + } + const text = await chunkRes.text().catch(() => ""); + throw new Error( + `GCS chunk ${i} upload failed (${chunkRes.status}): ${text || chunkRes.statusText}`, + ); + } + break; + } + + this.opts.onChunk?.({ index: i, bytes: chunk.size, total: totalChunks }); + } + + // Step 3 — notify the server that GCS upload is complete. + const completeRes = await fetch( + `${appBasePath()}/api/uploads/${recordingId}/complete`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + assetId, + filename, + mimeType: baseMime, + videoSizeBytes: blob.size, + durationMs: meta.durationMs, + width: meta.dimensions.width, + height: meta.dimensions.height, + hasAudio: meta.hasAudio, + hasCamera: meta.hasCamera, + }), + signal, + }, + ); + + if (!completeRes.ok) { + const text = await completeRes.text().catch(() => ""); + throw new Error( + `Resumable upload complete failed (${completeRes.status}): ${text || completeRes.statusText}`, + ); + } + + return (await completeRes.json().catch(() => undefined)) as + | Record + | undefined; + } + private async uploadBlobInSlices( blob: Blob, mimeType: string, diff --git a/templates/clips/app/routes/record.tsx b/templates/clips/app/routes/record.tsx index dffc5954f8..110855f095 100644 --- a/templates/clips/app/routes/record.tsx +++ b/templates/clips/app/routes/record.tsx @@ -549,6 +549,7 @@ interface PendingRecording { id: string; uploadChunkUrl: string; abortUrl: string; + supportsResumableUpload?: boolean; } function PreRecordPanelSkeleton() { @@ -1142,10 +1143,12 @@ export default function RecordRoute() { id: string; uploadChunkUrl: string; abortUrl: string; + supportsResumableUpload?: boolean; }; id?: string; uploadChunkUrl?: string; abortUrl?: string; + supportsResumableUpload?: boolean; }; const info = created.result ?? (created as PendingRecording); if (!info?.id) { @@ -1173,6 +1176,7 @@ export default function RecordRoute() { recordingId: info.id, uploadUrl: uploadChunkUrl, abortUrl, + useResumableUpload: info.supportsResumableUpload ?? false, }); setPreviewStream(ps); diff --git a/templates/clips/server/routes/api/uploads/[recordingId]/complete.post.ts b/templates/clips/server/routes/api/uploads/[recordingId]/complete.post.ts new file mode 100644 index 0000000000..42e853205d --- /dev/null +++ b/templates/clips/server/routes/api/uploads/[recordingId]/complete.post.ts @@ -0,0 +1,138 @@ +/** + * Complete a direct-to-GCS resumable upload. + * + * Called by the browser after all chunks have been successfully PUT to GCS. + * Registers the upload with Builder.io to get the CDN URL, then finalizes + * the recording row (status → ready, triggers transcription). + * + * Route: POST /api/uploads/:recordingId/complete + * Body: { + * assetId: string, + * mimeType?: string, + * filename?: string, + * durationMs?: number, + * width?: number, + * height?: number, + * hasAudio?: boolean, + * hasCamera?: boolean, + * videoSizeBytes?: number + * } + */ + +import { + createError, + defineEventHandler, + getRouterParam, + readBody, + setResponseStatus, + type H3Event, +} from "h3"; +import { and, eq } from "drizzle-orm"; +import { getDb, schema } from "../../../../db/index.js"; +import { getEventOwnerContext } from "../../../../lib/recordings.js"; +import { runWithRequestContext } from "@agent-native/core/server"; +import { completeBuilderResumableUpload } from "@agent-native/core/file-upload"; +import finalizeRecording from "../../../../../actions/finalize-recording.js"; + +export default defineEventHandler(async (event: H3Event) => { + const recordingId = getRouterParam(event, "recordingId"); + if (!recordingId) { + throw createError({ statusCode: 400, message: "Missing recordingId" }); + } + + let ownerEmail: string; + let orgId: string | undefined; + try { + const ctx = await getEventOwnerContext(event); + ownerEmail = ctx.userEmail; + orgId = ctx.orgId; + } catch { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + return runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => { + const [recording] = await getDb() + .select({ id: schema.recordings.id, status: schema.recordings.status }) + .from(schema.recordings) + .where( + and( + eq(schema.recordings.id, recordingId), + eq(schema.recordings.ownerEmail, ownerEmail), + ), + ); + + if (!recording) { + throw createError({ statusCode: 404, message: "Recording not found" }); + } + + const body = (await readBody(event).catch(() => null)) as { + assetId?: unknown; + mimeType?: unknown; + filename?: unknown; + durationMs?: unknown; + width?: unknown; + height?: unknown; + hasAudio?: unknown; + hasCamera?: unknown; + videoSizeBytes?: unknown; + } | null; + + if (typeof body?.assetId !== "string" || !body.assetId.trim()) { + throw createError({ statusCode: 400, message: "Missing assetId" }); + } + + const assetId = body.assetId.trim(); + const rawMime = + typeof body.mimeType === "string" ? body.mimeType.trim() : "video/webm"; + const baseMime = rawMime.split(";")[0].trim().toLowerCase(); + const ext = baseMime.includes("mp4") || baseMime.includes("quicktime") + ? "mp4" + : "webm"; + const filename = + typeof body.filename === "string" && body.filename.trim() + ? body.filename.trim() + : `${recordingId}.${ext}`; + + let videoUrl: string; + try { + videoUrl = await completeBuilderResumableUpload(assetId, filename); + } catch (err) { + console.error("[complete] Builder.io complete failed:", err); + setResponseStatus(event, 502); + return { + ok: false, + error: + err instanceof Error ? err.message : "Failed to complete upload", + }; + } + + try { + const result = await finalizeRecording.run({ + id: recordingId, + videoUrl, + videoSizeBytes: + typeof body.videoSizeBytes === "number" + ? body.videoSizeBytes + : undefined, + durationMs: + typeof body.durationMs === "number" ? body.durationMs : undefined, + width: typeof body.width === "number" ? body.width : undefined, + height: typeof body.height === "number" ? body.height : undefined, + hasAudio: + typeof body.hasAudio === "boolean" ? body.hasAudio : undefined, + hasCamera: + typeof body.hasCamera === "boolean" ? body.hasCamera : undefined, + mimeType: rawMime || undefined, + }); + + return { ok: true, finalized: true, ...result }; + } catch (err) { + console.error("[complete] finalize-recording failed:", err); + setResponseStatus(event, 500); + return { + ok: false, + error: err instanceof Error ? err.message : "Finalize failed", + }; + } + }); +}); diff --git a/templates/clips/server/routes/api/uploads/[recordingId]/init-resumable.post.ts b/templates/clips/server/routes/api/uploads/[recordingId]/init-resumable.post.ts new file mode 100644 index 0000000000..b70aca6dbd --- /dev/null +++ b/templates/clips/server/routes/api/uploads/[recordingId]/init-resumable.post.ts @@ -0,0 +1,113 @@ +/** + * Initiate a GCS resumable upload session for a recording. + * + * Called by the browser after recording stops (when the full blob size is + * known). Returns a `resumableSessionUri` the browser can PUT chunks to + * directly — no app-server hop per chunk. The session URI carries its own + * GCS auth so no Authorization header is needed on the PUT requests. + * + * GCS chunk rules (enforced by the browser): + * - Non-final chunks: PUT Content-Range: bytes start-end/* (multiple of 256 KB) + * - Final chunk: PUT Content-Range: bytes start-end/totalSize + * - GCS returns 308 Resume Incomplete for non-final, 200/201 for final + * + * Route: POST /api/uploads/:recordingId/init-resumable + * Body: { size: number, mimeType: string, filename?: string } + * Returns: { resumableSessionUri: string, assetId: string } + */ + +import { + createError, + defineEventHandler, + getRouterParam, + readBody, + setResponseStatus, + type H3Event, +} from "h3"; +import { and, eq } from "drizzle-orm"; +import { getDb, schema } from "../../../../db/index.js"; +import { getEventOwnerContext } from "../../../../lib/recordings.js"; +import { runWithRequestContext } from "@agent-native/core/server"; +import { requestBuilderResumableSession } from "@agent-native/core/file-upload"; + +const ALLOWED_MIME_TYPES = new Set([ + "video/webm", + "video/mp4", + "video/quicktime", +]); + +export default defineEventHandler(async (event: H3Event) => { + const recordingId = getRouterParam(event, "recordingId"); + if (!recordingId) { + throw createError({ statusCode: 400, message: "Missing recordingId" }); + } + + let ownerEmail: string; + let orgId: string | undefined; + try { + const ctx = await getEventOwnerContext(event); + ownerEmail = ctx.userEmail; + orgId = ctx.orgId; + } catch { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + return runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => { + const [recording] = await getDb() + .select({ id: schema.recordings.id, status: schema.recordings.status }) + .from(schema.recordings) + .where( + and( + eq(schema.recordings.id, recordingId), + eq(schema.recordings.ownerEmail, ownerEmail), + ), + ); + + if (!recording) { + throw createError({ statusCode: 404, message: "Recording not found" }); + } + + const body = (await readBody(event).catch(() => null)) as { + size?: unknown; + mimeType?: unknown; + filename?: unknown; + } | null; + + const size = typeof body?.size === "number" ? body.size : NaN; + if (!Number.isFinite(size) || size <= 0) { + throw createError({ + statusCode: 400, + message: "size must be a positive number", + }); + } + + const rawMime = + typeof body?.mimeType === "string" ? body.mimeType.trim() : "video/webm"; + const baseMime = rawMime.split(";")[0].trim().toLowerCase(); + if (!ALLOWED_MIME_TYPES.has(baseMime)) { + throw createError({ statusCode: 400, message: "Unsupported mimeType" }); + } + + const ext = baseMime.includes("mp4") || baseMime.includes("quicktime") + ? "mp4" + : "webm"; + const filename = + typeof body?.filename === "string" && body.filename.trim() + ? body.filename.trim() + : `${recordingId}.${ext}`; + + try { + const session = await requestBuilderResumableSession(filename, baseMime, size); + return session; + } catch (err) { + console.error("[init-resumable] session request failed:", err); + setResponseStatus(event, 502); + return { + error: + err instanceof Error + ? err.message + : "Failed to create resumable upload session", + }; + } + }); +});