@bernierllc/file-uploader 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.
- package/.eslintrc.cjs +33 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/__tests__/file-uploader.test.ts +517 -0
- package/dist/file-uploader.d.ts +25 -0
- package/dist/file-uploader.js +168 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.js +8 -0
- package/jest.config.cjs +33 -0
- package/package.json +54 -0
- package/src/file-uploader.ts +198 -0
- package/src/index.ts +16 -0
- package/src/types.ts +73 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'http';
|
|
2
|
+
import type { UploadConfig, FileInfo, UploadProgress, UploadResult } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Multipart file upload handler
|
|
5
|
+
*/
|
|
6
|
+
export declare class FileUploader {
|
|
7
|
+
private readonly config;
|
|
8
|
+
private readonly progressMap;
|
|
9
|
+
constructor(config?: UploadConfig);
|
|
10
|
+
/**
|
|
11
|
+
* Handle multipart form upload
|
|
12
|
+
*/
|
|
13
|
+
handleMultipart(req: IncomingMessage): Promise<UploadResult[]>;
|
|
14
|
+
/**
|
|
15
|
+
* Validate file
|
|
16
|
+
*/
|
|
17
|
+
validateFile(file: FileInfo): {
|
|
18
|
+
valid: boolean;
|
|
19
|
+
error?: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Get upload progress
|
|
23
|
+
*/
|
|
24
|
+
getProgress(fileId: string): UploadProgress | null;
|
|
25
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
import { createLogger } from '@bernierllc/logger';
|
|
9
|
+
import Busboy from 'busboy';
|
|
10
|
+
import { randomUUID } from 'crypto';
|
|
11
|
+
const logger = createLogger();
|
|
12
|
+
/**
|
|
13
|
+
* Multipart file upload handler
|
|
14
|
+
*/
|
|
15
|
+
export class FileUploader {
|
|
16
|
+
constructor(config = {}) {
|
|
17
|
+
this.progressMap = new Map();
|
|
18
|
+
this.config = {
|
|
19
|
+
maxFileSize: config.maxFileSize ?? 10 * 1024 * 1024, // 10MB default
|
|
20
|
+
maxFiles: config.maxFiles ?? 10,
|
|
21
|
+
allowedMimeTypes: config.allowedMimeTypes,
|
|
22
|
+
destination: config.destination,
|
|
23
|
+
onProgress: config.onProgress
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Handle multipart form upload
|
|
28
|
+
*/
|
|
29
|
+
async handleMultipart(req) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const results = [];
|
|
32
|
+
let fileCount = 0;
|
|
33
|
+
const errors = [];
|
|
34
|
+
const bb = Busboy({ headers: req.headers });
|
|
35
|
+
bb.on('file', (fieldname, file, info) => {
|
|
36
|
+
fileCount++;
|
|
37
|
+
if (fileCount > this.config.maxFiles) {
|
|
38
|
+
const error = new Error(`Maximum file count exceeded: ${this.config.maxFiles}`);
|
|
39
|
+
errors.push(error);
|
|
40
|
+
file.resume();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const fileId = randomUUID();
|
|
44
|
+
const fileInfo = {
|
|
45
|
+
fieldname,
|
|
46
|
+
filename: info.filename,
|
|
47
|
+
encoding: info.encoding,
|
|
48
|
+
mimeType: info.mimeType,
|
|
49
|
+
size: 0
|
|
50
|
+
};
|
|
51
|
+
// Validate file
|
|
52
|
+
const validation = this.validateFile(fileInfo);
|
|
53
|
+
if (!validation.valid) {
|
|
54
|
+
const error = new Error(validation.error || 'File validation failed');
|
|
55
|
+
errors.push(error);
|
|
56
|
+
file.resume();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Track progress
|
|
60
|
+
let bytesUploaded = 0;
|
|
61
|
+
const progress = {
|
|
62
|
+
fileId,
|
|
63
|
+
bytesUploaded: 0,
|
|
64
|
+
totalBytes: 0,
|
|
65
|
+
percentage: 0
|
|
66
|
+
};
|
|
67
|
+
this.progressMap.set(fileId, progress);
|
|
68
|
+
// Collect file data
|
|
69
|
+
const chunks = [];
|
|
70
|
+
file.on('data', (chunk) => {
|
|
71
|
+
chunks.push(chunk);
|
|
72
|
+
bytesUploaded += chunk.length;
|
|
73
|
+
fileInfo.size = bytesUploaded;
|
|
74
|
+
// Update progress
|
|
75
|
+
progress.bytesUploaded = bytesUploaded;
|
|
76
|
+
progress.totalBytes = bytesUploaded; // Will be updated when complete
|
|
77
|
+
progress.percentage = this.config.maxFileSize
|
|
78
|
+
? Math.min(100, (bytesUploaded / this.config.maxFileSize) * 100)
|
|
79
|
+
: 0;
|
|
80
|
+
if (this.config.onProgress) {
|
|
81
|
+
this.config.onProgress(progress);
|
|
82
|
+
}
|
|
83
|
+
// Check size limit
|
|
84
|
+
if (bytesUploaded > this.config.maxFileSize) {
|
|
85
|
+
const error = new Error(`File size exceeded: ${this.config.maxFileSize} bytes`);
|
|
86
|
+
errors.push(error);
|
|
87
|
+
file.resume();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
file.on('end', async () => {
|
|
91
|
+
try {
|
|
92
|
+
const buffer = Buffer.concat(chunks);
|
|
93
|
+
fileInfo.size = buffer.length;
|
|
94
|
+
// Update final progress
|
|
95
|
+
progress.totalBytes = buffer.length;
|
|
96
|
+
progress.percentage = 100;
|
|
97
|
+
if (this.config.onProgress) {
|
|
98
|
+
this.config.onProgress(progress);
|
|
99
|
+
}
|
|
100
|
+
// Determine destination
|
|
101
|
+
let destination;
|
|
102
|
+
if (typeof this.config.destination === 'function') {
|
|
103
|
+
destination = await this.config.destination(fileInfo);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
destination = this.config.destination || `/uploads/${fileId}-${fileInfo.filename}`;
|
|
107
|
+
}
|
|
108
|
+
const result = {
|
|
109
|
+
fileId,
|
|
110
|
+
filename: fileInfo.filename,
|
|
111
|
+
size: buffer.length,
|
|
112
|
+
mimeType: fileInfo.mimeType,
|
|
113
|
+
destination
|
|
114
|
+
};
|
|
115
|
+
results.push(result);
|
|
116
|
+
this.progressMap.delete(fileId);
|
|
117
|
+
logger.debug('File uploaded', { fileId, filename: fileInfo.filename, size: buffer.length });
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
121
|
+
errors.push(err);
|
|
122
|
+
logger.error('Error processing file', err);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
bb.on('finish', () => {
|
|
127
|
+
if (errors.length > 0) {
|
|
128
|
+
reject(new Error(`Upload failed: ${errors.map(e => e.message).join(', ')}`));
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
resolve(results);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
bb.on('error', (error) => {
|
|
135
|
+
reject(error);
|
|
136
|
+
});
|
|
137
|
+
req.pipe(bb);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Validate file
|
|
142
|
+
*/
|
|
143
|
+
validateFile(file) {
|
|
144
|
+
// Check MIME type
|
|
145
|
+
if (this.config.allowedMimeTypes && this.config.allowedMimeTypes.length > 0) {
|
|
146
|
+
if (!this.config.allowedMimeTypes.includes(file.mimeType)) {
|
|
147
|
+
return {
|
|
148
|
+
valid: false,
|
|
149
|
+
error: `MIME type ${file.mimeType} not allowed. Allowed types: ${this.config.allowedMimeTypes.join(', ')}`
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Check file size (if already known)
|
|
154
|
+
if (file.size > this.config.maxFileSize) {
|
|
155
|
+
return {
|
|
156
|
+
valid: false,
|
|
157
|
+
error: `File size ${file.size} exceeds maximum ${this.config.maxFileSize} bytes`
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return { valid: true };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Get upload progress
|
|
164
|
+
*/
|
|
165
|
+
getProgress(fileId) {
|
|
166
|
+
return this.progressMap.get(fileId) || null;
|
|
167
|
+
}
|
|
168
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
export { FileUploader } from './file-uploader.js';
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File upload configuration
|
|
3
|
+
*/
|
|
4
|
+
export interface UploadConfig {
|
|
5
|
+
/** Maximum file size in bytes */
|
|
6
|
+
maxFileSize?: number;
|
|
7
|
+
/** Allowed MIME types */
|
|
8
|
+
allowedMimeTypes?: string[];
|
|
9
|
+
/** Maximum number of files */
|
|
10
|
+
maxFiles?: number;
|
|
11
|
+
/** Destination path or function */
|
|
12
|
+
destination?: string | ((file: FileInfo) => Promise<string>);
|
|
13
|
+
/** Progress callback */
|
|
14
|
+
onProgress?: (progress: UploadProgress) => void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* File information
|
|
18
|
+
*/
|
|
19
|
+
export interface FileInfo {
|
|
20
|
+
/** Form field name */
|
|
21
|
+
fieldname: string;
|
|
22
|
+
/** Original filename */
|
|
23
|
+
filename: string;
|
|
24
|
+
/** File encoding */
|
|
25
|
+
encoding: string;
|
|
26
|
+
/** MIME type */
|
|
27
|
+
mimeType: string;
|
|
28
|
+
/** File size in bytes */
|
|
29
|
+
size: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Upload progress information
|
|
33
|
+
*/
|
|
34
|
+
export interface UploadProgress {
|
|
35
|
+
/** File identifier */
|
|
36
|
+
fileId: string;
|
|
37
|
+
/** Bytes uploaded */
|
|
38
|
+
bytesUploaded: number;
|
|
39
|
+
/** Total bytes */
|
|
40
|
+
totalBytes: number;
|
|
41
|
+
/** Percentage complete */
|
|
42
|
+
percentage: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Upload result
|
|
46
|
+
*/
|
|
47
|
+
export interface UploadResult {
|
|
48
|
+
/** File identifier */
|
|
49
|
+
fileId: string;
|
|
50
|
+
/** Filename */
|
|
51
|
+
filename: string;
|
|
52
|
+
/** File size */
|
|
53
|
+
size: number;
|
|
54
|
+
/** MIME type */
|
|
55
|
+
mimeType: string;
|
|
56
|
+
/** Destination path */
|
|
57
|
+
destination: string;
|
|
58
|
+
/** Optional URL */
|
|
59
|
+
url?: string;
|
|
60
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
package/jest.config.cjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
preset: 'ts-jest',
|
|
11
|
+
testEnvironment: '<rootDir>/../../../jest-environment-node-fixed.cjs',
|
|
12
|
+
roots: ['<rootDir>/__tests__'],
|
|
13
|
+
testMatch: ['**/*.test.ts'],
|
|
14
|
+
moduleNameMapper: {
|
|
15
|
+
'^(\\.{1,2}/.*)\\.js$': '$1'
|
|
16
|
+
},
|
|
17
|
+
collectCoverageFrom: [
|
|
18
|
+
'src/**/*.ts',
|
|
19
|
+
'!src/**/*.d.ts',
|
|
20
|
+
'!src/index.ts'
|
|
21
|
+
],
|
|
22
|
+
coverageThreshold: {
|
|
23
|
+
global: {
|
|
24
|
+
branches: 90,
|
|
25
|
+
functions: 90,
|
|
26
|
+
lines: 90,
|
|
27
|
+
statements: 90
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
coverageDirectory: 'coverage',
|
|
31
|
+
verbose: true
|
|
32
|
+
};
|
|
33
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bernierllc/file-uploader",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Multipart file upload handler with validation and progress tracking",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"upload",
|
|
16
|
+
"multipart",
|
|
17
|
+
"file",
|
|
18
|
+
"media",
|
|
19
|
+
"form-data"
|
|
20
|
+
],
|
|
21
|
+
"author": "Bernier LLC",
|
|
22
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"busboy": "^1.6.0",
|
|
25
|
+
"uuid": "^9.0.0",
|
|
26
|
+
"@bernierllc/logger": "1.0.3"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/busboy": "^1.5.0",
|
|
30
|
+
"@types/jest": "^29.5.0",
|
|
31
|
+
"@types/node": "^20.0.0",
|
|
32
|
+
"@types/uuid": "^9.0.0",
|
|
33
|
+
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
|
34
|
+
"@typescript-eslint/parser": "^6.0.0",
|
|
35
|
+
"eslint": "^8.0.0",
|
|
36
|
+
"jest": "^29.5.0",
|
|
37
|
+
"rimraf": "^5.0.0",
|
|
38
|
+
"ts-jest": "^29.1.0",
|
|
39
|
+
"typescript": "^5.0.0"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public",
|
|
43
|
+
"registry": "https://registry.npmjs.org/"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc",
|
|
47
|
+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
|
48
|
+
"test:run": "node --experimental-vm-modules node_modules/jest/bin/jest.js --no-watch",
|
|
49
|
+
"test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --no-watch",
|
|
50
|
+
"lint": "eslint src __tests__ --ext .ts",
|
|
51
|
+
"lint:fix": "eslint src __tests__ --ext .ts --fix",
|
|
52
|
+
"clean": "rimraf dist"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createLogger } from '@bernierllc/logger';
|
|
10
|
+
import Busboy from 'busboy';
|
|
11
|
+
import type { IncomingMessage } from 'http';
|
|
12
|
+
import { randomUUID } from 'crypto';
|
|
13
|
+
import type { UploadConfig, FileInfo, UploadProgress, UploadResult } from './types.js';
|
|
14
|
+
|
|
15
|
+
const logger = createLogger();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Multipart file upload handler
|
|
19
|
+
*/
|
|
20
|
+
export class FileUploader {
|
|
21
|
+
private readonly config: Required<Pick<UploadConfig, 'maxFileSize' | 'maxFiles'>> & Partial<UploadConfig>;
|
|
22
|
+
private readonly progressMap = new Map<string, UploadProgress>();
|
|
23
|
+
|
|
24
|
+
constructor(config: UploadConfig = {}) {
|
|
25
|
+
this.config = {
|
|
26
|
+
maxFileSize: config.maxFileSize ?? 10 * 1024 * 1024, // 10MB default
|
|
27
|
+
maxFiles: config.maxFiles ?? 10,
|
|
28
|
+
allowedMimeTypes: config.allowedMimeTypes,
|
|
29
|
+
destination: config.destination,
|
|
30
|
+
onProgress: config.onProgress
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Handle multipart form upload
|
|
36
|
+
*/
|
|
37
|
+
async handleMultipart(req: IncomingMessage): Promise<UploadResult[]> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const results: UploadResult[] = [];
|
|
40
|
+
let fileCount = 0;
|
|
41
|
+
const errors: Error[] = [];
|
|
42
|
+
|
|
43
|
+
const bb = Busboy({ headers: req.headers });
|
|
44
|
+
|
|
45
|
+
bb.on('file', (fieldname, file, info) => {
|
|
46
|
+
fileCount++;
|
|
47
|
+
|
|
48
|
+
if (fileCount > this.config.maxFiles) {
|
|
49
|
+
const error = new Error(`Maximum file count exceeded: ${this.config.maxFiles}`);
|
|
50
|
+
errors.push(error);
|
|
51
|
+
file.resume();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const fileId = randomUUID();
|
|
56
|
+
const fileInfo: FileInfo = {
|
|
57
|
+
fieldname,
|
|
58
|
+
filename: info.filename,
|
|
59
|
+
encoding: info.encoding,
|
|
60
|
+
mimeType: info.mimeType,
|
|
61
|
+
size: 0
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Validate file
|
|
65
|
+
const validation = this.validateFile(fileInfo);
|
|
66
|
+
if (!validation.valid) {
|
|
67
|
+
const error = new Error(validation.error || 'File validation failed');
|
|
68
|
+
errors.push(error);
|
|
69
|
+
file.resume();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Track progress
|
|
74
|
+
let bytesUploaded = 0;
|
|
75
|
+
const progress: UploadProgress = {
|
|
76
|
+
fileId,
|
|
77
|
+
bytesUploaded: 0,
|
|
78
|
+
totalBytes: 0,
|
|
79
|
+
percentage: 0
|
|
80
|
+
};
|
|
81
|
+
this.progressMap.set(fileId, progress);
|
|
82
|
+
|
|
83
|
+
// Collect file data
|
|
84
|
+
const chunks: Buffer[] = [];
|
|
85
|
+
|
|
86
|
+
file.on('data', (chunk: Buffer) => {
|
|
87
|
+
chunks.push(chunk);
|
|
88
|
+
bytesUploaded += chunk.length;
|
|
89
|
+
fileInfo.size = bytesUploaded;
|
|
90
|
+
|
|
91
|
+
// Update progress
|
|
92
|
+
progress.bytesUploaded = bytesUploaded;
|
|
93
|
+
progress.totalBytes = bytesUploaded; // Will be updated when complete
|
|
94
|
+
progress.percentage = this.config.maxFileSize
|
|
95
|
+
? Math.min(100, (bytesUploaded / this.config.maxFileSize) * 100)
|
|
96
|
+
: 0;
|
|
97
|
+
|
|
98
|
+
if (this.config.onProgress) {
|
|
99
|
+
this.config.onProgress(progress);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check size limit
|
|
103
|
+
if (bytesUploaded > this.config.maxFileSize) {
|
|
104
|
+
const error = new Error(`File size exceeded: ${this.config.maxFileSize} bytes`);
|
|
105
|
+
errors.push(error);
|
|
106
|
+
file.resume();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
file.on('end', async () => {
|
|
111
|
+
try {
|
|
112
|
+
const buffer = Buffer.concat(chunks as readonly Uint8Array[]);
|
|
113
|
+
fileInfo.size = buffer.length;
|
|
114
|
+
|
|
115
|
+
// Update final progress
|
|
116
|
+
progress.totalBytes = buffer.length;
|
|
117
|
+
progress.percentage = 100;
|
|
118
|
+
if (this.config.onProgress) {
|
|
119
|
+
this.config.onProgress(progress);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Determine destination
|
|
123
|
+
let destination: string;
|
|
124
|
+
if (typeof this.config.destination === 'function') {
|
|
125
|
+
destination = await this.config.destination(fileInfo);
|
|
126
|
+
} else {
|
|
127
|
+
destination = this.config.destination || `/uploads/${fileId}-${fileInfo.filename}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const result: UploadResult = {
|
|
131
|
+
fileId,
|
|
132
|
+
filename: fileInfo.filename,
|
|
133
|
+
size: buffer.length,
|
|
134
|
+
mimeType: fileInfo.mimeType,
|
|
135
|
+
destination
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
results.push(result);
|
|
139
|
+
this.progressMap.delete(fileId);
|
|
140
|
+
|
|
141
|
+
logger.debug('File uploaded', { fileId, filename: fileInfo.filename, size: buffer.length });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
144
|
+
errors.push(err);
|
|
145
|
+
logger.error('Error processing file', err);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
bb.on('finish', () => {
|
|
151
|
+
if (errors.length > 0) {
|
|
152
|
+
reject(new Error(`Upload failed: ${errors.map(e => e.message).join(', ')}`));
|
|
153
|
+
} else {
|
|
154
|
+
resolve(results);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
bb.on('error', (error) => {
|
|
159
|
+
reject(error);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
req.pipe(bb);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Validate file
|
|
168
|
+
*/
|
|
169
|
+
validateFile(file: FileInfo): { valid: boolean; error?: string } {
|
|
170
|
+
// Check MIME type
|
|
171
|
+
if (this.config.allowedMimeTypes && this.config.allowedMimeTypes.length > 0) {
|
|
172
|
+
if (!this.config.allowedMimeTypes.includes(file.mimeType)) {
|
|
173
|
+
return {
|
|
174
|
+
valid: false,
|
|
175
|
+
error: `MIME type ${file.mimeType} not allowed. Allowed types: ${this.config.allowedMimeTypes.join(', ')}`
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check file size (if already known)
|
|
181
|
+
if (file.size > this.config.maxFileSize) {
|
|
182
|
+
return {
|
|
183
|
+
valid: false,
|
|
184
|
+
error: `File size ${file.size} exceeds maximum ${this.config.maxFileSize} bytes`
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { valid: true };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get upload progress
|
|
193
|
+
*/
|
|
194
|
+
getProgress(fileId: string): UploadProgress | null {
|
|
195
|
+
return this.progressMap.get(fileId) || null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { FileUploader } from './file-uploader.js';
|
|
10
|
+
export type {
|
|
11
|
+
UploadConfig,
|
|
12
|
+
FileInfo,
|
|
13
|
+
UploadProgress,
|
|
14
|
+
UploadResult
|
|
15
|
+
} from './types.js';
|
|
16
|
+
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* File upload configuration
|
|
12
|
+
*/
|
|
13
|
+
export interface UploadConfig {
|
|
14
|
+
/** Maximum file size in bytes */
|
|
15
|
+
maxFileSize?: number;
|
|
16
|
+
/** Allowed MIME types */
|
|
17
|
+
allowedMimeTypes?: string[];
|
|
18
|
+
/** Maximum number of files */
|
|
19
|
+
maxFiles?: number;
|
|
20
|
+
/** Destination path or function */
|
|
21
|
+
destination?: string | ((file: FileInfo) => Promise<string>);
|
|
22
|
+
/** Progress callback */
|
|
23
|
+
onProgress?: (progress: UploadProgress) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* File information
|
|
28
|
+
*/
|
|
29
|
+
export interface FileInfo {
|
|
30
|
+
/** Form field name */
|
|
31
|
+
fieldname: string;
|
|
32
|
+
/** Original filename */
|
|
33
|
+
filename: string;
|
|
34
|
+
/** File encoding */
|
|
35
|
+
encoding: string;
|
|
36
|
+
/** MIME type */
|
|
37
|
+
mimeType: string;
|
|
38
|
+
/** File size in bytes */
|
|
39
|
+
size: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Upload progress information
|
|
44
|
+
*/
|
|
45
|
+
export interface UploadProgress {
|
|
46
|
+
/** File identifier */
|
|
47
|
+
fileId: string;
|
|
48
|
+
/** Bytes uploaded */
|
|
49
|
+
bytesUploaded: number;
|
|
50
|
+
/** Total bytes */
|
|
51
|
+
totalBytes: number;
|
|
52
|
+
/** Percentage complete */
|
|
53
|
+
percentage: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Upload result
|
|
58
|
+
*/
|
|
59
|
+
export interface UploadResult {
|
|
60
|
+
/** File identifier */
|
|
61
|
+
fileId: string;
|
|
62
|
+
/** Filename */
|
|
63
|
+
filename: string;
|
|
64
|
+
/** File size */
|
|
65
|
+
size: number;
|
|
66
|
+
/** MIME type */
|
|
67
|
+
mimeType: string;
|
|
68
|
+
/** Destination path */
|
|
69
|
+
destination: string;
|
|
70
|
+
/** Optional URL */
|
|
71
|
+
url?: string;
|
|
72
|
+
}
|
|
73
|
+
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2020"],
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"moduleResolution": "bundler",
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"noImplicitReturns": true,
|
|
18
|
+
"noFallthroughCasesInSwitch": true,
|
|
19
|
+
"allowSyntheticDefaultImports": true
|
|
20
|
+
},
|
|
21
|
+
"include": ["src/**/*"],
|
|
22
|
+
"exclude": ["node_modules", "dist", "__tests__"]
|
|
23
|
+
}
|
|
24
|
+
|