Skip to content

Commit 82f1ab5

Browse files
feat: add option to handle file streams (#666)
* feat: add option to store files or not * feat: add new option fileWriteStreamHandler
1 parent 76ad4ae commit 82f1ab5

12 files changed

+492
-27
lines changed

README.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ _**Note: v2 is coming soon!**_
6464
## Highlights
6565

6666
- [Fast (~900-2500 mb/sec)](#benchmarks) & streaming multipart parser
67-
- Automatically writing file uploads to disk (soon optionally)
67+
- Automatically writing file uploads to disk (optional, see
68+
[`options.fileWriteStreamHandler`](#options))
6869
- [Plugins API](#useplugin-plugin) - allowing custom parsers and plugins
6970
- Low memory footprint
7071
- Graceful error handling
@@ -310,8 +311,8 @@ const form = new Formidable(options);
310311

311312
### Options
312313

313-
See it's defaults in [src/Formidable.js DEFAULT_OPTIONS](./src/Formidable.js) (the
314-
`DEFAULT_OPTIONS` constant).
314+
See it's defaults in [src/Formidable.js DEFAULT_OPTIONS](./src/Formidable.js)
315+
(the `DEFAULT_OPTIONS` constant).
315316

316317
- `options.encoding` **{string}** - default `'utf-8'`; sets encoding for
317318
incoming form fields,
@@ -334,6 +335,16 @@ See it's defaults in [src/Formidable.js DEFAULT_OPTIONS](./src/Formidable.js) (t
334335
for incoming files, set this to some hash algorithm, see
335336
[crypto.createHash](https://nodejs.org/api/crypto.html#crypto_crypto_createhash_algorithm_options)
336337
for available algorithms
338+
- `options.fileWriteStreamHandler` **{function}** - default `null`, which by
339+
default writes to host machine file system every file parsed; The function
340+
should return an instance of a
341+
[Writable stream](https://nodejs.org/api/stream.html#stream_class_stream_writable)
342+
that will receive the uploaded file data. With this option, you can have any
343+
custom behavior regarding where the uploaded file data will be streamed for.
344+
If you are looking to write the file uploaded in other types of cloud storages
345+
(AWS S3, Azure blob storage, Google cloud storage) or private file storage,
346+
this is the option you're looking for. When this option is defined the default
347+
behavior of writing the file in the host machine file system is lost.
337348
- `options.multiples` **{boolean}** - default `false`; when you call the
338349
`.parse` method, the `files` argument (of the callback) will contain arrays of
339350
files for inputs which submit multiple files using the HTML5 `multiple`
@@ -636,8 +647,8 @@ form.on('end', () => {});
636647

637648
If the documentation is unclear or has a typo, please click on the page's `Edit`
638649
button (pencil icon) and suggest a correction. If you would like to help us fix
639-
a bug or add a new feature, please check our
640-
[Contributing Guide][contributing-url]. Pull requests are welcome!
650+
a bug or add a new feature, please check our [Contributing
651+
Guide][contributing-url]. Pull requests are welcome!
641652

642653
Thanks goes to these wonderful people
643654
([emoji key](https://allcontributors.org/docs/en/emoji-key)):
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use strict';
2+
3+
const http = require('http');
4+
const { Writable } = require('stream');
5+
const formidable = require('../src/index');
6+
7+
const server = http.createServer((req, res) => {
8+
if (req.url === '/api/upload' && req.method.toLowerCase() === 'post') {
9+
// parse a file upload
10+
const form = formidable({
11+
fileWriteStreamHandler: () => {
12+
const writable = Writable();
13+
// eslint-disable-next-line no-underscore-dangle
14+
writable._write = (chunk, enc, next) => {
15+
console.log(chunk.toString());
16+
next();
17+
};
18+
return writable;
19+
},
20+
});
21+
22+
form.parse(req, () => {
23+
res.writeHead(200);
24+
res.end();
25+
});
26+
27+
return;
28+
}
29+
30+
// show a file upload form
31+
res.writeHead(200, { 'Content-Type': 'text/html' });
32+
res.end(`
33+
<h2>With Node.js <code>"http"</code> module</h2>
34+
<form action="/api/upload" enctype="multipart/form-data" method="post">
35+
<div>Text field title: <input type="text" name="title" /></div>
36+
<div>File: <input type="file" name="file" /></div>
37+
<input type="submit" value="Upload" />
38+
</form>
39+
`);
40+
});
41+
42+
server.listen(3000, () => {
43+
console.log('Server listening on http://localhost:3000 ...');
44+
});

examples/store-files-on-s3.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// To test this example you have to install aws-sdk nodejs package and create a bucket named "demo-bucket"
2+
3+
'use strict';
4+
5+
const http = require('http');
6+
const { PassThrough } = require('stream');
7+
// eslint-disable-next-line import/no-unresolved
8+
const AWS = require('aws-sdk');
9+
const formidable = require('../src/index');
10+
11+
const s3Client = new AWS.S3({
12+
credentials: {
13+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
14+
secretAccessKey: process.env.AWS_SECRET_KEY,
15+
},
16+
});
17+
18+
const uploadStream = (filename) => {
19+
const pass = PassThrough();
20+
s3Client.upload(
21+
{
22+
Bucket: 'demo-bucket',
23+
Key: filename,
24+
Body: pass,
25+
},
26+
(err, data) => {
27+
console.log(err, data);
28+
},
29+
);
30+
31+
return pass;
32+
};
33+
34+
const server = http.createServer((req, res) => {
35+
if (req.url === '/api/upload' && req.method.toLowerCase() === 'post') {
36+
// parse a file upload
37+
const form = formidable({
38+
fileWriteStreamHandler: uploadStream,
39+
});
40+
41+
form.parse(req, () => {
42+
res.writeHead(200);
43+
res.end();
44+
});
45+
46+
return;
47+
}
48+
49+
// show a file upload form
50+
res.writeHead(200, { 'Content-Type': 'text/html' });
51+
res.end(`
52+
<h2>With Node.js <code>"http"</code> module</h2>
53+
<form action="/api/upload" enctype="multipart/form-data" method="post">
54+
<div>Text field title: <input type="text" name="title" /></div>
55+
<div>File: <input type="file" name="file"/></div>
56+
<input type="submit" value="Upload" />
57+
</form>
58+
`);
59+
});
60+
61+
server.listen(3000, () => {
62+
console.log('Server listening on http://localhost:3000 ...');
63+
});

src/Formidable.js

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ const DEFAULT_OPTIONS = {
2626
uploadDir: os.tmpdir(),
2727
multiples: false,
2828
enabledPlugins: ['octetstream', 'querystring', 'multipart', 'json'],
29+
fileWriteStreamHandler: null,
2930
};
3031

31-
const File = require('./File');
32+
const PersistentFile = require('./PersistentFile');
33+
const VolatileFile = require('./VolatileFile');
3234
const DummyParser = require('./parsers/Dummy');
3335
const MultipartParser = require('./parsers/Multipart');
3436

@@ -138,11 +140,11 @@ class IncomingForm extends EventEmitter {
138140
const fields = {};
139141
let mockFields = '';
140142
const files = {};
141-
143+
142144
this.on('field', (name, value) => {
143145
if (this.options.multiples) {
144-
let mObj = { [name] : value };
145-
mockFields = mockFields + '&' + qs.stringify(mObj);
146+
const mObj = { [name]: value };
147+
mockFields = `${mockFields}&${qs.stringify(mObj)}`;
146148
} else {
147149
fields[name] = value;
148150
}
@@ -295,11 +297,10 @@ class IncomingForm extends EventEmitter {
295297

296298
this._flushing += 1;
297299

298-
const file = new File({
300+
const file = this._newFile({
299301
path: this._rename(part),
300-
name: part.filename,
301-
type: part.mime,
302-
hash: this.options.hash,
302+
filename: part.filename,
303+
mime: part.mime,
303304
});
304305
file.on('error', (err) => {
305306
this._error(err);
@@ -420,7 +421,7 @@ class IncomingForm extends EventEmitter {
420421

421422
if (Array.isArray(this.openedFiles)) {
422423
this.openedFiles.forEach((file) => {
423-
file._writeStream.destroy();
424+
file.destroy();
424425
setTimeout(fs.unlink, 0, file.path, () => {});
425426
});
426427
}
@@ -443,6 +444,22 @@ class IncomingForm extends EventEmitter {
443444
return new MultipartParser(this.options);
444445
}
445446

447+
_newFile({ path: filePath, filename: name, mime: type }) {
448+
return this.options.fileWriteStreamHandler
449+
? new VolatileFile({
450+
name,
451+
type,
452+
createFileWriteStream: this.options.fileWriteStreamHandler,
453+
hash: this.options.hash,
454+
})
455+
: new PersistentFile({
456+
path: filePath,
457+
name,
458+
type,
459+
hash: this.options.hash,
460+
});
461+
}
462+
446463
_getFileName(headerValue) {
447464
// matches either a quoted-string or a token (RFC 2616 section 19.5.1)
448465
const m = headerValue.match(

src/File.js renamed to src/PersistentFile.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const fs = require('fs');
66
const crypto = require('crypto');
77
const { EventEmitter } = require('events');
88

9-
class File extends EventEmitter {
9+
class PersistentFile extends EventEmitter {
1010
constructor(properties) {
1111
super();
1212

@@ -56,7 +56,7 @@ class File extends EventEmitter {
5656
}
5757

5858
toString() {
59-
return `File: ${this.name}, Path: ${this.path}`;
59+
return `PersistentFile: ${this.name}, Path: ${this.path}`;
6060
}
6161

6262
write(buffer, cb) {
@@ -86,6 +86,10 @@ class File extends EventEmitter {
8686
cb();
8787
});
8888
}
89+
90+
destroy() {
91+
this._writeStream.destroy();
92+
}
8993
}
9094

91-
module.exports = File;
95+
module.exports = PersistentFile;

src/VolatileFile.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/* eslint-disable no-underscore-dangle */
2+
3+
'use strict';
4+
5+
const crypto = require('crypto');
6+
const { EventEmitter } = require('events');
7+
8+
class VolatileFile extends EventEmitter {
9+
constructor(properties) {
10+
super();
11+
12+
this.size = 0;
13+
this.name = null;
14+
this.type = null;
15+
this.hash = null;
16+
17+
this._writeStream = null;
18+
19+
// eslint-disable-next-line guard-for-in, no-restricted-syntax
20+
for (const key in properties) {
21+
this[key] = properties[key];
22+
}
23+
24+
if (typeof this.hash === 'string') {
25+
this.hash = crypto.createHash(properties.hash);
26+
} else {
27+
this.hash = null;
28+
}
29+
}
30+
31+
open() {
32+
this._writeStream = this.createFileWriteStream(this.name);
33+
this._writeStream.on('error', (err) => {
34+
this.emit('error', err);
35+
});
36+
}
37+
38+
destroy() {
39+
this._writeStream.destroy();
40+
}
41+
42+
toJSON() {
43+
const json = {
44+
size: this.size,
45+
name: this.name,
46+
type: this.type,
47+
length: this.length,
48+
filename: this.filename,
49+
mime: this.mime,
50+
};
51+
if (this.hash && this.hash !== '') {
52+
json.hash = this.hash;
53+
}
54+
return json;
55+
}
56+
57+
toString() {
58+
return `VolatileFile: ${this.name}`;
59+
}
60+
61+
write(buffer, cb) {
62+
if (this.hash) {
63+
this.hash.update(buffer);
64+
}
65+
66+
if (this._writeStream.closed || this._writeStream.destroyed) {
67+
cb();
68+
return;
69+
}
70+
71+
this._writeStream.write(buffer, () => {
72+
this.size += buffer.length;
73+
this.emit('progress', this.size);
74+
cb();
75+
});
76+
}
77+
78+
end(cb) {
79+
if (this.hash) {
80+
this.hash = this.hash.digest('hex');
81+
}
82+
this._writeStream.end(() => {
83+
this.emit('end');
84+
cb();
85+
});
86+
}
87+
}
88+
89+
module.exports = VolatileFile;

src/index.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

3-
const File = require('./File');
3+
const PersistentFile = require('./PersistentFile');
4+
const VolatileFile = require('./VolatileFile');
45
const Formidable = require('./Formidable');
56

67
const plugins = require('./plugins/index');
@@ -11,7 +12,9 @@ const parsers = require('./parsers/index');
1112
const formidable = (...args) => new Formidable(...args);
1213

1314
module.exports = Object.assign(formidable, {
14-
File,
15+
File: PersistentFile,
16+
PersistentFile,
17+
VolatileFile,
1518
Formidable,
1619
formidable,
1720

src/plugins/octetstream.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
'use strict';
44

5-
const File = require('../File');
65
const OctetStreamParser = require('../parsers/OctetStream');
76

87
// the `options` is also available through the `options` / `formidable.options`
@@ -28,11 +27,10 @@ function init(_self, _opts) {
2827
const filename = this.headers['x-file-name'];
2928
const mime = this.headers['content-type'];
3029

31-
const file = new File({
30+
const file = this._newFile({
3231
path: this._uploadPath(filename),
33-
name: filename,
34-
type: mime,
35-
hash: this.options.hash,
32+
filename,
33+
mime,
3634
});
3735

3836
this.emit('fileBegin', filename, file);

0 commit comments

Comments
 (0)