Skip to content

Commit c249922

Browse files
authored
feat: Upload dir async (#855)
* feature: upload dir and keep dir structure * docs: example upload dir and keep dir structure * feat: switch to async * feat: use pause and resume to await directory before file * feat: handle cases when directoryName is already an existing file * docs: describe options.createDirsFromUpload * fix: async await * fix: formatting #855 (comment) * tests: adapt tests * fix: too many tests us this port at the same time * tests: update sha1 (added linebreak) * test: update special chars we decode # encoded things now ? * test: force carriage return and use fetch * test: force async, * test: remove unused * test: move, use node for tests in test-node * test: try to fix jest error * test: update and fix custom plugin fail * test: disable this test, cannot understand the error ReferenceError: require is not defined 8 | size: 1024, 9 | filepath: '/tmp/cat.png', > 10 | name: 'cat.png', | ^ 11 | type: 'image/png', 12 | lastModifiedDate: now, 13 | originalFilename: 'cat.png', at _getJestObj (test/unit/persistent-file.test.js:10:7) at test/unit/persistent-file.test.js:19:1 * test: detect problematic test case, comment out (todo) * test: add test case for createDirsFromUploads option * test: semicolons and others * chore: version and changelog * feat: test command runs all tests at once * chore: update node version for testing * chore: up dependencies like in v2 9afd5f8 * chore: mark as latest on npm
1 parent e5e25d2 commit c249922

23 files changed

+338
-147
lines changed

.github/workflows/nodejs.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
strategy:
2929
matrix:
3030
os: [ubuntu-latest]
31-
node: [14.x]
31+
node: [20.x]
3232
runs-on: ubuntu-latest
3333
steps:
3434
- uses: actions/checkout@v2
@@ -56,7 +56,7 @@ jobs:
5656
strategy:
5757
matrix:
5858
os: [ubuntu-latest, macos-latest, windows-latest]
59-
node: [12.x, 14.x]
59+
node: [18.x, 20.x]
6060
runs-on: ${{ matrix.os }}
6161
steps:
6262
- uses: actions/checkout@v2
@@ -77,5 +77,5 @@ jobs:
7777
- name: Testing
7878
run: yarn test:ci
7979
- name: Sending test coverage to CodeCov
80-
if: matrix.os == 'ubuntu-latest' && matrix.node == '14.x'
80+
if: matrix.os == 'ubuntu-latest' && matrix.node == '20.x'
8181
run: echo ${{ matrix.node }} && bash <(curl -s https://codecov.io/bash)

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424

2525
!**/test
2626
!**/test/**
27+
!**/test-node
28+
!**/test-node/**
2729

2830
!**/*tests*
2931
!**/*tests*/**

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
### 3.3.2
4+
5+
* feature: ([#855](https://github.com/node-formidable/formidable/pull/855))add options.createDirsFromUploads, see README for usage
6+
* form.parse is an async function (ignore the promise)
7+
* benchmarks: add e2e becnhmark with as many request as possible per second
8+
* npm run to display all the commands
9+
* mark as latest on npm
10+
311
### 3.2.5
412

513
* fix: ([#881](https://github.com/node-formidable/formidable/pull/881)) fail earlier when maxFiles is exceeded

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,14 @@ already be included. Check the examples below and the [examples/](https://github
7777

7878
```
7979
# v2
80-
npm install formidable
8180
npm install formidable@v2
8281
8382
# v3
83+
npm install formidable
8484
npm install formidable@v3
8585
```
8686

87-
_**Note:** In the near future v3 will be published on the `latest` NPM dist-tag. Future not ready releases will be published on `*-next` dist-tags for the corresponding version._
87+
_**Note:** Future not ready releases will be published on `*-next` dist-tags for the corresponding version._
8888

8989

9090
## Examples
@@ -344,6 +344,8 @@ See it's defaults in [src/Formidable.js DEFAULT_OPTIONS](./src/Formidable.js)
344344
- `options.filter` **{function}** - default function that always returns true.
345345
Use it to filter files before they are uploaded. Must return a boolean.
346346

347+
- `options.createDirsFromUploads` **{boolean}** - default false. If true, makes direct folder uploads possible. Use `<input type="file" name="folders" webkitdirectory directory multiple>` to create a form to upload folders. Has to be used with the options `options.uploadDir` and `options.filename` where `options.filename` has to return a string with the character `/` for folders to be created. The base will be `options.uploadDir`.
348+
347349

348350
#### `options.filename` **{function}** function (name, ext, part, form) -> string
349351

examples/with-http.js

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,35 @@ const server = http.createServer((req, res) => {
1212
if (req.url === '/api/upload' && req.method.toLowerCase() === 'post') {
1313
// parse a file upload
1414
const form = formidable({
15-
// uploadDir: `uploads`,
15+
defaultInvalidName: 'invalid',
16+
uploadDir: `uploads`,
1617
keepExtensions: true,
18+
createDirsFromUploads: true,
19+
allowEmptyFiles: true,
20+
minFileSize: 0,
1721
filename(name, ext, part, form) {
1822
/* name basename of the http originalFilename
1923
ext with the dot ".txt" only if keepExtensions is true
2024
*/
21-
// slugify to avoid invalid filenames
22-
// substr to define a maximum
23-
return `${slugify(name)}.${slugify(ext, {separator: ''})}`.substr(0, 100);
24-
// return 'yo.txt'; // or completely different name
25+
// originalFilename will have slashes with relative path if a
26+
// directory was uploaded
27+
const {originalFilename} = part;
28+
if (!originalFilename) {
29+
return 'invalid';
30+
}
31+
32+
// return 'yo.txt'; // or completly different name
2533
// return 'z/yo.txt'; // subdirectory
34+
return originalFilename.split("/").map((subdir) => {
35+
return slugify(subdir, {separator: ''}); // slugify to avoid invalid filenames
36+
}).join("/").substr(0, 100); // substr to define a maximum
2637
},
27-
// filter: function ({name, originalFilename, mimetype}) {
28-
// // keep only images
29-
// return mimetype && mimetype.includes("image");
30-
// }
38+
filter: function ({name, originalFilename, mimetype}) {
39+
return Boolean(originalFilename);
40+
// keep only images
41+
// return mimetype?.includes("image");
42+
}
43+
3144
// maxTotalFileSize: 4000,
3245
// maxFileSize: 1000,
3346

@@ -53,7 +66,8 @@ const server = http.createServer((req, res) => {
5366
<h2>With Node.js <code>"http"</code> module</h2>
5467
<form action="/api/upload" enctype="multipart/form-data" method="post">
5568
<div>Text field title: <input type="text" name="title" /></div>
56-
<div>File: <input type="file" name="multipleFiles" multiple="multiple" /></div>
69+
<div>File: <input type="file" name="multipleFiles" multiple /></div>
70+
<div>Folders: <input type="file" name="folders" webkitdirectory directory multiple /></div>
5771
<input type="submit" value="Upload" />
5872
</form>
5973

package.json

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "formidable",
3-
"version": "3.2.5",
3+
"version": "3.3.2",
44
"license": "MIT",
55
"description": "A node.js module for parsing form data, especially file uploads.",
66
"homepage": "https://github.com/node-formidable/formidable",
@@ -13,10 +13,12 @@
1313
],
1414
"publishConfig": {
1515
"access": "public",
16-
"tag": "v3"
16+
"tag": "latest"
1717
},
1818
"scripts": {
1919
"bench": "node benchmark",
20+
"bench2prep": "node benchmark/server.js",
21+
"bench2": "bombardier --body-file=\"./README.md\" --method=POST --duration=10s --connections=100 http://localhost:3000/api/upload",
2022
"fmt": "yarn run fmt:prepare '**/*'",
2123
"fmt:prepare": "prettier --write",
2224
"lint": "yarn run lint:prepare .",
@@ -25,14 +27,16 @@
2527
"postreinstall": "yarn setup",
2628
"setup": "yarn",
2729
"pretest": "del-cli ./test/tmp && make-dir ./test/tmp",
28-
"test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --coverage",
30+
"test": "npm run test-jest && npm run test-node",
31+
"test-jest": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --testPathPattern=test/ --coverage",
32+
"test-node": "node --test test-node/",
2933
"pretest:ci": "yarn run pretest",
30-
"test:ci": "node --experimental-vm-modules node_modules/.bin/nyc jest --coverage"
34+
"test:ci": "node --experimental-vm-modules node_modules/.bin/nyc jest --testPathPattern=test/ --coverage && node --experimental-vm-modules node_modules/.bin/nyc node --test test-node/"
3135
},
3236
"dependencies": {
33-
"dezalgo": "1.0.3",
34-
"hexoid": "1.0.0",
35-
"once": "1.4.0"
37+
"dezalgo": "^1.0.4",
38+
"hexoid": "^1.0.0",
39+
"once": "^1.4.0"
3640
},
3741
"devDependencies": {
3842
"@commitlint/cli": "8.3.5",

src/Formidable.js

Lines changed: 68 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import os from 'node:os';
55
import path from 'node:path';
6+
import fsPromises from 'node:fs/promises';
67
import { EventEmitter } from 'node:events';
78
import { StringDecoder } from 'node:string_decoder';
89
import hexoid from 'hexoid';
@@ -25,6 +26,7 @@ const DEFAULT_OPTIONS = {
2526
maxTotalFileSize: undefined,
2627
minFileSize: 1,
2728
allowEmptyFiles: false,
29+
createDirsFromUploads: false,
2830
keepExtensions: false,
2931
encoding: 'utf-8',
3032
hashAlgorithm: false,
@@ -42,6 +44,32 @@ function hasOwnProp(obj, key) {
4244
return Object.prototype.hasOwnProperty.call(obj, key);
4345
}
4446

47+
48+
const decorateForceSequential = function (promiseCreator) {
49+
/* forces a function that returns a promise to be sequential
50+
useful for fs for example */
51+
let lastPromise = Promise.resolve();
52+
return async function (...x) {
53+
const promiseWeAreWaitingFor = lastPromise;
54+
let currentPromise;
55+
let callback;
56+
// we need to change lastPromise before await anything,
57+
// otherwise 2 calls might wait the same thing
58+
lastPromise = new Promise(function (resolve) {
59+
callback = resolve;
60+
});
61+
await promiseWeAreWaitingFor;
62+
currentPromise = promiseCreator(...x);
63+
currentPromise.then(callback).catch(callback);
64+
return currentPromise;
65+
};
66+
};
67+
68+
const createNecessaryDirectoriesAsync = decorateForceSequential(function (filePath) {
69+
const directoryname = path.dirname(filePath);
70+
return fsPromises.mkdir(directoryname, { recursive: true });
71+
});
72+
4573
const invalidExtensionChar = (c) => {
4674
const code = c.charCodeAt(0);
4775
return !(
@@ -150,7 +178,7 @@ class IncomingForm extends EventEmitter {
150178
return true;
151179
}
152180

153-
parse(req, cb) {
181+
async parse(req, cb) {
154182
this.req = req;
155183

156184
// Setup callback first, so we don't miss anything from data events emitted immediately.
@@ -186,7 +214,7 @@ class IncomingForm extends EventEmitter {
186214
}
187215

188216
// Parse headers and setup the parser, ready to start listening for data.
189-
this.writeHeaders(req.headers);
217+
await this.writeHeaders(req.headers);
190218

191219
// Start listening for data.
192220
req
@@ -216,10 +244,10 @@ class IncomingForm extends EventEmitter {
216244
return this;
217245
}
218246

219-
writeHeaders(headers) {
247+
async writeHeaders(headers) {
220248
this.headers = headers;
221249
this._parseContentLength();
222-
this._parseContentType();
250+
await this._parseContentType();
223251

224252
if (!this._parser) {
225253
this._error(
@@ -258,10 +286,10 @@ class IncomingForm extends EventEmitter {
258286

259287
onPart(part) {
260288
// this method can be overwritten by the user
261-
this._handlePart(part);
289+
return this._handlePart(part);
262290
}
263291

264-
_handlePart(part) {
292+
async _handlePart(part) {
265293
if (part.originalFilename && typeof part.originalFilename !== 'string') {
266294
this._error(
267295
new FormidableError(
@@ -318,7 +346,7 @@ class IncomingForm extends EventEmitter {
318346
let fileSize = 0;
319347
const newFilename = this._getNewName(part);
320348
const filepath = this._joinDirectoryName(newFilename);
321-
const file = this._newFile({
349+
const file = await this._newFile({
322350
newFilename,
323351
filepath,
324352
originalFilename: part.originalFilename,
@@ -396,7 +424,7 @@ class IncomingForm extends EventEmitter {
396424
}
397425

398426
// eslint-disable-next-line max-statements
399-
_parseContentType() {
427+
async _parseContentType() {
400428
if (this.bytesExpected === 0) {
401429
this._parser = new DummyParser(this, this.options);
402430
return;
@@ -417,10 +445,10 @@ class IncomingForm extends EventEmitter {
417445
new DummyParser(this, this.options);
418446

419447
const results = [];
420-
this._plugins.forEach((plugin, idx) => {
448+
await Promise.all(this._plugins.map(async (plugin, idx) => {
421449
let pluginReturn = null;
422450
try {
423-
pluginReturn = plugin(this, this.options) || this;
451+
pluginReturn = await plugin(this, this.options) || this;
424452
} catch (err) {
425453
// directly throw from the `form.parse` method;
426454
// there is no other better way, except a handle through options
@@ -436,7 +464,7 @@ class IncomingForm extends EventEmitter {
436464

437465
// todo: use Set/Map and pass plugin name instead of the `idx` index
438466
this.emit('plugin', idx, pluginReturn);
439-
});
467+
}));
440468
this.emit('pluginsResults', results);
441469
}
442470

@@ -471,23 +499,35 @@ class IncomingForm extends EventEmitter {
471499
return new MultipartParser(this.options);
472500
}
473501

474-
_newFile({ filepath, originalFilename, mimetype, newFilename }) {
475-
return this.options.fileWriteStreamHandler
476-
? new VolatileFile({
477-
newFilename,
478-
filepath,
479-
originalFilename,
480-
mimetype,
481-
createFileWriteStream: this.options.fileWriteStreamHandler,
482-
hashAlgorithm: this.options.hashAlgorithm,
483-
})
484-
: new PersistentFile({
485-
newFilename,
486-
filepath,
487-
originalFilename,
488-
mimetype,
489-
hashAlgorithm: this.options.hashAlgorithm,
490-
});
502+
async _newFile({ filepath, originalFilename, mimetype, newFilename }) {
503+
if (this.options.fileWriteStreamHandler) {
504+
return new VolatileFile({
505+
newFilename,
506+
filepath,
507+
originalFilename,
508+
mimetype,
509+
createFileWriteStream: this.options.fileWriteStreamHandler,
510+
hashAlgorithm: this.options.hashAlgorithm,
511+
});
512+
}
513+
if (this.options.createDirsFromUploads) {
514+
try {
515+
await createNecessaryDirectoriesAsync(filepath);
516+
} catch (errorCreatingDir) {
517+
this._error(new FormidableError(
518+
`cannot create directory`,
519+
errors.cannotCreateDir,
520+
409,
521+
));
522+
}
523+
}
524+
return new PersistentFile({
525+
newFilename,
526+
filepath,
527+
originalFilename,
528+
mimetype,
529+
hashAlgorithm: this.options.hashAlgorithm,
530+
});
491531
}
492532

493533
_getFileName(headerValue) {

src/FormidableError.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const unknownTransferEncoding = 1014;
1616
const maxFilesExceeded = 1015;
1717
const biggerThanMaxFileSize = 1016;
1818
const pluginFailed = 1017;
19+
const cannotCreateDir = 1018;
1920

2021
const FormidableError = class extends Error {
2122
constructor(message, internalCode, httpCode = 500) {
@@ -44,6 +45,7 @@ export {
4445
unknownTransferEncoding,
4546
biggerThanTotalMaxFileSize,
4647
pluginFailed,
48+
cannotCreateDir,
4749
};
4850

4951
export default FormidableError;

src/plugins/multipart.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ function createInitMultipart(boundary) {
5151
parser.initWithBoundary(boundary);
5252

5353
// eslint-disable-next-line max-statements, consistent-return
54-
parser.on('data', ({ name, buffer, start, end }) => {
54+
parser.on('data', async ({ name, buffer, start, end }) => {
5555
if (name === 'partBegin') {
5656
part = new Stream();
5757
part.readable = true;
@@ -159,8 +159,9 @@ function createInitMultipart(boundary) {
159159
),
160160
);
161161
}
162-
163-
this.onPart(part);
162+
this._parser.pause();
163+
await this.onPart(part);
164+
this._parser.resume();
164165
} else if (name === 'end') {
165166
this.ended = true;
166167
this._maybeEnd();

0 commit comments

Comments
 (0)