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!