16

The third-party libraries "node-formidable" and "express" come with the ability to handle multipart POST requests (e.g. with a file upload form), but I don't want to use any third-party code. How do I make the file upload process in pure JavaScript on Node.js?

There are very few resources in this regard. How can this be done? Thank you, love is.

1
  • Same question here. how to do it? Commented Sep 11, 2015 at 22:05

4 Answers 4

13

Just to clarify because it seems some people are angry that the other answer didn't help much: There is no simple way of doing this without relying on a library doing it for you.

First, here's an answer to another question trying to clarify what happens on a POST file upload: https://stackoverflow.com/a/8660740/2071242

To summarize, to parse such an upload, you'll first need to check for a Content-Type header containing "multipart/form-data" and, if one exists, read the boundary attribute within the header.

After this, the content comes in multiple parts, each starting with the boundary string, including some additional headers and then the data itself after a blank line. The browser can select the boundary string pretty freely as long as such byte sequence doesn't exist in the uploaded data (see the spec at https://www.rfc-editor.org/rfc/rfc1867 for details). You can read in the data by registering a callback function for the request object's data event: request.on('data', callback);

For example, with boundary "QweRTy", an upload might look something like this:

POST /upload HTTP/1.1
(some standard HTTP headers)
Content-Type: multipart/form-data; boundary=QweRTy

--QweRTy
Content-Disposition: form-data; name="upload"; filename="my_file.txt"
Content-Type: text/plain

(The contents of the file)
--QweRTy--

Note how after the initial headers two dashes are added to the beginning of each boundary string and two dashes are added to the end of the last one.

Now, what makes this challenging is that you might need to read the incoming data (within the callback function mentioned above) in several chunks, and there are no guarantees that the boundary will be contained within one chunk. So you'll either need to buffer all the data (not necessarily a good idea) or implement a state machine parser that goes through the data byte by byte. This is actually exactly what the formidable library is doing.

So after having similar considerations, what I personally decided to do is to use the library. Re-implementing such a parser is pretty error-prone and in my opinion not worth the effort. But if you really want to avoid any libraries, checking the code of formidable might be a good start.

Sign up to request clarification or add additional context in comments.

Comments

4

This is a bit old question, but still quite relevant.
I have been looking for a similar solution and no luck. So decided to do my own which might come handy to some other users.
GIST: https://gist.github.com/patrikbego/6b80c6cfaf4f4e6c119560e919409bb2

Nodejs itself recommends (as seen here) formidable, but I think that such a basic functionality should be provided by Nodejs out of the box.

Comments

1

I think you need to parse form by yourself if you don't want to use any modules very much. When uploading a file, the form will be in multipart/form-data format, which means your request content will be divided by a string that is generated randomly by your browser. You need to read this string at the beginning of the form, try to load data and find this string, then parse them one by one.

For more information about multipart/form-data you can refer http://www.w3.org/TR/html401/interact/forms.html#h-17.13.4.2

I think the best solution is to use formidable. It handles vary scenarios and works prefect I think.

3 Comments

If my understanding were correct, you can read the form content from Chrome if you opened developer tool and active network tab. In Windows you can use Fiddler as well.
An answer saying "you should do this thing you asked not to do" Isn't much of an answer. Just my opinion, I'm looking to do the same thing as the OP and no one has been any help.
Why downvote this answer? I have upvoted it. Without using any third-party modules, surely the answer here, though broad, is technically correct and the alternative is reasonable.
0

I had to code it today. Here is how this works, using the default http module only.

You will be using three files for this demo. Put them is the same folder, then run the server from this folder.

I added some safety check in the handling of the multipart/form-data request. You cannot be careful enough when handling requests yourself...

Note that I handle the content of the request as binary because the content of the file is in binary in the request.

First the client part. This is an index.html file. It contains the code for sending a file using an element or drag-and-drop.

<!DOCTYPE HTML>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body onload="run ()">

<div id="$dropZone" style="display:table-cell;vertical-align:middle;text-align:center;width:200px;height:200px;border:2px dashed black">[Drop file(s) here]</div>

<input id="$file" type="file" value="Select file"/>
<input type="button" value="Upload" onclick="upload ()"/>

<script>
let files = new Array ();
function run () {
    let $dropZone;

    $dropZone = document.getElementById ("$dropZone");
    $dropZone.addEventListener ("dragover", dragOver);
    $dropZone.addEventListener ("drop", dropFile);
}

function dragOver (e) {
    e.preventDefault ();
}

function dropFile (e) {
    let item;

    e.preventDefault ();
    files = new Array ();
    for (item of e.dataTransfer.items)
        files.push (item.getAsFile ());
    $dropZone.innerHTML = files.map (file => `<div>${file.name}</div>`).join ("");
}

function upload () {
    let request;

    request = {
        // This an object you want to send as JSON with the files, for example a call to some service
        service: "foo",
        params: {
            "bar": "quz"
        }
    };
    // Use this line if you want to upload the file selected with <input type="file">
    file = document.getElementById ("$file").files[0];
    // Use this line if you want to upload the file selected by drag-and-drop
    //file = files[0]
    let formData = new FormData ();
    formData.append ("request", JSON.stringify (request));
    formData.append ("some_file", file);    // "some_file" is the "name" field value of the "Content-Disposition" header for this part
    let options = {
        method: "POST",
        body: formData
    };
    return (fetch ("http://localhost:8000", options).then (response => {
        if (response.ok)
            response.text ().then (body => {
                console.log (`(${response.status}): ${body}`);
            });
        else
            console.log (`Network error: ${response.status}`);
    }));
}
</script>

</body>
</html>

Next the server file.

import { createRequire } from "module";
const require = createRequire (import.meta.url);
const http = require ("http");
import { randomUUID } from "crypto";
const fs = require ("fs").promises;

function foo (request) {

    // Read the file and return its content

    return (fs.readFile (request.params.$_FILES[0].tmp_name).then (content => {
        return ({ result: content.toString () });
    }));
}

function printObject (o, prefix="") {
    let tab="\t", text, i, properties;

    switch (o.constructor.name) {
        case "String":
            text = `"${o}"`;
            break;

        case "Number":
            text = `${o}`;
            break;

        case "Array":
            if (!o.length) {
                text = "[]";
                break;
            }
            text = "[\n";
            for (i = 0; i !== o.length - 1; i ++)
                text += `${prefix + tab}${printObject (o[i], prefix + tab)},\n`;
            text += `${prefix + tab}${printObject (o[i], prefix + tab)}\n`;
            text += `${prefix}]`;
            break;

        default:
            properties = Object.getOwnPropertyNames (o);
            if (!properties.length) {
                text = "{}";
                break;
            }
            text = "{\n";
            for (i = 0; i !== properties.length - 1; i ++) {
                text += `${prefix}${tab}${properties[i]}: `;
                text += `${printObject (o[properties[i]], prefix + tab)},\n`;
            }
            text += `${prefix}${tab}${properties[i]}: `;
            text += `${printObject (o[properties[i]], prefix + tab)}\n`;
            text += `${prefix}}`;
            break
    }
    return (text);
}

function parseHTTPHeader (header) {
    let parts;

    parts = /(.*)?:\s*(.*)/.exec (header);
    return ({
        name: parts[1],
        value: parts[2]
    });
};

function parseHTTPHeaderValue (value) {
    let parts, part;

    parts = value.matchAll (/\s*(.+?)(?:=(.+?))?(?:;|$)/g);
    value = {};
    for (part of parts)
        value[part[1]] = part[2];
    return (value);
}

function onServerRequest (req, res) {
    let request, files, file, boundary, offset, parts, part, buffer, start, header, contentDisposition, contentType, name, filename, filesize, response;

    switch (req.method) {
        case "GET":
            fs.readFile (`${config.www_root}/index.html`).then (content => {
                res.end (content.toString ());
            });
            break;

        case "POST":
            let body = [];
            req.on ("data", chunk => body.push (chunk) );
            req.on ("end", () => {
                try {
                    body = Buffer.concat (body);
                    if (typeof req.headers["content-type"] === "undefined")
                        throw `Illegal HTTP request: No Content-Type header found`;
                    contentType = req.headers["content-type"].split (";");
                    files = new Array ();
                    request = "";
                    switch (contentType[0]) {
                        case "application/json":
                            request = body.toString ();
                            break;

                        case "multipart/form-data":

                            // Split the body in parts

                            boundary = "--" + contentType[1].trim ().substring ("boundary=".length);
                            offset = 0;
                            parts = new Array ();
                            while (offset != body.length) {
                                offset = body.indexOf (boundary, offset, "utf8");
                                if (offset === -1) {
                                    parts.pop ()
                                    break;
                                }
                                parts.push ({
                                    offset: offset + boundary.length + 2,
                                    length: 0,
                                    headers: {}
                                });
                                if (parts.length > 1)
                                    parts[parts.length - 2].length = parts[parts.length - 1].offset - parts[parts.length - 2].offset - (boundary.length + 2 + 2);
                                offset += boundary.length + 2;
                            }

                            // Handle each part

                            for (part of parts) {

                                // Split the start of the part in lines until "\r\n\r\n" to retrieve the headers

                                buffer = body.subarray (part.offset, part.offset + part.length);
                                part.lines = new Array ();
                                start = 0;
                                offset = 0;
                                while (true) {
                                    offset = buffer.indexOf ("\r\n", start, "utf8");
                                    if (offset === -1)  // Safety first...
                                        break;
                                    if (offset === start)
                                        break;
                                    header = parseHTTPHeader (buffer.subarray (start, offset).toString ());
                                    part.headers[header.name] = header.value;
                                    start = offset + 2;
                                }
                                offset += 2;    // Start of the data (text or file content)

                                // Check if the part contains a file (I presume that it is a file if the "Content-Disposition" header contains a "filename" field

                                if (typeof part.headers["Content-Disposition"] !== "undefined") {
                                    contentDisposition = parseHTTPHeaderValue (part.headers["Content-Disposition"]);

                                    if (typeof contentDisposition.filename !== "undefined") {

                                        // Retrieve file content type if any (you should check it but I'm too lazy)

                                        contentType = part.headers["Content-Type"];

                                        // Safety first! Check the name of the parameter

                                        if (typeof contentDisposition.name === "undefined")
                                            throw `multipart/form-data request attachment name not defined (must be ${config.uploads.name})`
                                        name = contentDisposition.name.slice (1, -1);
                                        if (!config.uploads.name.test (name))
                                            throw `multipart/form-data request attachment illegal name "${name}" (must be ${config.uploads.name})`

                                        // Safety first! Check the name of the file

                                        if (typeof contentDisposition.filename !== "undefined") {
                                            filename = contentDisposition.filename.slice (1, -1);
                                            if (!config.uploads.filename.test (filename))
                                                throw `multipart/form-data request attachment illegal filename "${filename}" (must be ${config.uploads.filename})`
                                        }
                                        else
                                            filename = randomUUID ();

                                        // Safety first! Check the size of the file

                                        filesize = buffer.length - offset;
                                        if (!filesize)
                                            throw `multipart/form-data request attachment illegal filesize 0 (must be > 0)`;
                                        if (filesize > config.uploads.filesize)
                                            throw `multipart/form-data request attachment illegal filesize ${filesize} (must be <= ${config.uploads.filesize})`;

                                        // Dump file content in the uploads directory using a random file name

                                        file = {
                                            name: filename,
                                            size: filesize,
                                            type: contentType,
                                            tmp_name: randomUUID ()
                                        };
                                        files.push (file);
                                        fs.writeFile (`${config.uploads.path}/${file.tmp_name}`, buffer.subarray (offset));
                                    }
                                    else {
                                        request = buffer.subarray (offset).toString ();
                                    }
                                }
                            }
                            break;

                        default:
                            throw `Illegal HTTP request: Unknown Content-Type value ${contentType}`;
                    }

                    // Add the list of the files to a parameter "$_FILES"

                    request = JSON.parse (request);
                    request.params.$_FILES = files;
                    console.log (`REQUEST:\n${printObject (request).split ("\n").map (s => "\t" + s).join ("\n")}`);

                    // Call the service (I don't bother with securing the service call because I'm too lazy, but you should)

                    services[request.service](request).then (response => {

                        // Delete request file dumps in the uploads directory

                        files.forEach (file => fs.unlink (`${config.uploads.path}/${file.tmp_name}`));

                        // Return the result

                        res.setHeader ("Content-Type", "application/json");
                        res.end (JSON.stringify (response));
                    });
                }
                catch (error) {
                    console.log (`Error: ${error.toString ()}`);
                    res.setHeader ("Content-Type", "application/json");
                    res.end (JSON.stringify ({ result: error.toString () }));
                }
            });
            break;
    }
}

let server;
let config = {
    port: 8000,
    www_root: "w:/documents/projets/upload/",
    uploads: {
        name: /^[A-Za-z0-9\-_]{1,255}$/,
        filename: /^[A-Za-z0-9\-_]{1,251}\.[A-Za-z0-9]{3}$/,
        filesize: 1024 * 1024,
        path: "w:/documents/projets/upload/"
    }
};
let services = {
    foo: foo
};

server = http.createServer (onServerRequest);
server.listen (config.port);
console.log (`Ready to serve at http://localhost:${config.port}!`);

Last, the file you will be sending to the server. Name it file.txt for example.

Hello world!

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.