@onebun/core 0.2.0 → 0.2.2
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/package.json +1 -1
- package/src/application/application.test.ts +36 -0
- package/src/application/application.ts +258 -6
- package/src/application/multi-service-application.ts +2 -0
- package/src/application/multi-service.types.ts +1 -1
- package/src/decorators/decorators.test.ts +202 -12
- package/src/decorators/decorators.ts +228 -7
- package/src/docs-examples.test.ts +1339 -254
- package/src/file/index.ts +8 -0
- package/src/file/onebun-file.test.ts +315 -0
- package/src/file/onebun-file.ts +304 -0
- package/src/index.ts +20 -0
- package/src/module/controller.ts +96 -10
- package/src/module/index.ts +2 -1
- package/src/module/lifecycle.ts +13 -0
- package/src/module/middleware.ts +76 -0
- package/src/module/module.test.ts +138 -1
- package/src/module/module.ts +127 -2
- package/src/types.ts +169 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
it,
|
|
4
|
+
expect,
|
|
5
|
+
} from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
OneBunFile,
|
|
9
|
+
MimeType,
|
|
10
|
+
matchMimeType,
|
|
11
|
+
validateFile,
|
|
12
|
+
} from './onebun-file';
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// OneBunFile Class Tests
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
describe('OneBunFile', () => {
|
|
19
|
+
describe('constructor', () => {
|
|
20
|
+
it('should create from File object', () => {
|
|
21
|
+
const file = new File(['hello world'], 'test.txt', { type: 'text/plain' });
|
|
22
|
+
const oneBunFile = new OneBunFile(file);
|
|
23
|
+
|
|
24
|
+
expect(oneBunFile.name).toBe('test.txt');
|
|
25
|
+
expect(oneBunFile.size).toBe(11);
|
|
26
|
+
expect(oneBunFile.type).toStartWith('text/plain');
|
|
27
|
+
expect(oneBunFile.lastModified).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should preserve file metadata', () => {
|
|
31
|
+
const file = new File(['data'], 'image.png', {
|
|
32
|
+
type: 'image/png',
|
|
33
|
+
lastModified: 1700000000000,
|
|
34
|
+
});
|
|
35
|
+
const oneBunFile = new OneBunFile(file);
|
|
36
|
+
|
|
37
|
+
expect(oneBunFile.name).toBe('image.png');
|
|
38
|
+
expect(oneBunFile.type).toBe('image/png');
|
|
39
|
+
expect(oneBunFile.lastModified).toBe(1700000000000);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('fromBase64', () => {
|
|
44
|
+
it('should create from raw base64 string', () => {
|
|
45
|
+
const base64 = btoa('hello world');
|
|
46
|
+
const file = OneBunFile.fromBase64(base64, 'test.txt', 'text/plain');
|
|
47
|
+
|
|
48
|
+
expect(file.name).toBe('test.txt');
|
|
49
|
+
expect(file.size).toBe(11);
|
|
50
|
+
expect(file.type).toStartWith('text/plain');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should create with default name and mime type', () => {
|
|
54
|
+
const base64 = btoa('data');
|
|
55
|
+
const file = OneBunFile.fromBase64(base64);
|
|
56
|
+
|
|
57
|
+
expect(file.name).toBe('upload');
|
|
58
|
+
expect(file.type).toBe('application/octet-stream');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should handle data URI format', () => {
|
|
62
|
+
const base64 = btoa('png-data');
|
|
63
|
+
const dataUri = `data:image/png;base64,${base64}`;
|
|
64
|
+
const file = OneBunFile.fromBase64(dataUri, 'photo.png');
|
|
65
|
+
|
|
66
|
+
expect(file.type).toBe('image/png');
|
|
67
|
+
expect(file.name).toBe('photo.png');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should detect mime type from data URI even when explicit type is given', () => {
|
|
71
|
+
const base64 = btoa('svg-data');
|
|
72
|
+
const dataUri = `data:image/svg+xml;base64,${base64}`;
|
|
73
|
+
// Data URI mime type should override the explicit one
|
|
74
|
+
const file = OneBunFile.fromBase64(dataUri, 'image.svg', 'text/plain');
|
|
75
|
+
|
|
76
|
+
expect(file.type).toBe('image/svg+xml');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('toBase64', () => {
|
|
81
|
+
it('should convert file to base64', async () => {
|
|
82
|
+
const content = 'hello world';
|
|
83
|
+
const file = new File([content], 'test.txt', { type: 'text/plain' });
|
|
84
|
+
const oneBunFile = new OneBunFile(file);
|
|
85
|
+
|
|
86
|
+
const base64 = await oneBunFile.toBase64();
|
|
87
|
+
expect(base64).toBe(btoa(content));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should cache base64 result', async () => {
|
|
91
|
+
const file = new File(['test'], 'test.txt', { type: 'text/plain' });
|
|
92
|
+
const oneBunFile = new OneBunFile(file);
|
|
93
|
+
|
|
94
|
+
const first = await oneBunFile.toBase64();
|
|
95
|
+
const second = await oneBunFile.toBase64();
|
|
96
|
+
expect(first).toBe(second);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should return cached base64 for fromBase64 files', async () => {
|
|
100
|
+
const original = btoa('cached data');
|
|
101
|
+
const file = OneBunFile.fromBase64(original, 'test.txt');
|
|
102
|
+
|
|
103
|
+
const result = await file.toBase64();
|
|
104
|
+
expect(result).toBe(original);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('toBuffer', () => {
|
|
109
|
+
it('should convert file to Buffer', async () => {
|
|
110
|
+
const content = 'buffer test';
|
|
111
|
+
const file = new File([content], 'test.txt', { type: 'text/plain' });
|
|
112
|
+
const oneBunFile = new OneBunFile(file);
|
|
113
|
+
|
|
114
|
+
const buffer = await oneBunFile.toBuffer();
|
|
115
|
+
expect(buffer).toBeInstanceOf(Buffer);
|
|
116
|
+
expect(buffer.toString()).toBe(content);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('toArrayBuffer', () => {
|
|
121
|
+
it('should convert file to ArrayBuffer', async () => {
|
|
122
|
+
const content = 'arraybuffer test';
|
|
123
|
+
const file = new File([content], 'test.txt', { type: 'text/plain' });
|
|
124
|
+
const oneBunFile = new OneBunFile(file);
|
|
125
|
+
|
|
126
|
+
const ab = await oneBunFile.toArrayBuffer();
|
|
127
|
+
expect(ab).toBeInstanceOf(ArrayBuffer);
|
|
128
|
+
expect(ab.byteLength).toBe(content.length);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('toBlob', () => {
|
|
133
|
+
it('should return underlying Blob', () => {
|
|
134
|
+
const file = new File(['blob test'], 'test.txt', { type: 'text/plain' });
|
|
135
|
+
const oneBunFile = new OneBunFile(file);
|
|
136
|
+
|
|
137
|
+
const blob = oneBunFile.toBlob();
|
|
138
|
+
expect(blob).toBeInstanceOf(Blob);
|
|
139
|
+
expect(blob.size).toBe(9);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// =============================================================================
|
|
145
|
+
// MimeType Enum Tests
|
|
146
|
+
// =============================================================================
|
|
147
|
+
|
|
148
|
+
describe('MimeType', () => {
|
|
149
|
+
it('should have wildcard values', () => {
|
|
150
|
+
expect(String(MimeType.ANY)).toBe('*/*');
|
|
151
|
+
expect(String(MimeType.ANY_IMAGE)).toBe('image/*');
|
|
152
|
+
expect(String(MimeType.ANY_VIDEO)).toBe('video/*');
|
|
153
|
+
expect(String(MimeType.ANY_AUDIO)).toBe('audio/*');
|
|
154
|
+
expect(String(MimeType.ANY_TEXT)).toBe('text/*');
|
|
155
|
+
expect(String(MimeType.ANY_APPLICATION)).toBe('application/*');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should have image types', () => {
|
|
159
|
+
expect(String(MimeType.PNG)).toBe('image/png');
|
|
160
|
+
expect(String(MimeType.JPEG)).toBe('image/jpeg');
|
|
161
|
+
expect(String(MimeType.GIF)).toBe('image/gif');
|
|
162
|
+
expect(String(MimeType.WEBP)).toBe('image/webp');
|
|
163
|
+
expect(String(MimeType.SVG)).toBe('image/svg+xml');
|
|
164
|
+
expect(String(MimeType.AVIF)).toBe('image/avif');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should have document types', () => {
|
|
168
|
+
expect(String(MimeType.PDF)).toBe('application/pdf');
|
|
169
|
+
expect(String(MimeType.JSON)).toBe('application/json');
|
|
170
|
+
expect(String(MimeType.CSV)).toBe('text/csv');
|
|
171
|
+
expect(String(MimeType.ZIP)).toBe('application/zip');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should have media types', () => {
|
|
175
|
+
expect(String(MimeType.MP4)).toBe('video/mp4');
|
|
176
|
+
expect(String(MimeType.MP3)).toBe('audio/mpeg');
|
|
177
|
+
expect(String(MimeType.WAV)).toBe('audio/wav');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should have text types', () => {
|
|
181
|
+
expect(String(MimeType.PLAIN)).toBe('text/plain');
|
|
182
|
+
expect(String(MimeType.HTML)).toBe('text/html');
|
|
183
|
+
expect(String(MimeType.CSS)).toBe('text/css');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should have binary type', () => {
|
|
187
|
+
expect(String(MimeType.OCTET_STREAM)).toBe('application/octet-stream');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// =============================================================================
|
|
192
|
+
// matchMimeType Tests
|
|
193
|
+
// =============================================================================
|
|
194
|
+
|
|
195
|
+
describe('matchMimeType', () => {
|
|
196
|
+
it('should match exact MIME types', () => {
|
|
197
|
+
expect(matchMimeType('image/png', 'image/png')).toBe(true);
|
|
198
|
+
expect(matchMimeType('text/plain', 'text/plain')).toBe(true);
|
|
199
|
+
expect(matchMimeType('application/pdf', 'application/pdf')).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should not match different MIME types', () => {
|
|
203
|
+
expect(matchMimeType('image/png', 'image/jpeg')).toBe(false);
|
|
204
|
+
expect(matchMimeType('text/plain', 'text/html')).toBe(false);
|
|
205
|
+
expect(matchMimeType('image/png', 'video/mp4')).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should match wildcard subtypes', () => {
|
|
209
|
+
expect(matchMimeType('image/png', 'image/*')).toBe(true);
|
|
210
|
+
expect(matchMimeType('image/jpeg', 'image/*')).toBe(true);
|
|
211
|
+
expect(matchMimeType('image/webp', 'image/*')).toBe(true);
|
|
212
|
+
expect(matchMimeType('video/mp4', 'video/*')).toBe(true);
|
|
213
|
+
expect(matchMimeType('audio/mpeg', 'audio/*')).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should not match wrong type with wildcard', () => {
|
|
217
|
+
expect(matchMimeType('video/mp4', 'image/*')).toBe(false);
|
|
218
|
+
expect(matchMimeType('text/plain', 'image/*')).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should match universal wildcard', () => {
|
|
222
|
+
expect(matchMimeType('image/png', '*/*')).toBe(true);
|
|
223
|
+
expect(matchMimeType('video/mp4', '*/*')).toBe(true);
|
|
224
|
+
expect(matchMimeType('application/json', '*/*')).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should work with MimeType enum values', () => {
|
|
228
|
+
expect(matchMimeType('image/png', MimeType.ANY_IMAGE)).toBe(true);
|
|
229
|
+
expect(matchMimeType('image/png', MimeType.PNG)).toBe(true);
|
|
230
|
+
expect(matchMimeType('image/png', MimeType.JPEG)).toBe(false);
|
|
231
|
+
expect(matchMimeType('application/pdf', MimeType.ANY_APPLICATION)).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// =============================================================================
|
|
236
|
+
// validateFile Tests
|
|
237
|
+
// =============================================================================
|
|
238
|
+
|
|
239
|
+
describe('validateFile', () => {
|
|
240
|
+
it('should pass validation when no constraints', () => {
|
|
241
|
+
const file = new File(['data'], 'test.txt', { type: 'text/plain' });
|
|
242
|
+
const oneBunFile = new OneBunFile(file);
|
|
243
|
+
|
|
244
|
+
expect(() => validateFile(oneBunFile, {})).not.toThrow();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should pass when file is within size limit', () => {
|
|
248
|
+
const file = new File(['small'], 'test.txt', { type: 'text/plain' });
|
|
249
|
+
const oneBunFile = new OneBunFile(file);
|
|
250
|
+
|
|
251
|
+
expect(() => validateFile(oneBunFile, { maxSize: 1024 })).not.toThrow();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('should throw when file exceeds size limit', () => {
|
|
255
|
+
const file = new File(['x'.repeat(100)], 'test.txt', { type: 'text/plain' });
|
|
256
|
+
const oneBunFile = new OneBunFile(file);
|
|
257
|
+
|
|
258
|
+
expect(() => validateFile(oneBunFile, { maxSize: 10 })).toThrow(
|
|
259
|
+
/exceeds maximum size/,
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should include field name in size error', () => {
|
|
264
|
+
const file = new File(['x'.repeat(100)], 'test.txt', { type: 'text/plain' });
|
|
265
|
+
const oneBunFile = new OneBunFile(file);
|
|
266
|
+
|
|
267
|
+
expect(() => validateFile(oneBunFile, { maxSize: 10 }, 'avatar')).toThrow(
|
|
268
|
+
/File "avatar" exceeds maximum size/,
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should pass when MIME type matches', () => {
|
|
273
|
+
const file = new File(['data'], 'test.png', { type: 'image/png' });
|
|
274
|
+
const oneBunFile = new OneBunFile(file);
|
|
275
|
+
|
|
276
|
+
expect(() => validateFile(oneBunFile, { mimeTypes: ['image/*'] })).not.toThrow();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should pass when MIME type matches one of multiple', () => {
|
|
280
|
+
const file = new File(['data'], 'test.png', { type: 'image/png' });
|
|
281
|
+
const oneBunFile = new OneBunFile(file);
|
|
282
|
+
|
|
283
|
+
expect(() =>
|
|
284
|
+
validateFile(oneBunFile, { mimeTypes: ['application/pdf', 'image/png'] }),
|
|
285
|
+
).not.toThrow();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should throw when MIME type does not match', () => {
|
|
289
|
+
const file = new File(['data'], 'test.txt', { type: 'text/plain' });
|
|
290
|
+
const oneBunFile = new OneBunFile(file);
|
|
291
|
+
|
|
292
|
+
expect(() => validateFile(oneBunFile, { mimeTypes: ['image/*'] })).toThrow(
|
|
293
|
+
/invalid MIME type/,
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should include field name in MIME error', () => {
|
|
298
|
+
const file = new File(['data'], 'test.txt', { type: 'text/plain' });
|
|
299
|
+
const oneBunFile = new OneBunFile(file);
|
|
300
|
+
|
|
301
|
+
expect(() =>
|
|
302
|
+
validateFile(oneBunFile, { mimeTypes: ['image/*'] }, 'document'),
|
|
303
|
+
).toThrow(/File "document" has invalid MIME type/);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should validate both size and MIME type', () => {
|
|
307
|
+
const file = new File(['x'.repeat(100)], 'big.txt', { type: 'text/plain' });
|
|
308
|
+
const oneBunFile = new OneBunFile(file);
|
|
309
|
+
|
|
310
|
+
// Fails on size first
|
|
311
|
+
expect(() =>
|
|
312
|
+
validateFile(oneBunFile, { maxSize: 10, mimeTypes: ['image/*'] }),
|
|
313
|
+
).toThrow(/exceeds maximum size/);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OneBunFile - Unified file wrapper for file uploads
|
|
3
|
+
*
|
|
4
|
+
* Provides a consistent interface for files uploaded via:
|
|
5
|
+
* - multipart/form-data (standard file upload)
|
|
6
|
+
* - JSON body with base64-encoded data
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* // Access file metadata
|
|
11
|
+
* console.log(file.name); // "photo.png"
|
|
12
|
+
* console.log(file.size); // 1024
|
|
13
|
+
* console.log(file.type); // "image/png"
|
|
14
|
+
*
|
|
15
|
+
* // Convert to base64
|
|
16
|
+
* const base64 = await file.toBase64();
|
|
17
|
+
*
|
|
18
|
+
* // Write to disk
|
|
19
|
+
* await file.writeTo('./uploads/photo.png');
|
|
20
|
+
*
|
|
21
|
+
* // Get as Buffer
|
|
22
|
+
* const buffer = await file.toBuffer();
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export class OneBunFile {
|
|
26
|
+
/** File name (e.g., "photo.png") */
|
|
27
|
+
readonly name: string;
|
|
28
|
+
/** File size in bytes */
|
|
29
|
+
readonly size: number;
|
|
30
|
+
/** MIME type (e.g., "image/png") */
|
|
31
|
+
readonly type: string;
|
|
32
|
+
/** Last modified timestamp */
|
|
33
|
+
readonly lastModified: number;
|
|
34
|
+
|
|
35
|
+
private readonly _blob: Blob;
|
|
36
|
+
private _base64Cache?: string;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create from a Web API File (typically from multipart/form-data)
|
|
40
|
+
*/
|
|
41
|
+
constructor(file: File) {
|
|
42
|
+
this.name = file.name;
|
|
43
|
+
this.size = file.size;
|
|
44
|
+
this.type = file.type;
|
|
45
|
+
this.lastModified = file.lastModified;
|
|
46
|
+
this._blob = file;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create OneBunFile from a base64-encoded string
|
|
51
|
+
*
|
|
52
|
+
* @param data - Base64-encoded file data (with or without data URI prefix)
|
|
53
|
+
* @param filename - File name (default: "upload")
|
|
54
|
+
* @param mimeType - MIME type (default: "application/octet-stream")
|
|
55
|
+
* @returns OneBunFile instance
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```typescript
|
|
59
|
+
* // From raw base64
|
|
60
|
+
* const file = OneBunFile.fromBase64('iVBORw0KGgo...', 'photo.png', 'image/png');
|
|
61
|
+
*
|
|
62
|
+
* // From data URI
|
|
63
|
+
* const file = OneBunFile.fromBase64('data:image/png;base64,iVBORw0KGgo...', 'photo.png');
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
static fromBase64(
|
|
67
|
+
data: string,
|
|
68
|
+
filename: string = 'upload',
|
|
69
|
+
mimeType: string = MimeType.OCTET_STREAM,
|
|
70
|
+
): OneBunFile {
|
|
71
|
+
// Handle data URI format: "data:image/png;base64,iVBORw0KGgo..."
|
|
72
|
+
let base64Data = data;
|
|
73
|
+
let detectedMimeType = mimeType;
|
|
74
|
+
|
|
75
|
+
const dataUriMatch = data.match(/^data:([^;]+);base64,(.+)$/);
|
|
76
|
+
if (dataUriMatch) {
|
|
77
|
+
detectedMimeType = dataUriMatch[1];
|
|
78
|
+
base64Data = dataUriMatch[2];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const binaryString = atob(base64Data);
|
|
82
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
83
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
84
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const file = new File([bytes], filename, {
|
|
88
|
+
type: detectedMimeType,
|
|
89
|
+
lastModified: Date.now(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const oneBunFile = new OneBunFile(file);
|
|
93
|
+
// Cache the base64 since we already have it
|
|
94
|
+
oneBunFile._base64Cache = base64Data;
|
|
95
|
+
|
|
96
|
+
return oneBunFile;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Convert file content to a base64-encoded string.
|
|
101
|
+
* Result is cached after the first call.
|
|
102
|
+
*
|
|
103
|
+
* @returns Base64-encoded string (without data URI prefix)
|
|
104
|
+
*/
|
|
105
|
+
async toBase64(): Promise<string> {
|
|
106
|
+
if (this._base64Cache) {
|
|
107
|
+
return this._base64Cache;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const arrayBuffer = await this._blob.arrayBuffer();
|
|
111
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
112
|
+
let binary = '';
|
|
113
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
114
|
+
binary += String.fromCharCode(bytes[i]);
|
|
115
|
+
}
|
|
116
|
+
this._base64Cache = btoa(binary);
|
|
117
|
+
|
|
118
|
+
return this._base64Cache;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Convert file content to a Buffer
|
|
123
|
+
*/
|
|
124
|
+
async toBuffer(): Promise<Buffer> {
|
|
125
|
+
const arrayBuffer = await this._blob.arrayBuffer();
|
|
126
|
+
|
|
127
|
+
return Buffer.from(arrayBuffer);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Convert file content to an ArrayBuffer
|
|
132
|
+
*/
|
|
133
|
+
async toArrayBuffer(): Promise<ArrayBuffer> {
|
|
134
|
+
return await this._blob.arrayBuffer();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get the underlying Blob
|
|
139
|
+
*/
|
|
140
|
+
toBlob(): Blob {
|
|
141
|
+
return this._blob;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Write file to disk using Bun.write()
|
|
146
|
+
*
|
|
147
|
+
* @param path - Destination file path
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* ```typescript
|
|
151
|
+
* await file.writeTo('./uploads/photo.png');
|
|
152
|
+
* await file.writeTo(`./uploads/${file.name}`);
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
async writeTo(path: string): Promise<void> {
|
|
156
|
+
await Bun.write(path, this._blob);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// =============================================================================
|
|
161
|
+
// MimeType Enum
|
|
162
|
+
// =============================================================================
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Common MIME types for use with file upload decorators.
|
|
166
|
+
* Includes wildcard groups for matching entire categories.
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ```typescript
|
|
170
|
+
* // Accept only images
|
|
171
|
+
* \@UploadedFile('avatar', { mimeTypes: [MimeType.ANY_IMAGE] })
|
|
172
|
+
*
|
|
173
|
+
* // Accept specific types
|
|
174
|
+
* \@UploadedFile('avatar', { mimeTypes: [MimeType.PNG, MimeType.JPEG, MimeType.WEBP] })
|
|
175
|
+
*
|
|
176
|
+
* // Mix enum values and string literals
|
|
177
|
+
* \@UploadedFile('doc', { mimeTypes: [MimeType.PDF, 'application/msword'] })
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
export enum MimeType {
|
|
181
|
+
// Wildcard groups
|
|
182
|
+
ANY = '*/*',
|
|
183
|
+
ANY_IMAGE = 'image/*',
|
|
184
|
+
ANY_VIDEO = 'video/*',
|
|
185
|
+
ANY_AUDIO = 'audio/*',
|
|
186
|
+
ANY_TEXT = 'text/*',
|
|
187
|
+
ANY_APPLICATION = 'application/*',
|
|
188
|
+
|
|
189
|
+
// Images
|
|
190
|
+
PNG = 'image/png',
|
|
191
|
+
JPEG = 'image/jpeg',
|
|
192
|
+
GIF = 'image/gif',
|
|
193
|
+
WEBP = 'image/webp',
|
|
194
|
+
SVG = 'image/svg+xml',
|
|
195
|
+
ICO = 'image/x-icon',
|
|
196
|
+
BMP = 'image/bmp',
|
|
197
|
+
AVIF = 'image/avif',
|
|
198
|
+
|
|
199
|
+
// Documents
|
|
200
|
+
PDF = 'application/pdf',
|
|
201
|
+
|
|
202
|
+
JSON = 'application/json',
|
|
203
|
+
XML = 'application/xml',
|
|
204
|
+
ZIP = 'application/zip',
|
|
205
|
+
GZIP = 'application/gzip',
|
|
206
|
+
CSV = 'text/csv',
|
|
207
|
+
XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
208
|
+
DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
209
|
+
|
|
210
|
+
// Video
|
|
211
|
+
MP4 = 'video/mp4',
|
|
212
|
+
WEBM = 'video/webm',
|
|
213
|
+
|
|
214
|
+
// Audio
|
|
215
|
+
MP3 = 'audio/mpeg',
|
|
216
|
+
WAV = 'audio/wav',
|
|
217
|
+
OGG = 'audio/ogg',
|
|
218
|
+
|
|
219
|
+
// Text
|
|
220
|
+
PLAIN = 'text/plain',
|
|
221
|
+
HTML = 'text/html',
|
|
222
|
+
CSS = 'text/css',
|
|
223
|
+
JAVASCRIPT = 'text/javascript',
|
|
224
|
+
|
|
225
|
+
// Binary
|
|
226
|
+
OCTET_STREAM = 'application/octet-stream',
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// =============================================================================
|
|
230
|
+
// File Validation Helpers
|
|
231
|
+
// =============================================================================
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Check if a MIME type matches a pattern (supports wildcards).
|
|
235
|
+
*
|
|
236
|
+
* @param actual - The actual MIME type of the file (e.g., "image/png")
|
|
237
|
+
* @param pattern - The pattern to match against (e.g., "image/*", "image/png", "*\/*")
|
|
238
|
+
* @returns true if the actual MIME type matches the pattern
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* ```typescript
|
|
242
|
+
* matchMimeType('image/png', 'image/*') // true
|
|
243
|
+
* matchMimeType('image/png', 'image/png') // true
|
|
244
|
+
* matchMimeType('image/png', 'video/*') // false
|
|
245
|
+
* matchMimeType('image/png', '*\/*') // true
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
export function matchMimeType(actual: string, pattern: string): boolean {
|
|
249
|
+
if (pattern === '*/*') {
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const [patternType, patternSubtype] = pattern.split('/');
|
|
254
|
+
const [actualType, actualSubtype] = actual.split('/');
|
|
255
|
+
|
|
256
|
+
if (patternSubtype === '*') {
|
|
257
|
+
return patternType === actualType;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return patternType === actualType && patternSubtype === actualSubtype;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Options for file validation
|
|
265
|
+
*/
|
|
266
|
+
export interface FileValidationOptions {
|
|
267
|
+
/** Maximum file size in bytes */
|
|
268
|
+
maxSize?: number;
|
|
269
|
+
/** Allowed MIME types (supports wildcards like 'image/*') */
|
|
270
|
+
mimeTypes?: string[];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Validate a file against size and MIME type constraints.
|
|
275
|
+
* Throws an Error with a descriptive message if validation fails.
|
|
276
|
+
*
|
|
277
|
+
* @param file - The OneBunFile to validate
|
|
278
|
+
* @param options - Validation options
|
|
279
|
+
* @param fieldName - Field name for error messages
|
|
280
|
+
*/
|
|
281
|
+
export function validateFile(
|
|
282
|
+
file: OneBunFile,
|
|
283
|
+
options: FileValidationOptions,
|
|
284
|
+
fieldName?: string,
|
|
285
|
+
): void {
|
|
286
|
+
const prefix = fieldName ? `File "${fieldName}"` : 'File';
|
|
287
|
+
|
|
288
|
+
// Validate file size
|
|
289
|
+
if (options.maxSize !== undefined && file.size > options.maxSize) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`${prefix} exceeds maximum size. Got ${file.size} bytes, max is ${options.maxSize} bytes`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Validate MIME type
|
|
296
|
+
if (options.mimeTypes && options.mimeTypes.length > 0) {
|
|
297
|
+
const matches = options.mimeTypes.some((pattern) => matchMimeType(file.type, pattern));
|
|
298
|
+
if (!matches) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`${prefix} has invalid MIME type "${file.type}". Allowed: ${options.mimeTypes.join(', ')}`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -36,8 +36,14 @@ export {
|
|
|
36
36
|
type ParamDecoratorOptions,
|
|
37
37
|
type ParamMetadata,
|
|
38
38
|
type ResponseSchemaMetadata,
|
|
39
|
+
type RouteOptions,
|
|
39
40
|
type RouteMetadata,
|
|
40
41
|
type ControllerMetadata,
|
|
42
|
+
type MiddlewareClass,
|
|
43
|
+
type OnModuleConfigure,
|
|
44
|
+
// File upload types
|
|
45
|
+
type FileUploadOptions,
|
|
46
|
+
type FilesUploadOptions,
|
|
41
47
|
// WebSocket types are exported from ./websocket
|
|
42
48
|
type WsStorageType,
|
|
43
49
|
type WsStorageOptions,
|
|
@@ -53,6 +59,11 @@ export {
|
|
|
53
59
|
// Decorators and Metadata (exports Controller decorator, Module decorator, etc.)
|
|
54
60
|
export * from './decorators';
|
|
55
61
|
|
|
62
|
+
// File Upload (OneBunFile class, MimeType enum, helpers)
|
|
63
|
+
export {
|
|
64
|
+
OneBunFile, MimeType, matchMimeType, validateFile,
|
|
65
|
+
} from './file';
|
|
66
|
+
|
|
56
67
|
// Module System - explicitly re-export to avoid Controller conflict
|
|
57
68
|
export {
|
|
58
69
|
OneBunModule,
|
|
@@ -78,6 +89,8 @@ export {
|
|
|
78
89
|
type OnModuleDestroy,
|
|
79
90
|
type BeforeApplicationDestroy,
|
|
80
91
|
type OnApplicationDestroy,
|
|
92
|
+
// Middleware
|
|
93
|
+
BaseMiddleware,
|
|
81
94
|
// Lifecycle hooks helper functions
|
|
82
95
|
hasOnModuleInit,
|
|
83
96
|
hasOnApplicationInit,
|
|
@@ -89,6 +102,13 @@ export {
|
|
|
89
102
|
callOnModuleDestroy,
|
|
90
103
|
callBeforeApplicationDestroy,
|
|
91
104
|
callOnApplicationDestroy,
|
|
105
|
+
// SSE helpers
|
|
106
|
+
formatSseEvent,
|
|
107
|
+
createSseStream,
|
|
108
|
+
// Server & SSE default constants
|
|
109
|
+
DEFAULT_IDLE_TIMEOUT,
|
|
110
|
+
DEFAULT_SSE_HEARTBEAT_MS,
|
|
111
|
+
DEFAULT_SSE_TIMEOUT,
|
|
92
112
|
} from './module';
|
|
93
113
|
|
|
94
114
|
// Application
|