@uploadbox/core 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 (76) hide show
  1. package/dist/builder.d.ts +11 -0
  2. package/dist/builder.d.ts.map +1 -0
  3. package/dist/builder.js +27 -0
  4. package/dist/builder.js.map +1 -0
  5. package/dist/create-uploadbox.d.ts +13 -0
  6. package/dist/create-uploadbox.d.ts.map +1 -0
  7. package/dist/create-uploadbox.js +31 -0
  8. package/dist/create-uploadbox.js.map +1 -0
  9. package/dist/errors.d.ts +22 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +53 -0
  12. package/dist/errors.js.map +1 -0
  13. package/dist/file-types.d.ts +6 -0
  14. package/dist/file-types.d.ts.map +1 -0
  15. package/dist/file-types.js +39 -0
  16. package/dist/file-types.js.map +1 -0
  17. package/dist/hooks/image-resize.d.ts +24 -0
  18. package/dist/hooks/image-resize.d.ts.map +1 -0
  19. package/dist/hooks/image-resize.js +78 -0
  20. package/dist/hooks/image-resize.js.map +1 -0
  21. package/dist/hooks/virus-scan.d.ts +24 -0
  22. package/dist/hooks/virus-scan.d.ts.map +1 -0
  23. package/dist/hooks/virus-scan.js +69 -0
  24. package/dist/hooks/virus-scan.js.map +1 -0
  25. package/dist/index.d.ts +15 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +12 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/processing.d.ts +25 -0
  30. package/dist/processing.d.ts.map +1 -0
  31. package/dist/processing.js +2 -0
  32. package/dist/processing.js.map +1 -0
  33. package/dist/s3-multipart.d.ts +12 -0
  34. package/dist/s3-multipart.d.ts.map +1 -0
  35. package/dist/s3-multipart.js +72 -0
  36. package/dist/s3-multipart.js.map +1 -0
  37. package/dist/s3.d.ts +20 -0
  38. package/dist/s3.d.ts.map +1 -0
  39. package/dist/s3.js +95 -0
  40. package/dist/s3.js.map +1 -0
  41. package/dist/server-api.d.ts +43 -0
  42. package/dist/server-api.d.ts.map +1 -0
  43. package/dist/server-api.js +48 -0
  44. package/dist/server-api.js.map +1 -0
  45. package/dist/server-upload.d.ts +19 -0
  46. package/dist/server-upload.d.ts.map +1 -0
  47. package/dist/server-upload.js +53 -0
  48. package/dist/server-upload.js.map +1 -0
  49. package/dist/types.d.ts +94 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +3 -0
  52. package/dist/types.js.map +1 -0
  53. package/dist/utils.d.ts +7 -0
  54. package/dist/utils.d.ts.map +1 -0
  55. package/dist/utils.js +50 -0
  56. package/dist/utils.js.map +1 -0
  57. package/dist/validation.d.ts +3 -0
  58. package/dist/validation.d.ts.map +1 -0
  59. package/dist/validation.js +44 -0
  60. package/dist/validation.js.map +1 -0
  61. package/package.json +58 -0
  62. package/src/builder.ts +39 -0
  63. package/src/create-uploadbox.ts +46 -0
  64. package/src/errors.ts +65 -0
  65. package/src/file-types.ts +37 -0
  66. package/src/hooks/image-resize.ts +102 -0
  67. package/src/hooks/virus-scan.ts +95 -0
  68. package/src/index.ts +38 -0
  69. package/src/processing.ts +28 -0
  70. package/src/s3-multipart.ts +107 -0
  71. package/src/s3.ts +142 -0
  72. package/src/server-api.ts +81 -0
  73. package/src/server-upload.ts +88 -0
  74. package/src/types.ts +105 -0
  75. package/src/utils.ts +54 -0
  76. package/src/validation.ts +52 -0
package/src/builder.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type {
2
+ FileRouteConfig,
3
+ FileRoute,
4
+ MiddlewareFn,
5
+ OnUploadCompleteFn,
6
+ } from "./types.js";
7
+
8
+ export class UploadBuilder<TMetadata = Record<string, unknown>, TReturn = unknown> {
9
+ /** @internal */ _config: FileRouteConfig;
10
+ /** @internal */ _middleware: MiddlewareFn<TMetadata>;
11
+ /** @internal */ _onUploadComplete: OnUploadCompleteFn<TMetadata, TReturn>;
12
+
13
+ constructor(config: FileRouteConfig) {
14
+ this._config = config;
15
+ this._middleware = (async () => ({}) as TMetadata) as MiddlewareFn<TMetadata>;
16
+ this._onUploadComplete = (async () => undefined) as unknown as OnUploadCompleteFn<TMetadata, TReturn>;
17
+ }
18
+
19
+ middleware<TNewMetadata>(fn: MiddlewareFn<TNewMetadata>): UploadBuilder<TNewMetadata, TReturn> {
20
+ const builder = new UploadBuilder<TNewMetadata, TReturn>(this._config);
21
+ builder._middleware = fn;
22
+ builder._onUploadComplete = this._onUploadComplete as unknown as OnUploadCompleteFn<TNewMetadata, TReturn>;
23
+ return builder;
24
+ }
25
+
26
+ onUploadComplete<TNewReturn>(
27
+ fn: OnUploadCompleteFn<TMetadata, TNewReturn>
28
+ ): FileRoute<TMetadata, TNewReturn> {
29
+ return {
30
+ _config: this._config,
31
+ _middleware: this._middleware,
32
+ _onUploadComplete: fn,
33
+ };
34
+ }
35
+ }
36
+
37
+ export function createUploadBuilder(config: FileRouteConfig): UploadBuilder {
38
+ return new UploadBuilder(config);
39
+ }
@@ -0,0 +1,46 @@
1
+ import type { FileRouter, FileRouteConfig, UploadboxConfig } from "./types.js";
2
+ import { createUploadBuilder } from "./builder.js";
3
+ import { createS3Client } from "./s3.js";
4
+ import type { S3Client } from "@aws-sdk/client-s3";
5
+
6
+ export interface UploadboxInstance<TRouter extends FileRouter> {
7
+ router: TRouter;
8
+ s3Client: S3Client;
9
+ config: UploadboxConfig;
10
+ }
11
+
12
+ function getConfigFromEnv(): UploadboxConfig {
13
+ return {
14
+ region: process.env.UPLOADBOX_AWS_REGION ?? "us-east-1",
15
+ bucket: process.env.UPLOADBOX_S3_BUCKET ?? "",
16
+ accessKeyId: process.env.UPLOADBOX_AWS_ACCESS_KEY_ID ?? "",
17
+ secretAccessKey: process.env.UPLOADBOX_AWS_SECRET_ACCESS_KEY ?? "",
18
+ cdnBaseUrl: process.env.UPLOADBOX_CDN_BASE_URL,
19
+ endpoint: process.env.UPLOADBOX_S3_ENDPOINT,
20
+ forcePathStyle: process.env.UPLOADBOX_S3_FORCE_PATH_STYLE === "true",
21
+ presignedUrlExpiry: process.env.UPLOADBOX_PRESIGNED_URL_EXPIRY
22
+ ? parseInt(process.env.UPLOADBOX_PRESIGNED_URL_EXPIRY, 10)
23
+ : undefined,
24
+ };
25
+ }
26
+
27
+ export function createUploadbox<TRouter extends FileRouter>(opts: {
28
+ router: TRouter;
29
+ config?: Partial<UploadboxConfig>;
30
+ }): UploadboxInstance<TRouter> {
31
+ const envConfig = getConfigFromEnv();
32
+ const config: UploadboxConfig = { ...envConfig, ...opts.config };
33
+
34
+ const s3Client = createS3Client(config);
35
+
36
+ return {
37
+ router: opts.router,
38
+ s3Client,
39
+ config,
40
+ };
41
+ }
42
+
43
+ // The `f` function — shorthand for creating a file route builder
44
+ export function f(config: FileRouteConfig) {
45
+ return createUploadBuilder(config);
46
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,65 @@
1
+ export class UploadboxError extends Error {
2
+ public readonly code: string;
3
+ public readonly statusCode: number;
4
+
5
+ constructor(code: string, message: string, statusCode: number = 400) {
6
+ super(message);
7
+ this.name = "UploadboxError";
8
+ this.code = code;
9
+ this.statusCode = statusCode;
10
+ }
11
+
12
+ static fileTooLarge(maxSize: string) {
13
+ return new UploadboxError("FILE_TOO_LARGE", `File exceeds maximum size of ${maxSize}`, 413);
14
+ }
15
+
16
+ static invalidFileType(type: string) {
17
+ return new UploadboxError("INVALID_FILE_TYPE", `File type "${type}" is not allowed`, 415);
18
+ }
19
+
20
+ static tooManyFiles(max: number) {
21
+ return new UploadboxError("TOO_MANY_FILES", `Maximum ${max} files allowed`, 400);
22
+ }
23
+
24
+ static tooFewFiles(min: number) {
25
+ return new UploadboxError("TOO_FEW_FILES", `Minimum ${min} files required`, 400);
26
+ }
27
+
28
+ static routeNotFound(route: string) {
29
+ return new UploadboxError("ROUTE_NOT_FOUND", `Upload route "${route}" not found`, 404);
30
+ }
31
+
32
+ static uploadFailed(reason: string) {
33
+ return new UploadboxError("UPLOAD_FAILED", `Upload failed: ${reason}`, 500);
34
+ }
35
+
36
+ static unauthorized(message = "Unauthorized") {
37
+ return new UploadboxError("UNAUTHORIZED", message, 401);
38
+ }
39
+
40
+ static rateLimited(retryAfter: number) {
41
+ const err = new UploadboxError("RATE_LIMITED", `Rate limit exceeded. Retry after ${retryAfter} seconds`, 429);
42
+ (err as any).retryAfter = retryAfter;
43
+ return err;
44
+ }
45
+
46
+ static forbidden(message = "Forbidden") {
47
+ return new UploadboxError("FORBIDDEN", message, 403);
48
+ }
49
+
50
+ static quotaExceeded() {
51
+ return new UploadboxError("QUOTA_EXCEEDED", "Storage or upload quota exceeded", 413);
52
+ }
53
+
54
+ static s3Error(message: string) {
55
+ return new UploadboxError("S3_ERROR", `S3 error: ${message}`, 500);
56
+ }
57
+
58
+ toJSON() {
59
+ return {
60
+ error: this.code,
61
+ message: this.message,
62
+ statusCode: this.statusCode,
63
+ };
64
+ }
65
+ }
@@ -0,0 +1,37 @@
1
+ import type { FileType } from "./types.js";
2
+
3
+ export const FILE_TYPE_MIME_MAP: Record<FileType, string[]> = {
4
+ image: ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/avif", "image/heic", "image/heif"],
5
+ video: ["video/mp4", "video/webm", "video/ogg", "video/quicktime", "video/x-msvideo"],
6
+ audio: ["audio/mpeg", "audio/ogg", "audio/wav", "audio/webm", "audio/aac", "audio/flac"],
7
+ pdf: ["application/pdf"],
8
+ text: ["text/plain", "text/csv", "text/html", "text/css", "text/javascript", "application/json", "application/xml"],
9
+ blob: ["application/octet-stream"],
10
+ };
11
+
12
+ export const DEFAULT_MAX_FILE_SIZES: Record<FileType, string> = {
13
+ image: "4MB",
14
+ video: "64MB",
15
+ audio: "16MB",
16
+ pdf: "16MB",
17
+ text: "1MB",
18
+ blob: "8MB",
19
+ };
20
+
21
+ export function getFileTypeFromMime(mimeType: string): FileType | null {
22
+ for (const [fileType, mimes] of Object.entries(FILE_TYPE_MIME_MAP)) {
23
+ if (mimes.includes(mimeType)) return fileType as FileType;
24
+ }
25
+ // Fallback: check prefix
26
+ if (mimeType.startsWith("image/")) return "image";
27
+ if (mimeType.startsWith("video/")) return "video";
28
+ if (mimeType.startsWith("audio/")) return "audio";
29
+ if (mimeType.startsWith("text/")) return "text";
30
+ return null;
31
+ }
32
+
33
+ export function isFileTypeAllowed(mimeType: string, allowedTypes: FileType[]): boolean {
34
+ if (allowedTypes.includes("blob")) return true;
35
+ const fileType = getFileTypeFromMime(mimeType);
36
+ return fileType !== null && allowedTypes.includes(fileType);
37
+ }
@@ -0,0 +1,102 @@
1
+ import type { ProcessingHook, ProcessingContext, ProcessingHookResult } from "../processing.js";
2
+
3
+ interface ImageResizeOptions {
4
+ maxWidth?: number;
5
+ maxHeight?: number;
6
+ quality?: number;
7
+ format?: "jpeg" | "png" | "webp";
8
+ }
9
+
10
+ /**
11
+ * Example image resize hook using sharp.
12
+ * Install sharp as a dependency to use this hook:
13
+ * npm install sharp
14
+ *
15
+ * Usage:
16
+ * import { createImageResizeHook } from "@uploadbox/core/hooks/image-resize";
17
+ *
18
+ * const resizeHook = createImageResizeHook({
19
+ * maxWidth: 1920,
20
+ * maxHeight: 1080,
21
+ * quality: 80,
22
+ * });
23
+ */
24
+ export function createImageResizeHook(options: ImageResizeOptions = {}): ProcessingHook {
25
+ const {
26
+ maxWidth = 1920,
27
+ maxHeight = 1080,
28
+ quality = 80,
29
+ format = "webp",
30
+ } = options;
31
+
32
+ return {
33
+ name: "image-resize",
34
+ timeoutMs: 60000,
35
+
36
+ shouldRun(ctx: ProcessingContext): boolean {
37
+ return ctx.file.type.startsWith("image/");
38
+ },
39
+
40
+ async run(ctx: ProcessingContext): Promise<ProcessingHookResult> {
41
+ try {
42
+ // Dynamic import to make sharp optional
43
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
44
+ let sharp: any;
45
+ try {
46
+ sharp = await (Function('return import("sharp")')() as Promise<any>);
47
+ } catch {
48
+ return {
49
+ success: false,
50
+ error: "sharp is not installed. Run: npm install sharp",
51
+ };
52
+ }
53
+
54
+ const { GetObjectCommand, PutObjectCommand } = await import("@aws-sdk/client-s3");
55
+
56
+ // Download file from S3
57
+ const response = await ctx.s3Client.send(
58
+ new GetObjectCommand({
59
+ Bucket: ctx.config.bucket,
60
+ Key: ctx.file.key,
61
+ })
62
+ );
63
+
64
+ const body = await response.Body?.transformToByteArray();
65
+ if (!body) {
66
+ return { success: false, error: "Failed to download file from S3" };
67
+ }
68
+
69
+ // Resize image
70
+ const sharpFn = sharp.default ?? sharp;
71
+ const resized = await sharpFn(Buffer.from(body))
72
+ .resize(maxWidth, maxHeight, { fit: "inside", withoutEnlargement: true })
73
+ .toFormat(format, { quality })
74
+ .toBuffer();
75
+
76
+ // Upload resized image back to S3
77
+ await ctx.s3Client.send(
78
+ new PutObjectCommand({
79
+ Bucket: ctx.config.bucket,
80
+ Key: ctx.file.key,
81
+ Body: resized,
82
+ ContentType: `image/${format}`,
83
+ })
84
+ );
85
+
86
+ return {
87
+ success: true,
88
+ data: {
89
+ originalSize: body.length,
90
+ resizedSize: resized.length,
91
+ format,
92
+ },
93
+ };
94
+ } catch (err) {
95
+ return {
96
+ success: false,
97
+ error: (err as Error).message,
98
+ };
99
+ }
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,95 @@
1
+ import type { ProcessingHook, ProcessingContext, ProcessingHookResult } from "../processing.js";
2
+
3
+ interface VirusScanOptions {
4
+ /** ClamAV REST API endpoint (e.g., http://localhost:3310/scan) */
5
+ clamavUrl: string;
6
+ /** Whether to delete the file if a virus is found */
7
+ deleteOnDetection?: boolean;
8
+ /** Timeout in ms for the scan request */
9
+ timeoutMs?: number;
10
+ }
11
+
12
+ /**
13
+ * Example virus scan hook using ClamAV REST API.
14
+ * Requires a running ClamAV REST service.
15
+ *
16
+ * Usage:
17
+ * import { createVirusScanHook } from "@uploadbox/core/hooks/virus-scan";
18
+ *
19
+ * const scanHook = createVirusScanHook({
20
+ * clamavUrl: "http://localhost:3310/scan",
21
+ * deleteOnDetection: true,
22
+ * });
23
+ */
24
+ export function createVirusScanHook(options: VirusScanOptions): ProcessingHook {
25
+ const {
26
+ clamavUrl,
27
+ deleteOnDetection = true,
28
+ timeoutMs = 30000,
29
+ } = options;
30
+
31
+ return {
32
+ name: "virus-scan",
33
+ timeoutMs,
34
+
35
+ shouldRun(): boolean {
36
+ // Scan all uploaded files
37
+ return true;
38
+ },
39
+
40
+ async run(ctx: ProcessingContext): Promise<ProcessingHookResult> {
41
+ try {
42
+ const { GetObjectCommand, DeleteObjectCommand } = await import("@aws-sdk/client-s3");
43
+
44
+ // Download file from S3
45
+ const response = await ctx.s3Client.send(
46
+ new GetObjectCommand({
47
+ Bucket: ctx.config.bucket,
48
+ Key: ctx.file.key,
49
+ })
50
+ );
51
+
52
+ const body = await response.Body?.transformToByteArray();
53
+ if (!body) {
54
+ return { success: false, error: "Failed to download file from S3" };
55
+ }
56
+
57
+ // Send to ClamAV for scanning
58
+ const scanResponse = await fetch(clamavUrl, {
59
+ method: "POST",
60
+ body: Buffer.from(body),
61
+ headers: {
62
+ "Content-Type": "application/octet-stream",
63
+ },
64
+ signal: AbortSignal.timeout(timeoutMs),
65
+ });
66
+
67
+ const scanResult = await scanResponse.text();
68
+ const isClean = scanResponse.ok && !scanResult.toLowerCase().includes("found");
69
+
70
+ if (!isClean && deleteOnDetection) {
71
+ await ctx.s3Client.send(
72
+ new DeleteObjectCommand({
73
+ Bucket: ctx.config.bucket,
74
+ Key: ctx.file.key,
75
+ })
76
+ );
77
+ }
78
+
79
+ return {
80
+ success: isClean,
81
+ data: {
82
+ clean: isClean,
83
+ scanResult: scanResult.slice(0, 500),
84
+ },
85
+ error: isClean ? undefined : `Virus detected: ${scanResult.slice(0, 200)}`,
86
+ };
87
+ } catch (err) {
88
+ return {
89
+ success: false,
90
+ error: `Virus scan failed: ${(err as Error).message}`,
91
+ };
92
+ }
93
+ },
94
+ };
95
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ export { createUploadbox, f } from "./create-uploadbox.js";
2
+ export { UploadboxApi } from "./server-api.js";
3
+ export { UploadboxError } from "./errors.js";
4
+ export { createUploadBuilder } from "./builder.js";
5
+ export { validateFiles } from "./validation.js";
6
+ export { createS3Client, generatePresignedPutUrl, generatePresignedGetUrl, headObject, deleteObject, deleteObjects, listObjects } from "./s3.js";
7
+ export { parseFileSize, formatFileSize, generateFileKey, generateUploadId } from "./utils.js";
8
+ export { FILE_TYPE_MIME_MAP, DEFAULT_MAX_FILE_SIZES, getFileTypeFromMime, isFileTypeAllowed } from "./file-types.js";
9
+ export { serverUpload, serverUploadMany } from "./server-upload.js";
10
+ export type { ServerUploadInput, ServerUploadResult } from "./server-upload.js";
11
+ export {
12
+ createMultipartUpload,
13
+ generatePresignedPartUrls,
14
+ completeMultipartUpload,
15
+ abortMultipartUpload,
16
+ } from "./s3-multipart.js";
17
+ export type {
18
+ FileType,
19
+ FileSize,
20
+ ACL,
21
+ ContentDisposition,
22
+ FileRouteConfig,
23
+ FileInfo,
24
+ UploadedFileData,
25
+ MiddlewareFn,
26
+ OnUploadCompleteFn,
27
+ FileRoute,
28
+ FileRouter,
29
+ PresignedUrlResponse,
30
+ UploadCompleteResponse,
31
+ UploadboxConfig,
32
+ RouterConfig,
33
+ AuthContext,
34
+ MultipartPresignedResponse,
35
+ CompletedPartInfo,
36
+ } from "./types.js";
37
+ export { MULTIPART_THRESHOLD, DEFAULT_PART_SIZE } from "./types.js";
38
+ export type { ProcessingHook, ProcessingContext, ProcessingHookResult, ProcessingPipelineConfig } from "./processing.js";
@@ -0,0 +1,28 @@
1
+ import type { UploadedFileData, UploadboxConfig } from "./types.js";
2
+ import type { S3Client } from "@aws-sdk/client-s3";
3
+
4
+ export interface ProcessingContext {
5
+ file: UploadedFileData;
6
+ metadata: Record<string, unknown>;
7
+ s3Client: S3Client;
8
+ config: UploadboxConfig;
9
+ }
10
+
11
+ export interface ProcessingHookResult {
12
+ success: boolean;
13
+ data?: Record<string, unknown>;
14
+ error?: string;
15
+ }
16
+
17
+ export interface ProcessingHook {
18
+ name: string;
19
+ shouldRun: (ctx: ProcessingContext) => boolean;
20
+ run: (ctx: ProcessingContext) => Promise<ProcessingHookResult>;
21
+ timeoutMs?: number;
22
+ }
23
+
24
+ export interface ProcessingPipelineConfig {
25
+ hooks: ProcessingHook[];
26
+ mode?: "sequential" | "parallel";
27
+ continueOnError?: boolean;
28
+ }
@@ -0,0 +1,107 @@
1
+ import {
2
+ S3Client,
3
+ CreateMultipartUploadCommand,
4
+ UploadPartCommand,
5
+ CompleteMultipartUploadCommand,
6
+ AbortMultipartUploadCommand,
7
+ } from "@aws-sdk/client-s3";
8
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
9
+ import { UploadboxError } from "./errors.js";
10
+
11
+ export async function createMultipartUpload(
12
+ client: S3Client,
13
+ bucket: string,
14
+ key: string,
15
+ contentType: string
16
+ ): Promise<string> {
17
+ try {
18
+ const response = await client.send(
19
+ new CreateMultipartUploadCommand({
20
+ Bucket: bucket,
21
+ Key: key,
22
+ ContentType: contentType,
23
+ })
24
+ );
25
+ if (!response.UploadId) {
26
+ throw new Error("No UploadId returned from S3");
27
+ }
28
+ return response.UploadId;
29
+ } catch (err) {
30
+ if (err instanceof UploadboxError) throw err;
31
+ throw UploadboxError.s3Error((err as Error).message);
32
+ }
33
+ }
34
+
35
+ export async function generatePresignedPartUrls(
36
+ client: S3Client,
37
+ bucket: string,
38
+ key: string,
39
+ uploadId: string,
40
+ partNumbers: number[],
41
+ expiresIn: number = 3600
42
+ ): Promise<{ partNumber: number; url: string }[]> {
43
+ try {
44
+ return await Promise.all(
45
+ partNumbers.map(async (partNumber) => {
46
+ const command = new UploadPartCommand({
47
+ Bucket: bucket,
48
+ Key: key,
49
+ UploadId: uploadId,
50
+ PartNumber: partNumber,
51
+ });
52
+ const url = await getSignedUrl(client, command, { expiresIn });
53
+ return { partNumber, url };
54
+ })
55
+ );
56
+ } catch (err) {
57
+ throw UploadboxError.s3Error((err as Error).message);
58
+ }
59
+ }
60
+
61
+ export async function completeMultipartUpload(
62
+ client: S3Client,
63
+ bucket: string,
64
+ key: string,
65
+ uploadId: string,
66
+ parts: { partNumber: number; etag: string }[]
67
+ ): Promise<void> {
68
+ try {
69
+ await client.send(
70
+ new CompleteMultipartUploadCommand({
71
+ Bucket: bucket,
72
+ Key: key,
73
+ UploadId: uploadId,
74
+ MultipartUpload: {
75
+ Parts: parts
76
+ .sort((a, b) => a.partNumber - b.partNumber)
77
+ .map((p) => ({
78
+ PartNumber: p.partNumber,
79
+ ETag: p.etag,
80
+ })),
81
+ },
82
+ })
83
+ );
84
+ } catch (err) {
85
+ throw UploadboxError.s3Error((err as Error).message);
86
+ }
87
+ }
88
+
89
+ export async function abortMultipartUpload(
90
+ client: S3Client,
91
+ bucket: string,
92
+ key: string,
93
+ uploadId: string
94
+ ): Promise<void> {
95
+ try {
96
+ await client.send(
97
+ new AbortMultipartUploadCommand({
98
+ Bucket: bucket,
99
+ Key: key,
100
+ UploadId: uploadId,
101
+ })
102
+ );
103
+ } catch (err) {
104
+ // Ignore errors when aborting — the upload may already be aborted
105
+ console.warn("[uploadbox] Failed to abort multipart upload:", (err as Error).message);
106
+ }
107
+ }
package/src/s3.ts ADDED
@@ -0,0 +1,142 @@
1
+ import {
2
+ S3Client,
3
+ PutObjectCommand,
4
+ HeadObjectCommand,
5
+ DeleteObjectCommand,
6
+ DeleteObjectsCommand,
7
+ ListObjectsV2Command,
8
+ GetObjectCommand,
9
+ } from "@aws-sdk/client-s3";
10
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
11
+ import type { UploadboxConfig } from "./types.js";
12
+ import { UploadboxError } from "./errors.js";
13
+
14
+ export function createS3Client(config: UploadboxConfig): S3Client {
15
+ return new S3Client({
16
+ region: config.region ?? "us-east-1",
17
+ credentials: {
18
+ accessKeyId: config.accessKeyId,
19
+ secretAccessKey: config.secretAccessKey,
20
+ },
21
+ ...(config.endpoint ? { endpoint: config.endpoint } : {}),
22
+ ...(config.forcePathStyle ? { forcePathStyle: true } : {}),
23
+ });
24
+ }
25
+
26
+ export async function generatePresignedPutUrl(
27
+ client: S3Client,
28
+ bucket: string,
29
+ key: string,
30
+ contentType: string,
31
+ contentLength: number,
32
+ expiresIn: number = 3600
33
+ ): Promise<string> {
34
+ try {
35
+ const command = new PutObjectCommand({
36
+ Bucket: bucket,
37
+ Key: key,
38
+ ContentType: contentType,
39
+ ContentLength: contentLength,
40
+ });
41
+ return await getSignedUrl(client, command, { expiresIn });
42
+ } catch (err) {
43
+ throw UploadboxError.s3Error((err as Error).message);
44
+ }
45
+ }
46
+
47
+ export async function generatePresignedGetUrl(
48
+ client: S3Client,
49
+ bucket: string,
50
+ key: string,
51
+ expiresIn: number = 3600
52
+ ): Promise<string> {
53
+ try {
54
+ const command = new GetObjectCommand({ Bucket: bucket, Key: key });
55
+ return await getSignedUrl(client, command, { expiresIn });
56
+ } catch (err) {
57
+ throw UploadboxError.s3Error((err as Error).message);
58
+ }
59
+ }
60
+
61
+ export async function headObject(
62
+ client: S3Client,
63
+ bucket: string,
64
+ key: string
65
+ ): Promise<{ contentLength: number; contentType: string } | null> {
66
+ try {
67
+ const response = await client.send(
68
+ new HeadObjectCommand({ Bucket: bucket, Key: key })
69
+ );
70
+ return {
71
+ contentLength: response.ContentLength ?? 0,
72
+ contentType: response.ContentType ?? "application/octet-stream",
73
+ };
74
+ } catch (err: any) {
75
+ if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
76
+ return null;
77
+ }
78
+ throw UploadboxError.s3Error(err.message);
79
+ }
80
+ }
81
+
82
+ export async function deleteObject(
83
+ client: S3Client,
84
+ bucket: string,
85
+ key: string
86
+ ): Promise<void> {
87
+ try {
88
+ await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
89
+ } catch (err) {
90
+ throw UploadboxError.s3Error((err as Error).message);
91
+ }
92
+ }
93
+
94
+ export async function deleteObjects(
95
+ client: S3Client,
96
+ bucket: string,
97
+ keys: string[]
98
+ ): Promise<void> {
99
+ if (keys.length === 0) return;
100
+ try {
101
+ await client.send(
102
+ new DeleteObjectsCommand({
103
+ Bucket: bucket,
104
+ Delete: { Objects: keys.map((Key) => ({ Key })) },
105
+ })
106
+ );
107
+ } catch (err) {
108
+ throw UploadboxError.s3Error((err as Error).message);
109
+ }
110
+ }
111
+
112
+ export async function listObjects(
113
+ client: S3Client,
114
+ bucket: string,
115
+ prefix?: string,
116
+ limit: number = 1000,
117
+ continuationToken?: string
118
+ ): Promise<{
119
+ keys: { key: string; size: number; lastModified: Date }[];
120
+ nextToken?: string;
121
+ }> {
122
+ try {
123
+ const response = await client.send(
124
+ new ListObjectsV2Command({
125
+ Bucket: bucket,
126
+ Prefix: prefix,
127
+ MaxKeys: limit,
128
+ ContinuationToken: continuationToken,
129
+ })
130
+ );
131
+ return {
132
+ keys: (response.Contents ?? []).map((obj) => ({
133
+ key: obj.Key!,
134
+ size: obj.Size ?? 0,
135
+ lastModified: obj.LastModified ?? new Date(),
136
+ })),
137
+ nextToken: response.NextContinuationToken,
138
+ };
139
+ } catch (err) {
140
+ throw UploadboxError.s3Error((err as Error).message);
141
+ }
142
+ }