@uploadbox/nextjs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/analytics.d.ts +11 -0
  2. package/dist/analytics.d.ts.map +1 -0
  3. package/dist/analytics.js +50 -0
  4. package/dist/analytics.js.map +1 -0
  5. package/dist/auth.d.ts +4 -0
  6. package/dist/auth.d.ts.map +1 -0
  7. package/dist/auth.js +46 -0
  8. package/dist/auth.js.map +1 -0
  9. package/dist/create-hosted-handler.d.ts +17 -0
  10. package/dist/create-hosted-handler.d.ts.map +1 -0
  11. package/dist/create-hosted-handler.js +13 -0
  12. package/dist/create-hosted-handler.js.map +1 -0
  13. package/dist/create-route-handler.d.ts +7 -0
  14. package/dist/create-route-handler.d.ts.map +1 -0
  15. package/dist/create-route-handler.js +469 -0
  16. package/dist/create-route-handler.js.map +1 -0
  17. package/dist/extract-router-config.d.ts +3 -0
  18. package/dist/extract-router-config.d.ts.map +1 -0
  19. package/dist/extract-router-config.js +8 -0
  20. package/dist/extract-router-config.js.map +1 -0
  21. package/dist/hosted-hooks.d.ts +9 -0
  22. package/dist/hosted-hooks.d.ts.map +1 -0
  23. package/dist/hosted-hooks.js +105 -0
  24. package/dist/hosted-hooks.js.map +1 -0
  25. package/dist/index.d.ts +10 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +7 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/processing-pipeline.d.ts +3 -0
  30. package/dist/processing-pipeline.d.ts.map +1 -0
  31. package/dist/processing-pipeline.js +68 -0
  32. package/dist/processing-pipeline.js.map +1 -0
  33. package/dist/quota.d.ts +5 -0
  34. package/dist/quota.d.ts.map +1 -0
  35. package/dist/quota.js +68 -0
  36. package/dist/quota.js.map +1 -0
  37. package/dist/rate-limiter.d.ts +17 -0
  38. package/dist/rate-limiter.d.ts.map +1 -0
  39. package/dist/rate-limiter.js +47 -0
  40. package/dist/rate-limiter.js.map +1 -0
  41. package/dist/types.d.ts +104 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +2 -0
  44. package/dist/types.js.map +1 -0
  45. package/dist/webhooks.d.ts +7 -0
  46. package/dist/webhooks.d.ts.map +1 -0
  47. package/dist/webhooks.js +151 -0
  48. package/dist/webhooks.js.map +1 -0
  49. package/package.json +53 -0
  50. package/src/create-hosted-handler.ts +27 -0
  51. package/src/create-route-handler.ts +654 -0
  52. package/src/extract-router-config.ts +9 -0
  53. package/src/hosted-hooks.ts +132 -0
  54. package/src/index.ts +19 -0
  55. package/src/processing-pipeline.ts +77 -0
  56. package/src/rate-limiter.ts +64 -0
  57. package/src/types.ts +129 -0
@@ -0,0 +1,654 @@
1
+ import {
2
+ type FileRouter,
3
+ type FileInfo,
4
+ type UploadboxConfig,
5
+ type AuthContext,
6
+ type CompletedPartInfo,
7
+ validateFiles,
8
+ generatePresignedPutUrl,
9
+ generateFileKey,
10
+ generateUploadId,
11
+ headObject,
12
+ createS3Client,
13
+ UploadboxError,
14
+ getFileTypeFromMime,
15
+ DEFAULT_PART_SIZE,
16
+ createMultipartUpload,
17
+ generatePresignedPartUrls,
18
+ completeMultipartUpload,
19
+ abortMultipartUpload,
20
+ } from "@uploadbox/core";
21
+ import { extractRouterConfig } from "./extract-router-config.js";
22
+ import { RateLimiter } from "./rate-limiter.js";
23
+ import { runProcessingPipeline } from "./processing-pipeline.js";
24
+ import type { RouteHandlerOpts, UploadStartedEvent } from "./types.js";
25
+
26
+ interface PendingUpload {
27
+ key: string;
28
+ name: string;
29
+ size: number;
30
+ type: string;
31
+ url: string;
32
+ acl: string;
33
+ routeKey: string;
34
+ metadata: Record<string, unknown>;
35
+ customMetadata?: Record<string, string> | null;
36
+ auth?: AuthContext;
37
+ resolvedConfig?: UploadboxConfig;
38
+ createdAtMs: number;
39
+ }
40
+
41
+ const PENDING_UPLOAD_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
42
+
43
+ function getConfig(override?: Partial<UploadboxConfig>): UploadboxConfig {
44
+ return {
45
+ region: override?.region ?? process.env.UPLOADBOX_AWS_REGION ?? "us-east-1",
46
+ bucket: override?.bucket ?? process.env.UPLOADBOX_S3_BUCKET ?? "",
47
+ accessKeyId: override?.accessKeyId ?? process.env.UPLOADBOX_AWS_ACCESS_KEY_ID ?? "",
48
+ secretAccessKey: override?.secretAccessKey ?? process.env.UPLOADBOX_AWS_SECRET_ACCESS_KEY ?? "",
49
+ cdnBaseUrl: override?.cdnBaseUrl ?? process.env.UPLOADBOX_CDN_BASE_URL,
50
+ endpoint: override?.endpoint ?? process.env.UPLOADBOX_S3_ENDPOINT,
51
+ forcePathStyle: override?.forcePathStyle ?? process.env.UPLOADBOX_S3_FORCE_PATH_STYLE === "true",
52
+ presignedUrlExpiry: override?.presignedUrlExpiry ?? (process.env.UPLOADBOX_PRESIGNED_URL_EXPIRY
53
+ ? parseInt(process.env.UPLOADBOX_PRESIGNED_URL_EXPIRY, 10)
54
+ : undefined),
55
+ };
56
+ }
57
+
58
+ function buildFileUrl(config: UploadboxConfig, key: string): string {
59
+ if (config.cdnBaseUrl) {
60
+ return `${config.cdnBaseUrl.replace(/\/$/, "")}/${key}`;
61
+ }
62
+ if (config.endpoint) {
63
+ const base = config.endpoint.replace(/\/$/, "");
64
+ if (config.forcePathStyle) {
65
+ return `${base}/${config.bucket}/${key}`;
66
+ }
67
+ return `${base}/${key}`;
68
+ }
69
+ return `https://${config.bucket}.s3.${config.region ?? "us-east-1"}.amazonaws.com/${key}`;
70
+ }
71
+
72
+ export function createRouteHandler<TRouter extends FileRouter>(
73
+ opts: RouteHandlerOpts<TRouter>
74
+ ): { GET: () => Promise<Response>; POST: (request: Request) => Promise<Response> } {
75
+ let _config: UploadboxConfig | undefined;
76
+ let _s3Client: ReturnType<typeof createS3Client> | undefined;
77
+ let _rateLimiter: RateLimiter | undefined;
78
+
79
+ const hooks = opts.hooks ?? {};
80
+
81
+ // In-memory store for pending uploads (between presign and complete)
82
+ const pendingUploads = new Map<string, PendingUpload>();
83
+
84
+ function cleanupExpiredPendingUploads() {
85
+ const now = Date.now();
86
+ for (const [key, pending] of pendingUploads.entries()) {
87
+ if (now - pending.createdAtMs > PENDING_UPLOAD_MAX_AGE_MS) {
88
+ pendingUploads.delete(key);
89
+ }
90
+ }
91
+ }
92
+
93
+ function getConfigLazy() {
94
+ if (!_config) _config = getConfig(opts.config);
95
+ return _config;
96
+ }
97
+ function getS3() {
98
+ if (!_s3Client) _s3Client = createS3Client(getConfigLazy());
99
+ return _s3Client;
100
+ }
101
+ function makeS3(config: UploadboxConfig): ReturnType<typeof createS3Client> {
102
+ if (opts.s3ClientFactory) {
103
+ return opts.s3ClientFactory(config) as ReturnType<typeof createS3Client>;
104
+ }
105
+ return createS3Client(config);
106
+ }
107
+ function getRateLimiter() {
108
+ if (!_rateLimiter && opts.rateLimit) _rateLimiter = new RateLimiter(opts.rateLimit);
109
+ return _rateLimiter;
110
+ }
111
+
112
+ /** Resolve per-request S3 config + client via hook, or fall back to global defaults. */
113
+ async function resolveRequestConfig(auth?: AuthContext): Promise<{
114
+ config: UploadboxConfig;
115
+ s3Client: ReturnType<typeof createS3Client>;
116
+ keyPrefix: string;
117
+ }> {
118
+ if (hooks.onResolveConfig) {
119
+ const resolved = await hooks.onResolveConfig(auth);
120
+ if (resolved) {
121
+ return {
122
+ config: resolved.config,
123
+ s3Client: makeS3(resolved.config),
124
+ keyPrefix: resolved.keyPrefix ?? "",
125
+ };
126
+ }
127
+ }
128
+ return { config: getConfigLazy(), s3Client: getS3(), keyPrefix: "" };
129
+ }
130
+
131
+ async function GET() {
132
+ cleanupExpiredPendingUploads();
133
+ const routerConfig = extractRouterConfig(opts.router);
134
+ return Response.json(routerConfig);
135
+ }
136
+
137
+ async function POST(request: Request) {
138
+ try {
139
+ cleanupExpiredPendingUploads();
140
+ const body = (await request.json()) as { action: string; [key: string]: unknown };
141
+ const { action } = body;
142
+
143
+ // Authentication via lifecycle hook
144
+ let auth: AuthContext | undefined;
145
+ if (hooks.onAuthenticate) {
146
+ auth = await hooks.onAuthenticate(request);
147
+ }
148
+
149
+ // Request-level rate limiting
150
+ const rateLimiter = getRateLimiter();
151
+ if (rateLimiter) {
152
+ const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
153
+ const key = auth?.apiKeyId ?? clientIp;
154
+ const result = rateLimiter.check(key, "request");
155
+ if (!result.allowed) {
156
+ throw UploadboxError.rateLimited(result.retryAfter);
157
+ }
158
+ }
159
+
160
+ switch (action) {
161
+ case "upload":
162
+ return handleUpload(request, body, auth);
163
+ case "complete":
164
+ return handleComplete(body, auth);
165
+ case "create-multipart":
166
+ return handleCreateMultipart(request, body, auth);
167
+ case "presign-parts":
168
+ return handlePresignParts(body);
169
+ case "complete-multipart":
170
+ return handleCompleteMultipart(body, auth);
171
+ case "abort-multipart":
172
+ return handleAbortMultipart(body);
173
+ default:
174
+ return Response.json(
175
+ { error: "INVALID_ACTION", message: `Unknown action: ${action}` },
176
+ { status: 400 }
177
+ );
178
+ }
179
+ } catch (err) {
180
+ if (err instanceof UploadboxError) {
181
+ return Response.json(err.toJSON(), { status: err.statusCode });
182
+ }
183
+ console.error("[uploadbox] Unexpected error:", err);
184
+ return Response.json(
185
+ { error: "INTERNAL_ERROR", message: "An unexpected error occurred" },
186
+ { status: 500 }
187
+ );
188
+ }
189
+ }
190
+
191
+ async function handleUpload(request: Request, body: Record<string, unknown>, auth?: AuthContext) {
192
+ const { routeKey, files: fileInfos } = body as {
193
+ routeKey: string;
194
+ files: FileInfo[];
195
+ };
196
+
197
+ const route = opts.router[routeKey];
198
+ if (!route) {
199
+ throw UploadboxError.routeNotFound(routeKey);
200
+ }
201
+
202
+ validateFiles(fileInfos, route._config);
203
+
204
+ // Upload-specific rate limiting
205
+ const rateLimiter = getRateLimiter();
206
+ if (rateLimiter) {
207
+ const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
208
+ const key = auth?.apiKeyId ?? clientIp;
209
+ const result = rateLimiter.check(key, "upload");
210
+ if (!result.allowed) {
211
+ throw UploadboxError.rateLimited(result.retryAfter);
212
+ }
213
+ }
214
+
215
+ // Quota check via lifecycle hook
216
+ if (hooks.onQuotaCheck) {
217
+ const totalSize = fileInfos.reduce((sum, f) => sum + f.size, 0);
218
+ await hooks.onQuotaCheck({ auth, totalSize, fileCount: fileInfos.length });
219
+ }
220
+
221
+ const metadata = await route._middleware({ req: request, files: fileInfos, auth });
222
+
223
+ const { config, s3Client, keyPrefix } = await resolveRequestConfig(auth);
224
+
225
+ const results: { uploadId: string; key: string; url: string; name: string; size: number; type: string }[] = [];
226
+ const startedEvents: UploadStartedEvent[] = [];
227
+
228
+ for (const file of fileInfos) {
229
+ const key = keyPrefix + generateFileKey(file.name);
230
+ const uploadId = generateUploadId();
231
+
232
+ const detectedFileType = getFileTypeFromMime(file.type);
233
+ const routeTypeConfig = detectedFileType ? route._config[detectedFileType] : undefined;
234
+ const expiresIn = routeTypeConfig?.presignedUrlExpiry ?? config.presignedUrlExpiry ?? 3600;
235
+
236
+ const presignedUrl = await generatePresignedPutUrl(
237
+ s3Client,
238
+ config.bucket,
239
+ key,
240
+ file.type,
241
+ file.size,
242
+ expiresIn
243
+ );
244
+
245
+ const ttl = file.ttlSeconds ?? routeTypeConfig?.defaultTtlSeconds;
246
+ const maxTtl = routeTypeConfig?.maxTtlSeconds;
247
+ const effectiveTtl = ttl != null && maxTtl != null ? Math.min(ttl, maxTtl) : ttl;
248
+ const expiresAt = effectiveTtl != null ? new Date(Date.now() + effectiveTtl * 1000) : null;
249
+
250
+ const url = buildFileUrl(config, key);
251
+
252
+ // Store in memory for the complete step
253
+ pendingUploads.set(key, {
254
+ key,
255
+ name: file.name,
256
+ size: file.size,
257
+ type: file.type,
258
+ url,
259
+ acl: routeTypeConfig?.acl ?? "public-read",
260
+ routeKey,
261
+ metadata: metadata as Record<string, unknown>,
262
+ customMetadata: file.customMetadata ?? null,
263
+ auth,
264
+ resolvedConfig: config,
265
+ createdAtMs: Date.now(),
266
+ });
267
+
268
+ startedEvents.push({
269
+ key,
270
+ name: file.name,
271
+ size: file.size,
272
+ type: file.type,
273
+ url,
274
+ routeKey,
275
+ auth,
276
+ metadata: metadata as Record<string, unknown>,
277
+ customMetadata: file.customMetadata ?? null,
278
+ expiresAt,
279
+ });
280
+
281
+ results.push({
282
+ uploadId,
283
+ key,
284
+ url: presignedUrl,
285
+ name: file.name,
286
+ size: file.size,
287
+ type: file.type,
288
+ });
289
+ }
290
+
291
+ // Fire-and-forget: notify lifecycle hook
292
+ if (hooks.onUploadStarted && startedEvents.length > 0) {
293
+ hooks.onUploadStarted(startedEvents).catch(console.error);
294
+ }
295
+
296
+ return Response.json(results);
297
+ }
298
+
299
+ async function handleComplete(body: Record<string, unknown>, auth?: AuthContext) {
300
+ const { routeKey, keys } = body as { routeKey: string; keys: string[] };
301
+
302
+ const route = opts.router[routeKey];
303
+ if (!route) {
304
+ throw UploadboxError.routeNotFound(routeKey);
305
+ }
306
+
307
+ // Resolve config for this request (used as default for keys without stored config)
308
+ const requestCtx = await resolveRequestConfig(auth);
309
+
310
+ const results = await Promise.all(
311
+ keys.map(async (key) => {
312
+ // Get file data from in-memory store, or fall back to lifecycle hook
313
+ let pending = pendingUploads.get(key);
314
+ if (!pending && hooks.onFileVerified) {
315
+ const verified = await hooks.onFileVerified(key);
316
+ if (verified) {
317
+ pending = {
318
+ key: verified.key,
319
+ name: verified.name,
320
+ size: verified.size,
321
+ type: verified.type,
322
+ url: verified.url,
323
+ acl: verified.acl,
324
+ routeKey,
325
+ metadata: verified.metadata,
326
+ customMetadata: verified.customMetadata ?? null,
327
+ auth,
328
+ createdAtMs: Date.now(),
329
+ };
330
+ }
331
+ }
332
+
333
+ // Use stored config from pending upload if available, otherwise request-level
334
+ const config = pending?.resolvedConfig ?? requestCtx.config;
335
+ const s3Client = pending?.resolvedConfig ? makeS3(pending.resolvedConfig) : requestCtx.s3Client;
336
+
337
+ const s3Head = await headObject(s3Client, config.bucket, key);
338
+ if (!s3Head) {
339
+ // Notify failure
340
+ if (hooks.onUploadFailed) {
341
+ hooks.onUploadFailed({ key, routeKey, auth }).catch(console.error);
342
+ }
343
+ throw UploadboxError.uploadFailed(`File not found in storage: ${key}`);
344
+ }
345
+
346
+ if (!pending) {
347
+ throw UploadboxError.uploadFailed(`File record not found: ${key}`);
348
+ }
349
+
350
+ // Clean up in-memory state
351
+ pendingUploads.delete(key);
352
+
353
+ const fileData = {
354
+ key: pending.key,
355
+ name: pending.name,
356
+ size: pending.size,
357
+ type: pending.type,
358
+ url: pending.url,
359
+ acl: pending.acl as "public-read" | "private",
360
+ customMetadata: (pending.customMetadata as Record<string, string>) ?? undefined,
361
+ };
362
+
363
+ const serverData = await route._onUploadComplete({
364
+ metadata: pending.metadata,
365
+ file: fileData,
366
+ });
367
+
368
+ // Fire-and-forget: notify lifecycle hook
369
+ if (hooks.onUploadCompleted) {
370
+ hooks.onUploadCompleted({
371
+ file: fileData,
372
+ routeKey,
373
+ auth: pending.auth,
374
+ metadata: pending.metadata,
375
+ serverData,
376
+ }).catch(console.error);
377
+ }
378
+
379
+ // Run processing pipeline (non-blocking)
380
+ if (opts.processing) {
381
+ runProcessingPipeline(
382
+ opts.processing,
383
+ fileData,
384
+ pending.metadata,
385
+ s3Client,
386
+ config
387
+ ).catch((err) => {
388
+ console.error("[uploadbox] Processing pipeline error:", err);
389
+ });
390
+ }
391
+
392
+ return { file: fileData, serverData };
393
+ })
394
+ );
395
+
396
+ return Response.json(results);
397
+ }
398
+
399
+ async function handleCreateMultipart(request: Request, body: Record<string, unknown>, auth?: AuthContext) {
400
+ const { routeKey, file: fileInfo } = body as {
401
+ routeKey: string;
402
+ file: FileInfo;
403
+ };
404
+
405
+ const route = opts.router[routeKey];
406
+ if (!route) {
407
+ throw UploadboxError.routeNotFound(routeKey);
408
+ }
409
+
410
+ validateFiles([fileInfo], route._config);
411
+
412
+ const metadata = await route._middleware({ req: request, files: [fileInfo], auth });
413
+
414
+ const { config, s3Client, keyPrefix } = await resolveRequestConfig(auth);
415
+
416
+ const key = keyPrefix + generateFileKey(fileInfo.name);
417
+ const partSize = DEFAULT_PART_SIZE;
418
+ const totalParts = Math.ceil(fileInfo.size / partSize);
419
+
420
+ // Create S3 multipart upload
421
+ const s3UploadId = await createMultipartUpload(
422
+ s3Client,
423
+ config.bucket,
424
+ key,
425
+ fileInfo.type
426
+ );
427
+
428
+ // Generate presigned URLs for all parts
429
+ const partNumbers = Array.from({ length: totalParts }, (_, i) => i + 1);
430
+ const detectedFileType = getFileTypeFromMime(fileInfo.type);
431
+ const routeTypeConfig = detectedFileType ? route._config[detectedFileType] : undefined;
432
+ const expiresIn = routeTypeConfig?.presignedUrlExpiry ?? config.presignedUrlExpiry ?? 3600;
433
+
434
+ const parts = await generatePresignedPartUrls(
435
+ s3Client,
436
+ config.bucket,
437
+ key,
438
+ s3UploadId,
439
+ partNumbers,
440
+ expiresIn
441
+ );
442
+
443
+ // Compute TTL/expiresAt
444
+ const ttl = fileInfo.ttlSeconds ?? routeTypeConfig?.defaultTtlSeconds;
445
+ const maxTtl = routeTypeConfig?.maxTtlSeconds;
446
+ const effectiveTtl = ttl != null && maxTtl != null ? Math.min(ttl, maxTtl) : ttl;
447
+ const fileExpiresAt = effectiveTtl != null ? new Date(Date.now() + effectiveTtl * 1000) : null;
448
+
449
+ const url = buildFileUrl(config, key);
450
+
451
+ // Store in memory for the complete step
452
+ pendingUploads.set(key, {
453
+ key,
454
+ name: fileInfo.name,
455
+ size: fileInfo.size,
456
+ type: fileInfo.type,
457
+ url,
458
+ acl: routeTypeConfig?.acl ?? "public-read",
459
+ routeKey,
460
+ metadata: metadata as Record<string, unknown>,
461
+ customMetadata: fileInfo.customMetadata ?? null,
462
+ auth,
463
+ resolvedConfig: config,
464
+ createdAtMs: Date.now(),
465
+ });
466
+
467
+ // Fire-and-forget: notify lifecycle hook
468
+ if (hooks.onMultipartStarted) {
469
+ hooks.onMultipartStarted({
470
+ key,
471
+ name: fileInfo.name,
472
+ size: fileInfo.size,
473
+ type: fileInfo.type,
474
+ url,
475
+ routeKey,
476
+ s3UploadId,
477
+ bucket: config.bucket,
478
+ totalParts,
479
+ partSize,
480
+ auth,
481
+ metadata: metadata as Record<string, unknown>,
482
+ customMetadata: fileInfo.customMetadata ?? null,
483
+ expiresAt: fileExpiresAt,
484
+ uploadExpiresAt: new Date(Date.now() + expiresIn * 1000),
485
+ }).catch(console.error);
486
+ }
487
+
488
+ return Response.json({
489
+ fileKey: key,
490
+ uploadId: s3UploadId,
491
+ parts,
492
+ partSize,
493
+ totalParts,
494
+ });
495
+ }
496
+
497
+ async function handlePresignParts(body: Record<string, unknown>) {
498
+ const { fileKey, uploadId, partNumbers } = body as {
499
+ fileKey: string;
500
+ uploadId: string;
501
+ partNumbers: number[];
502
+ };
503
+
504
+ // Use stored config from pending upload if available
505
+ const pending = pendingUploads.get(fileKey);
506
+ const config = pending?.resolvedConfig ?? getConfigLazy();
507
+ const s3Client = pending?.resolvedConfig ? makeS3(pending.resolvedConfig) : getS3();
508
+ const expiresIn = config.presignedUrlExpiry ?? 3600;
509
+
510
+ const parts = await generatePresignedPartUrls(
511
+ s3Client,
512
+ config.bucket,
513
+ fileKey,
514
+ uploadId,
515
+ partNumbers,
516
+ expiresIn
517
+ );
518
+
519
+ return Response.json({ parts });
520
+ }
521
+
522
+ async function handleCompleteMultipart(body: Record<string, unknown>, auth?: AuthContext) {
523
+ const { routeKey, fileKey, uploadId, parts } = body as {
524
+ routeKey: string;
525
+ fileKey: string;
526
+ uploadId: string;
527
+ parts: CompletedPartInfo[];
528
+ };
529
+
530
+ const route = opts.router[routeKey];
531
+ if (!route) {
532
+ throw UploadboxError.routeNotFound(routeKey);
533
+ }
534
+
535
+ // Get file data from in-memory store, or fall back to lifecycle hook
536
+ let pending = pendingUploads.get(fileKey);
537
+ if (!pending && hooks.onFileVerified) {
538
+ const verified = await hooks.onFileVerified(fileKey);
539
+ if (verified) {
540
+ pending = {
541
+ key: verified.key,
542
+ name: verified.name,
543
+ size: verified.size,
544
+ type: verified.type,
545
+ url: verified.url,
546
+ acl: verified.acl,
547
+ routeKey,
548
+ metadata: verified.metadata,
549
+ customMetadata: verified.customMetadata ?? null,
550
+ auth,
551
+ createdAtMs: Date.now(),
552
+ };
553
+ }
554
+ }
555
+
556
+ // Use stored config or resolve fresh
557
+ const config = pending?.resolvedConfig ?? (await resolveRequestConfig(auth)).config;
558
+ const s3Client = makeS3(config);
559
+
560
+ // Complete S3 multipart upload
561
+ await completeMultipartUpload(s3Client, config.bucket, fileKey, uploadId, parts);
562
+
563
+ // Verify with headObject
564
+ const s3Head = await headObject(s3Client, config.bucket, fileKey);
565
+ if (!s3Head) {
566
+ throw UploadboxError.uploadFailed(`File not found after multipart complete: ${fileKey}`);
567
+ }
568
+
569
+ if (!pending) {
570
+ throw UploadboxError.uploadFailed(`File record not found: ${fileKey}`);
571
+ }
572
+
573
+ // Clean up in-memory state
574
+ pendingUploads.delete(fileKey);
575
+
576
+ // Fire-and-forget: notify multipart completed
577
+ if (hooks.onMultipartCompleted) {
578
+ hooks.onMultipartCompleted({
579
+ key: fileKey,
580
+ s3UploadId: uploadId,
581
+ routeKey,
582
+ auth: pending.auth,
583
+ parts,
584
+ }).catch(console.error);
585
+ }
586
+
587
+ const fileData = {
588
+ key: pending.key,
589
+ name: pending.name,
590
+ size: pending.size,
591
+ type: pending.type,
592
+ url: pending.url,
593
+ acl: pending.acl as "public-read" | "private",
594
+ customMetadata: (pending.customMetadata as Record<string, string>) ?? undefined,
595
+ };
596
+
597
+ const serverData = await route._onUploadComplete({
598
+ metadata: pending.metadata,
599
+ file: fileData,
600
+ });
601
+
602
+ // Fire-and-forget: notify lifecycle hook
603
+ if (hooks.onUploadCompleted) {
604
+ hooks.onUploadCompleted({
605
+ file: fileData,
606
+ routeKey,
607
+ auth: pending.auth,
608
+ metadata: pending.metadata,
609
+ serverData,
610
+ }).catch(console.error);
611
+ }
612
+
613
+ // Run processing pipeline (non-blocking)
614
+ if (opts.processing) {
615
+ runProcessingPipeline(
616
+ opts.processing,
617
+ fileData,
618
+ pending.metadata,
619
+ s3Client,
620
+ config
621
+ ).catch((err) => {
622
+ console.error("[uploadbox] Processing pipeline error:", err);
623
+ });
624
+ }
625
+
626
+ return Response.json({ file: fileData, serverData });
627
+ }
628
+
629
+ async function handleAbortMultipart(body: Record<string, unknown>) {
630
+ const { fileKey, uploadId } = body as {
631
+ fileKey: string;
632
+ uploadId: string;
633
+ };
634
+
635
+ // Use stored config from pending upload if available
636
+ const pending = pendingUploads.get(fileKey);
637
+ const config = pending?.resolvedConfig ?? getConfigLazy();
638
+ const s3Client = pending?.resolvedConfig ? makeS3(pending.resolvedConfig) : getS3();
639
+
640
+ await abortMultipartUpload(s3Client, config.bucket, fileKey, uploadId);
641
+
642
+ // Clean up in-memory state
643
+ pendingUploads.delete(fileKey);
644
+
645
+ // Fire-and-forget: notify lifecycle hook
646
+ if (hooks.onMultipartAborted) {
647
+ hooks.onMultipartAborted({ key: fileKey, s3UploadId: uploadId }).catch(console.error);
648
+ }
649
+
650
+ return Response.json({ success: true });
651
+ }
652
+
653
+ return { GET, POST };
654
+ }
@@ -0,0 +1,9 @@
1
+ import type { FileRouter, RouterConfig } from "@uploadbox/core";
2
+
3
+ export function extractRouterConfig(router: FileRouter): RouterConfig {
4
+ const config: RouterConfig = {};
5
+ for (const [key, route] of Object.entries(router)) {
6
+ config[key] = { config: route._config };
7
+ }
8
+ return config;
9
+ }