@asterflow/multipart 1.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/README.md ADDED
@@ -0,0 +1,75 @@
1
+ <div align="center">
2
+
3
+ # @asterflow/multipart
4
+
5
+ ![license-info](https://img.shields.io/github/license/AsterFlow/AsterFlow?style=for-the-badge&colorA=302D41&colorB=f9e2af&logoColor=f9e2af)
6
+ ![stars-info](https://img.shields.io/github/stars/AsterFlow/AsterFlow?colorA=302D41&colorB=f9e2af&style=for-the-badge)
7
+ ![last-commit](https://img.shields.io/github/last-commit/AsterFlow/AsterFlow?path=plugins%2Fmultipart&style=for-the-badge&colorA=302D41&colorB=b4befe)
8
+
9
+ ![bundle-size](https://img.shields.io/bundlejs/size/@asterflow/multipart?style=for-the-badge&colorA=302D41&colorB=3ac97b)
10
+
11
+ </div>
12
+
13
+ > Parses `multipart/form-data` requests before your handler runs, with optional per-route field rules that are checked at runtime and enforced in the handler's types.
14
+
15
+ ## 📦 Installation
16
+
17
+ ```bash
18
+ bun install @asterflow/multipart
19
+ ```
20
+
21
+ Register the plugin on an AsterFlow app:
22
+
23
+ ```ts
24
+ import { AsterFlow } from 'asterflow'
25
+ import { multipartPlugin } from '@asterflow/multipart'
26
+
27
+ const app = new AsterFlow()
28
+ .use(multipartPlugin, { limits: { fileSize: 10 * 1024 * 1024 } })
29
+ ```
30
+
31
+ ### ✨ Features
32
+
33
+ - **Automatic parsing** - any `multipart/form-data` request is parsed with `busboy` before it reaches your route.
34
+ - **Per-route criteria** - calling `.multipart({...})` on `Method.create(...)` (or inside a `Router.builder(...).method(...)` chain) validates fields at runtime and narrows `getFile`/`getFiles` in that handler's types - a `required` field types as always-present, a declared `mimeTypes` list narrows `.mimeType`.
35
+ - **Request extensions** - `request.body`, `request.files`, `request.getFile()`, `request.getFiles()`, `request.hasFiles()`, `request.getFilesByType()`, `request.saveAll()` and `request.cleanupMultipart()` are attached directly onto `request`, no wrapper object.
36
+ - **Streaming storage** - files stream into memory or to disk (`fileHandling.keepInMemory`), never buffered twice.
37
+ - **Automatic cleanup** - temp files written to disk are removed after the response is sent.
38
+ - **Standardized errors** - limit, MIME/extension and required-field failures all reject with the same `{ error, code, message }` shape before your handler runs.
39
+
40
+ ## ❓ How to Use
41
+
42
+ Declare a route's fields with `.multipart(schema)` - the handler only runs once the request passes that schema, and `getFile`/`getFiles` are typed to match it:
43
+
44
+ ```ts
45
+ import { Method } from '@asterflow/router'
46
+
47
+ export default Method.create(Method.POST, { path: '/avatar' })
48
+ .multipart({
49
+ avatar: { mimeTypes: ['image/png', 'image/jpeg'], maxSize: 5 * 1024 * 1024, required: true }
50
+ })
51
+ .handler(({ request, response }) => {
52
+ const avatar = request.getFile('avatar') // always present, mimeType narrowed
53
+ return response.success({ filename: avatar.filename, size: avatar.size })
54
+ })
55
+ ```
56
+
57
+ Without a declared schema, the same methods are still there on `request`, just optional and unnarrowed:
58
+
59
+ ```ts
60
+ export default Method.create(Method.POST, { path: '/upload' }).handler(({ request, response }) => {
61
+ if (!request.hasFiles?.()) return response.badRequest({ error: 'NO_FILES' })
62
+ return response.success({ files: request.files, fields: request.body })
63
+ })
64
+ ```
65
+
66
+ ## 🔗 Related Packages
67
+
68
+ - [@asterflow/plugin](https://www.npmjs.com/package/@asterflow/plugin) - built as a plugin with `Plugin.create()`
69
+ - [@asterflow/router](https://www.npmjs.com/package/@asterflow/router) - adds `.multipart(...)` to `Method` and `RouteMethodBuilder` via declaration merging
70
+ - [@asterflow/request](https://www.npmjs.com/package/@asterflow/request) - extends `AsterRequest` via `.extend()` to attach parsed multipart data
71
+ - [@asterflow/response](https://www.npmjs.com/package/@asterflow/response) - returns `AsterResponse` errors for malformed or rejected multipart requests
72
+
73
+ ## 📄 License
74
+
75
+ This project is licensed under the [MIT License](../../LICENSE).
@@ -0,0 +1,437 @@
1
+ "use strict";
2
+ var W = Object.create;
3
+ var L = Object.defineProperty;
4
+ var ee = Object.getOwnPropertyDescriptor;
5
+ var te = Object.getOwnPropertyNames;
6
+ var ie = Object.getPrototypeOf, re = Object.prototype.hasOwnProperty;
7
+ var ne = (t, e) => {
8
+ for (var i in e)
9
+ L(t, i, { get: e[i], enumerable: !0 });
10
+ }, O = (t, e, i, r) => {
11
+ if (e && typeof e == "object" || typeof e == "function")
12
+ for (let s of te(e))
13
+ !re.call(t, s) && s !== i && L(t, s, { get: () => e[s], enumerable: !(r = ee(e, s)) || r.enumerable });
14
+ return t;
15
+ };
16
+ var oe = (t, e, i) => (i = t != null ? W(ie(t)) : {}, O(
17
+ e || !t || !t.__esModule ? L(i, "default", { value: t, enumerable: !0 }) : i,
18
+ t
19
+ )), se = (t) => O(L({}, "__esModule", { value: !0 }), t);
20
+ // plugins/multipart/src/index.ts
21
+ var me = {};
22
+ ne(me, {
23
+ DEFAULT_CONFIG: () => b,
24
+ ErrorCodes: () => n,
25
+ MultipartError: () => o,
26
+ MultipartParser: () => v,
27
+ default: () => fe,
28
+ errorResponse: () => A,
29
+ multipartPlugin: () => K,
30
+ parseMultipart: () => k,
31
+ resolveConfig: () => R,
32
+ validateFields: () => D
33
+ });
34
+ module.exports = se(me);
35
+ var G = require("@asterflow/plugin"), P = require("@asterflow/router"), J = require("fs/promises"), Q = require("os");
36
+ // plugins/multipart/package.json
37
+ var z = "1.1.0";
38
+ // plugins/multipart/src/controllers/multipartExtension.ts
39
+ var x = require("@asterflow/router"), $ = !1;
40
+ function B() {
41
+ return $ || ($ = !0, x.Method.prototype.multipart = function(t) {
42
+ return this.extend({}, { multipart: t });
43
+ }, x.RouteMethodBuilder.prototype.multipart = function(t) {
44
+ return this.extend({}, { multipart: t });
45
+ }), !0;
46
+ }
47
+ // plugins/multipart/src/types/multipart.ts
48
+ var b = {
49
+ limits: {
50
+ fieldNameSize: 100,
51
+ fieldSize: 1048576,
52
+ fields: 1 / 0,
53
+ fileSize: 10485760,
54
+ files: 10,
55
+ parts: 1 / 0
56
+ },
57
+ fileHandling: {
58
+ keepInMemory: !0
59
+ },
60
+ validation: {
61
+ allowedMimeTypes: [],
62
+ allowedExtensions: []
63
+ }
64
+ };
65
+ function R(t, e) {
66
+ return {
67
+ limits: {
68
+ ...b.limits,
69
+ ...t.limits
70
+ },
71
+ fileHandling: {
72
+ keepInMemory: !0,
73
+ tempDir: e,
74
+ ...t.fileHandling
75
+ },
76
+ validation: {
77
+ ...b.validation,
78
+ ...t.validation
79
+ }
80
+ };
81
+ }
82
+ var n = {
83
+ LIMIT_FILE_SIZE: "LIMIT_FILE_SIZE",
84
+ LIMIT_FILE_COUNT: "LIMIT_FILE_COUNT",
85
+ LIMIT_FIELD_SIZE: "LIMIT_FIELD_SIZE",
86
+ LIMIT_FIELD_COUNT: "LIMIT_FIELD_COUNT",
87
+ LIMIT_PARTS: "LIMIT_PARTS",
88
+ INVALID_MIME_TYPE: "INVALID_MIME_TYPE",
89
+ INVALID_EXTENSION: "INVALID_EXTENSION",
90
+ VALIDATION_FAILED: "VALIDATION_FAILED",
91
+ FIELD_REQUIRED: "FIELD_REQUIRED",
92
+ FIELD_TOO_MANY_FILES: "FIELD_TOO_MANY_FILES",
93
+ PARSE_ERROR: "PARSE_ERROR"
94
+ }, o = class extends Error {
95
+ code;
96
+ details;
97
+ constructor(e, i, r) {
98
+ super(e), this.name = "MultipartError", this.code = i, this.details = r;
99
+ }
100
+ };
101
+ // plugins/multipart/src/controllers/validateMultipartFields.ts
102
+ function D(t, e) {
103
+ for (let [i, r] of Object.entries(e)) {
104
+ let s = t.files.filter((u) => u.fieldName === i), c = r.multiple ?? !1;
105
+ if ((r.required ?? !1) && s.length === 0)
106
+ throw new o(`Field "${i}" is required`, n.FIELD_REQUIRED, { field: i });
107
+ if (!c && s.length > 1)
108
+ throw new o(
109
+ `Field "${i}" accepts only a single file`,
110
+ n.FIELD_TOO_MANY_FILES,
111
+ { field: i, count: s.length }
112
+ );
113
+ for (let u of s) {
114
+ if (r.mimeTypes && !r.mimeTypes.includes(u.mimeType))
115
+ throw new o(
116
+ `Field "${i}" has unsupported MIME type: ${u.mimeType}`,
117
+ n.INVALID_MIME_TYPE,
118
+ { field: i, mimeType: u.mimeType }
119
+ );
120
+ if (r.extensions && !r.extensions.map((d) => d.toLowerCase()).includes(u.extension.toLowerCase()))
121
+ throw new o(
122
+ `Field "${i}" has unsupported extension: ${u.extension}`,
123
+ n.INVALID_EXTENSION,
124
+ { field: i, extension: u.extension }
125
+ );
126
+ if (r.maxSize && u.size > r.maxSize)
127
+ throw new o(
128
+ `Field "${i}" exceeds max size of ${r.maxSize} bytes`,
129
+ n.LIMIT_FILE_SIZE,
130
+ { field: i, size: u.size, maxSize: r.maxSize }
131
+ );
132
+ }
133
+ }
134
+ }
135
+ // plugins/multipart/src/controllers/MultipartParser.ts
136
+ var C = oe(require("busboy"), 1), H = require("crypto"), w = require("fs"), y = require("fs/promises"), j = require("os"), M = require("path"), Y = require("stream"), T = require("stream/promises");
137
+ // plugins/multipart/src/utils/stream.ts
138
+ var q = require("stream");
139
+ function U(t) {
140
+ return typeof t == "object" && t !== null && typeof t.pipe == "function";
141
+ }
142
+ function ue(t) {
143
+ return typeof t == "object" && t !== null && typeof t.headers?.forEach == "function" && "body" in t;
144
+ }
145
+ function V(t) {
146
+ if (U(t)) return t;
147
+ let e = t?.raw;
148
+ if (U(e)) return e;
149
+ if (ue(t)) {
150
+ let i = t.body;
151
+ return i ? q.Readable.fromWeb(i) : null;
152
+ }
153
+ throw new o(
154
+ "Unsupported request type for multipart parsing",
155
+ n.PARSE_ERROR
156
+ );
157
+ }
158
+ // plugins/multipart/src/controllers/MultipartParser.ts
159
+ var pe = C.default ?? C, _ = class {
160
+ fieldName;
161
+ filename;
162
+ encoding;
163
+ mimeType;
164
+ size;
165
+ extension;
166
+ buffer;
167
+ tempPath;
168
+ constructor(e) {
169
+ this.fieldName = e.fieldName, this.filename = e.filename, this.encoding = e.encoding, this.mimeType = e.mimeType, this.size = e.size, this.extension = e.extension, this.buffer = e.buffer, this.tempPath = e.tempPath;
170
+ }
171
+ async toBuffer() {
172
+ if (this.buffer) return this.buffer;
173
+ if (this.tempPath) return (0, y.readFile)(this.tempPath);
174
+ throw new Error(`No data available for file "${this.filename}"`);
175
+ }
176
+ async save(e) {
177
+ if (await (0, y.mkdir)((0, M.dirname)(e), { recursive: !0 }), this.buffer) {
178
+ await (0, y.writeFile)(e, this.buffer);
179
+ return;
180
+ }
181
+ if (this.tempPath) {
182
+ await (0, T.pipeline)((0, w.createReadStream)(this.tempPath), (0, w.createWriteStream)(e));
183
+ return;
184
+ }
185
+ throw new Error(`No data available for file "${this.filename}"`);
186
+ }
187
+ stream() {
188
+ if (this.buffer) return Y.Readable.from(this.buffer);
189
+ if (this.tempPath) return (0, w.createReadStream)(this.tempPath);
190
+ throw new Error(`No data available for file "${this.filename}"`);
191
+ }
192
+ }, v = class {
193
+ config;
194
+ events;
195
+ constructor(e = {}, i = {}) {
196
+ this.config = R(e, e.fileHandling?.tempDir ?? (0, j.tmpdir)()), this.events = i;
197
+ }
198
+ async parse(e) {
199
+ let i = Date.now(), r = V(e.raw);
200
+ return r ? new Promise((s, c) => {
201
+ let p = !1, u = 0, f = 0, d = {}, I = [], F = [], a;
202
+ try {
203
+ a = pe({ headers: e.getHeaders(), limits: this.config.limits });
204
+ } catch (l) {
205
+ c(new o(
206
+ l instanceof Error ? l.message : "Failed to initialize the multipart parser",
207
+ n.PARSE_ERROR
208
+ ));
209
+ return;
210
+ }
211
+ let m = (l) => {
212
+ if (p) return;
213
+ p = !0;
214
+ let g = l instanceof o ? l : new o(
215
+ l instanceof Error ? l.message : "Unknown multipart parsing error",
216
+ n.PARSE_ERROR
217
+ );
218
+ this.events.onError?.(g), r.unpipe(a), queueMicrotask(() => a.destroy()), c(g);
219
+ };
220
+ a.on("field", (l, g, S) => {
221
+ if (p) return;
222
+ if (S.valueTruncated) {
223
+ m(new o(`Field "${l}" exceeds the configured size limit`, n.LIMIT_FIELD_SIZE));
224
+ return;
225
+ }
226
+ this.events.onField?.(l, g);
227
+ let h = d[l];
228
+ d[l] = h === void 0 ? g : Array.isArray(h) ? [...h, g] : [h, g], f++;
229
+ }), a.on("file", (l, g, S) => {
230
+ if (p) {
231
+ g.resume();
232
+ return;
233
+ }
234
+ let h = this.consumeFile(l, g, S, m, (E) => {
235
+ u += E.length, this.events.onProgress?.(u);
236
+ }).then((E) => {
237
+ p || !E || (I.push(E), this.events.onFileEnd?.(E));
238
+ }).catch((E) => m(E));
239
+ F.push(h);
240
+ }), a.on("partsLimit", () => m(new o("Parts limit exceeded", n.LIMIT_PARTS))), a.on("filesLimit", () => m(new o("Files limit exceeded", n.LIMIT_FILE_COUNT))), a.on("fieldsLimit", () => m(new o("Fields limit exceeded", n.LIMIT_FIELD_COUNT))), a.on("error", (l) => m(l)), r.on("error", (l) => m(l)), a.on("close", async () => {
241
+ try {
242
+ await Promise.all(F);
243
+ } catch {
244
+ return;
245
+ }
246
+ p || (p = !0, s({
247
+ fields: d,
248
+ files: I,
249
+ metadata: {
250
+ processingTime: Date.now() - i,
251
+ totalSize: u,
252
+ fieldsCount: f,
253
+ filesCount: I.length
254
+ }
255
+ }));
256
+ }), r.pipe(a);
257
+ }) : {
258
+ fields: {},
259
+ files: [],
260
+ metadata: { processingTime: Date.now() - i, totalSize: 0, fieldsCount: 0, filesCount: 0 }
261
+ };
262
+ }
263
+ async consumeFile(e, i, r, s, c) {
264
+ let p = {
265
+ fieldName: e,
266
+ filename: r.filename || "unknown",
267
+ encoding: r.encoding,
268
+ mimeType: r.mimeType
269
+ }, u = !1;
270
+ i.once("limit", () => {
271
+ u = !0, s(new o(`File "${p.filename}" exceeds the configured size limit`, n.LIMIT_FILE_SIZE));
272
+ }), i.on("error", (a) => s(a));
273
+ let f = await this.validateFile(p);
274
+ if (!f.valid)
275
+ return i.resume(), s(f.error), null;
276
+ this.events.onFileStart?.(p);
277
+ let d = 0, I = (0, M.extname)(p.filename), F = (a) => {
278
+ d += a.length, c(a), this.events.onFileData?.(p, a);
279
+ };
280
+ try {
281
+ if (this.config.fileHandling.keepInMemory) {
282
+ let m = [];
283
+ return i.on("data", (l) => {
284
+ F(l), m.push(l);
285
+ }), await (0, T.finished)(i), u ? null : new _({ ...p, size: d, extension: I, buffer: Buffer.concat(m) });
286
+ }
287
+ await (0, y.mkdir)(this.config.fileHandling.tempDir, { recursive: !0 });
288
+ let a = (0, M.join)(this.config.fileHandling.tempDir, `multipart-${(0, H.randomUUID)()}${I}`);
289
+ return i.on("data", F), await (0, T.pipeline)(i, (0, w.createWriteStream)(a)), u ? null : new _({ ...p, size: d, extension: I, tempPath: a });
290
+ } catch (a) {
291
+ return u || s(a), null;
292
+ }
293
+ }
294
+ async validateFile(e) {
295
+ let { validation: i } = this.config;
296
+ if (i.allowedMimeTypes && i.allowedMimeTypes.length > 0 && !i.allowedMimeTypes.includes(e.mimeType))
297
+ return {
298
+ valid: !1,
299
+ error: new o(`MIME type "${e.mimeType}" is not allowed`, n.INVALID_MIME_TYPE)
300
+ };
301
+ if (i.allowedExtensions && i.allowedExtensions.length > 0) {
302
+ let r = (0, M.extname)(e.filename).toLowerCase();
303
+ if (!i.allowedExtensions.some((s) => s.toLowerCase() === r))
304
+ return {
305
+ valid: !1,
306
+ error: new o(`Extension "${r}" is not allowed`, n.INVALID_EXTENSION)
307
+ };
308
+ }
309
+ if (i.validator)
310
+ try {
311
+ if (!await i.validator(e))
312
+ return {
313
+ valid: !1,
314
+ error: new o(`File "${e.filename}" failed custom validation`, n.VALIDATION_FAILED)
315
+ };
316
+ } catch (r) {
317
+ return {
318
+ valid: !1,
319
+ error: new o(
320
+ `Validator threw an error: ${r instanceof Error ? r.message : "Unknown error"}`,
321
+ n.VALIDATION_FAILED
322
+ )
323
+ };
324
+ }
325
+ return { valid: !0 };
326
+ }
327
+ };
328
+ async function k(t, e, i) {
329
+ return new v(e, i).parse(t);
330
+ }
331
+ // plugins/multipart/src/utils/errors.ts
332
+ function A(t, e) {
333
+ let i = {
334
+ code: e.code,
335
+ message: e.message,
336
+ ...e.details !== void 0 ? { details: e.details } : {}
337
+ };
338
+ switch (e.code) {
339
+ case n.LIMIT_FILE_SIZE:
340
+ case n.LIMIT_FILE_COUNT:
341
+ case n.LIMIT_FIELD_SIZE:
342
+ case n.LIMIT_FIELD_COUNT:
343
+ case n.LIMIT_PARTS:
344
+ return t.status(413).json({ error: "PAYLOAD_TOO_LARGE", ...i });
345
+ case n.INVALID_MIME_TYPE:
346
+ case n.INVALID_EXTENSION:
347
+ return t.status(415).json({ error: "UNSUPPORTED_MEDIA_TYPE", ...i });
348
+ case n.VALIDATION_FAILED:
349
+ case n.FIELD_REQUIRED:
350
+ case n.FIELD_TOO_MANY_FILES:
351
+ return t.validationError({ error: "VALIDATION_FAILED", ...i });
352
+ default:
353
+ return t.badRequest({ error: "MULTIPART_ERROR", ...i });
354
+ }
355
+ }
356
+ // plugins/multipart/src/utils/log.ts
357
+ var N = {
358
+ reset: "\x1B[0m",
359
+ red: "\x1B[31m",
360
+ blue: "\x1B[34m"
361
+ };
362
+ function Z(...t) {
363
+ process.env.DEBUG === "true" && console.log(`${N.blue}[AsterFlow Multipart]${N.reset}`, ...t);
364
+ }
365
+ function X(t, e) {
366
+ console.error(`${N.red}%s %s${N.reset}`, "[AsterFlow Multipart]", t), console.group(), console.error("Error:", e instanceof Error ? e.message : e), e instanceof Error && "code" in e && console.error("Code:", e.code), console.groupEnd();
367
+ }
368
+ // plugins/multipart/src/index.ts
369
+ function de(t) {
370
+ return {
371
+ body: t.fields,
372
+ files: t.files,
373
+ multipartMetadata: t.metadata,
374
+ getFile(e) {
375
+ return t.files.find((i) => i.fieldName === e);
376
+ },
377
+ getFiles(e) {
378
+ return e ? t.files.filter((i) => i.fieldName === e) : t.files;
379
+ },
380
+ hasFiles() {
381
+ return t.files.length > 0;
382
+ },
383
+ getFilesByType(e) {
384
+ return t.files.filter((i) => i.mimeType === e);
385
+ },
386
+ async saveAll(e) {
387
+ let i = [];
388
+ for (let r of t.files) {
389
+ let s = `${e}/${r.filename}`;
390
+ await r.save(s), i.push(s);
391
+ }
392
+ return i;
393
+ },
394
+ async cleanupMultipart() {
395
+ await Promise.all(t.files.map(async (e) => {
396
+ if (e.tempPath)
397
+ try {
398
+ await (0, J.unlink)(e.tempPath);
399
+ } catch {
400
+ }
401
+ }));
402
+ }
403
+ };
404
+ }
405
+ var K = G.Plugin.create({ name: "multipart" }).decorate("creator", "Ashu11-A").decorate("version", z).decorate("installed", B()).config(b).on("onRequest", async ({ request: t, response: e, router: i, plugin: r }) => {
406
+ let s = t.getMethod().toLowerCase(), c = i.route instanceof P.Method ? i.route.extensions.multipart : (0, P.getRouteExtensions)(i.route)?.[s]?.multipart, p = t.getHeaders()["content-type"];
407
+ if (!(!!p && p.toLowerCase().includes("multipart/form-data")))
408
+ return c ? A(e, new o(
409
+ "Expected a multipart/form-data request",
410
+ n.PARSE_ERROR
411
+ )) : void 0;
412
+ try {
413
+ let f = R(r.context, (0, Q.tmpdir)()), d = await k(t, f);
414
+ c && D(d, c), Z("Multipart request parsed", {
415
+ Fields: String(d.metadata.fieldsCount),
416
+ Files: String(d.metadata.filesCount),
417
+ "Total Size": `${d.metadata.totalSize} bytes`,
418
+ "Processing Time": `${d.metadata.processingTime}ms`
419
+ }), t.extend(de(d));
420
+ } catch (f) {
421
+ let d = f instanceof o ? f : new o(f instanceof Error ? f.message : "Unknown error", n.PARSE_ERROR);
422
+ return X("Failed to parse multipart request", d), A(e, d);
423
+ }
424
+ }).on("onResponse", async ({ request: t }) => {
425
+ await t.cleanupMultipart?.();
426
+ }), fe = K;
427
+ 0 && (module.exports = {
428
+ DEFAULT_CONFIG,
429
+ ErrorCodes,
430
+ MultipartError,
431
+ MultipartParser,
432
+ errorResponse,
433
+ multipartPlugin,
434
+ parseMultipart,
435
+ resolveConfig,
436
+ validateFields
437
+ });
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }