Skip to content

Commit 0bb8cca

Browse files
committed
Add support for aborting multipart uploads
1 parent 1aa82d1 commit 0bb8cca

File tree

3 files changed

+112
-4
lines changed

3 files changed

+112
-4
lines changed

apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/instant-mp4-uploader.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ const completeMultipartUpload = async (
8585
});
8686
};
8787

88+
const abortMultipartUpload = async (videoId: VideoId, uploadId: string) => {
89+
await postJson<{ success: boolean }>("/api/upload/multipart/abort", {
90+
videoId,
91+
uploadId,
92+
});
93+
};
94+
8895
interface FinalizeOptions extends MultipartCompletePayload {
8996
finalBlob: Blob;
9097
thumbnailUrl?: string;
@@ -367,13 +374,18 @@ export class InstantMp4Uploader {
367374

368375
async cancel() {
369376
if (this.finished) return;
377+
this.finished = true;
370378
this.bufferedChunks = [];
371379
this.bufferedBytes = 0;
372380
this.clearChunkStates();
373-
try {
374-
await this.uploadPromise;
375-
} catch {
381+
const pendingUpload = this.uploadPromise.catch(() => {
376382
// Swallow errors during cancellation cleanup.
383+
});
384+
try {
385+
await abortMultipartUpload(this.videoId, this.uploadId);
386+
} catch (error) {
387+
console.error("Failed to abort multipart upload", error);
377388
}
389+
await pendingUpload;
378390
}
379391
}

apps/web/app/api/upload/[...route]/multipart.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,3 +482,77 @@ app.post(
482482
}).pipe(Effect.provide(makeCurrentUserLayer(user)), runPromise);
483483
},
484484
);
485+
486+
app.post(
487+
"/abort",
488+
zValidator(
489+
"json",
490+
z
491+
.object({
492+
uploadId: z.string(),
493+
})
494+
.and(
495+
z.union([
496+
z.object({ videoId: z.string() }),
497+
// deprecated
498+
z.object({ fileKey: z.string() }),
499+
]),
500+
),
501+
),
502+
(c) => {
503+
const { uploadId, ...body } = c.req.valid("json");
504+
const user = c.get("user");
505+
506+
const fileKey = parseVideoIdOrFileKey(user.id, {
507+
...body,
508+
subpath: "result.mp4",
509+
});
510+
511+
const videoIdFromFileKey = fileKey.split("/")[1];
512+
const videoIdRaw = "videoId" in body ? body.videoId : videoIdFromFileKey;
513+
if (!videoIdRaw) return c.text("Video id not found", 400);
514+
const videoId = Video.VideoId.make(videoIdRaw);
515+
516+
return Effect.gen(function* () {
517+
const repo = yield* VideosRepo;
518+
const policy = yield* VideosPolicy;
519+
const db = yield* Database;
520+
521+
const maybeVideo = yield* repo
522+
.getById(videoId)
523+
.pipe(Policy.withPolicy(policy.isOwner(videoId)));
524+
if (Option.isNone(maybeVideo)) {
525+
c.status(404);
526+
return c.text(`Video '${encodeURIComponent(videoId)}' not found`);
527+
}
528+
const [video] = maybeVideo.value;
529+
530+
const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId);
531+
532+
console.log(`Aborting multipart upload ${uploadId} for key: ${fileKey}`);
533+
yield* bucket.multipart.abort(fileKey, uploadId);
534+
535+
yield* db.use((db) =>
536+
db.delete(Db.videoUploads).where(eq(Db.videoUploads.videoId, videoId)),
537+
);
538+
539+
return c.json({ success: true, fileKey, uploadId });
540+
}).pipe(
541+
Effect.catchAll((error) => {
542+
console.error("Failed to abort multipart upload:", error);
543+
544+
return Effect.succeed(
545+
c.json(
546+
{
547+
error: "Failed to abort multipart upload",
548+
details: error instanceof Error ? error.message : String(error),
549+
},
550+
500,
551+
),
552+
);
553+
}),
554+
Effect.provide(makeCurrentUserLayer(user)),
555+
runPromise,
556+
);
557+
},
558+
);

packages/web-backend/src/S3Buckets/S3BucketAccess.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export const createS3BucketAccess = Effect.gen(function* () {
107107
provider.getInternal.pipe(
108108
Effect.flatMap((client) =>
109109
Effect.gen(function* () {
110-
let _body;
110+
let _body: S3.PutObjectCommandInput["Body"];
111111

112112
if (typeof body === "string" || body instanceof Uint8Array) {
113113
_body = body;
@@ -284,6 +284,28 @@ export const createS3BucketAccess = Effect.gen(function* () {
284284
),
285285
),
286286
),
287+
abort: (
288+
key: string,
289+
uploadId: string,
290+
args?: Omit<
291+
S3.AbortMultipartUploadCommandInput,
292+
"Key" | "Bucket" | "UploadId"
293+
>,
294+
) =>
295+
wrapS3Promise(
296+
provider.getInternal.pipe(
297+
Effect.map((client) =>
298+
client.send(
299+
new S3.AbortMultipartUploadCommand({
300+
Bucket: provider.bucket,
301+
Key: key,
302+
UploadId: uploadId,
303+
...args,
304+
}),
305+
),
306+
),
307+
),
308+
),
287309
},
288310
};
289311
});

0 commit comments

Comments
 (0)