Skip to content

Instantly share code, notes, and snippets.

@ObjSal
Last active February 22, 2024 13:29
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save ObjSal/8a8bbe7809553c81e0ab309b67b4dd51 to your computer and use it in GitHub Desktop.
Save ObjSal/8a8bbe7809553c81e0ab309b67b4dd51 to your computer and use it in GitHub Desktop.
Posting form data in 3 ways to a Node.js server without third-party libraries - application/json, application/x-www-form-urlencoded, and multipart/form-data
// Author: Salvador Guerrero
'use strict'
const fs = require('fs')
// Project modules
const { CreateServer } = require('./server')
const SecurityUtils = require('./security-utils')
CreateServer((request, response, body) => {
if (request.url === '/' && request.method === 'GET') {
response.setHeader('Content-Type', 'text/html')
const stream = fs.createReadStream(`${__dirname}/index.html`)
stream.pipe(body)
} else if (request.url === '/' && request.method === 'POST') {
const contentLength = 90000000000
SecurityUtils.readRequestDataInMemory(request, response, body, contentLength, (error, data) => {
if (error) {
console.error(error.message)
return
}
// No error, all client data, server side parsing was successful.
//
// Now we can do whatever we want with the data, in the below code
// I'm saving the uploaded file to the root of the node server and
// returning the parsed data as json, I'm removing the binary data
// from the response.
//
// In production this can redirect to another site that makes sense,
// in the below commented code it redirects to the home page:
// response.setHeader('Location', '/')
// response.statusCode = 301
// body.end()
if (data.files) {
for (let file of data.files) {
const stream = fs.createWriteStream(file.filename)
stream.write(file.picture, 'binary')
stream.close()
file.picture = 'bin'
}
}
response.setHeader('Content-Type', 'text/plain')
body.end(JSON.stringify(data))
})
} else {
response.setHeader('Content-Type', 'text/html')
response.statusCode = 404
body.end('<html lang="en"><body><h1>Page Doesn\'t exist<h1></body></html>')
}
})
// Author: Salvador Guerrero
'use strict'
// https://nodejs.org/api/zlib.html
const zlib = require('zlib')
const kGzip = 'gzip'
const kDeflate = 'deflate'
const kBr = 'br'
const kAny = '*'
const kIdentity = 'identity'
class EncoderInfo {
constructor(name) {
this.name = name
}
isIdentity() {
return this.name === kIdentity
}
createEncoder() {
switch (this.name) {
case kGzip: return zlib.createGzip()
case kDeflate: return zlib.createDeflate()
case kBr: return zlib.createBrotliCompress()
default: return null
}
}
}
class ClientEncodingInfo {
constructor(name, qvalue) {
this.name = name
this.qvalue = qvalue
}
}
exports.getSupportedEncoderInfo = function getSupportedEncoderInfo(request) {
// See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
let acceptEncoding = request.headers['accept-encoding']
let acceptEncodings = []
let knownEncodings = [kGzip, kDeflate, kBr, kAny, kIdentity]
// If explicit is true, then it means the client sent *;q=0, meaning accept only given encodings
let explicit = false
if (!acceptEncoding || acceptEncoding.trim().length === 0) {
// If the Accept-Encoding field-value is empty, then only the "identity" encoding is acceptable.
knownEncodings = [kIdentity]
acceptEncodings = [new ClientEncodingInfo(kIdentity, 1)]
} else {
// NOTE: Only return 406 if the client sends 'identity;q=0' or a '*;q=0'
let acceptEncodingArray = acceptEncoding.split(',')
for (let encoding of acceptEncodingArray) {
encoding = encoding.trim()
if (/[a-z*];q=0$/.test(encoding)) {
// The "identity" content-coding is always acceptable, unless
// specifically refused because the Accept-Encoding field includes
// "identity;q=0", or because the field includes "*;q=0" and does
// not explicitly include the "identity" content-coding.
let split = encoding.split(';')
let name = split[0].trim()
if (name === kAny) {
explicit = true
}
knownEncodings.splice(knownEncodings.indexOf(name), 1)
} else if (/[a-z*]+;q=\d+(.\d+)*/.test(encoding)) {
// This string contains a qvalue.
let split = encoding.split(';')
let name = split[0].trim()
let value = split[1].trim()
value = value.split('=')[1]
value = parseFloat(value)
acceptEncodings.push(new ClientEncodingInfo(name, value))
} else {
// No qvalue, treat it as q=1.0
acceptEncodings.push(new ClientEncodingInfo(encoding.trim(), 1.0))
}
}
// order by qvalue, max to min
acceptEncodings.sort((a, b) => {
return b.qvalue - a.qvalue
})
}
// `acceptEncodings` is sorted by priority
// Pick the first known encoding.
let encoding = ''
for (let encodingInfo of acceptEncodings) {
if (knownEncodings.indexOf(encodingInfo.name) !== -1) {
encoding = encodingInfo.name
break
}
}
// If any, pick a known encoding
if (encoding === kAny) {
for (let knownEncoding of knownEncodings) {
if (knownEncoding === kAny) {
continue
} else {
encoding = knownEncoding
break
}
}
}
// If no known encoding was set, then use identity if not excluded
if (encoding.length === 0) {
if (!explicit && knownEncodings.indexOf(kIdentity) !== -1) {
encoding = kIdentity
} else {
console.error('No known encoding were found in accept-encoding, return http status code 406')
return null
}
}
return new EncoderInfo(encoding)
}
<html lang="en">
<head>
<title>Home</title>
<script>
'use strict'
function onFormSubmit(form) {
const username = form["username2"].value
const password = form["password2"].value
let body = JSON.stringify({
username: username,
password: password
});
(async () => {
try {
const response = await fetch('/', {
headers: {
'content-type': 'application/json'
},
method: 'POST',
body: body
})
const text = await response.text()
if (response.status !== 200) {
if (text && text.length > 0) {
console.error(text)
} else {
console.error('There was an error without description')
}
return
}
document.body.innerHTML = text
} catch (e) {
console.error(e.message)
}
})()
}
</script>
</head>
<body>
<h1>application/x-www-form-urlencoded</h1>
<form method="post">
<input id="username1" type="text" name="username" placeholder="username"><br />
<input id="password1" type="password" name="password" placeholder="password"><br />
<input type="submit">
</form>
<h1>application/json</h1>
<form action="javascript:" onsubmit="onFormSubmit(this)">
<input id="username2" type="text" placeholder="username"><br />
<input id="password2" type="password" placeholder="password"><br />
<input type="submit">
</form>
<h1>multipart/form-data</h1>
<form method="post" enctype="multipart/form-data">
<input id="username3" type="text" name="username" placeholder="username"><br />
<input id="password3" type="password" name="password" placeholder="password"><br />
<input id="picture3" type="file" name="picture"><br />
<input type="submit">
</form>
</body>
</html>
// Author: Salvador Guerrero
'use strict'
const querystring = require('querystring')
const kApplicationJSON = 'application/json'
const kApplicationFormUrlEncoded = 'application/x-www-form-urlencoded'
const kMultipartFormData = 'multipart/form-data'
function endRequestWithError(response, body, statusCode, message, cb) {
response.statusCode = statusCode
if (message && message.length > 0) {
response.setHeader('Content-Type', 'application/json')
body.end(JSON.stringify({message: message}))
if (cb) cb(new Error(message))
} else {
body.end()
if (cb) cb(new Error(`Error with statusCode: ${statusCode}`))
}
}
function getMatching(string, regex) {
// Helper function when using non-matching groups
const matches = string.match(regex)
if (!matches || matches.length < 2) {
return null
}
return matches[1]
}
function getBoundary(contentTypeArray) {
const boundaryPrefix = 'boundary='
let boundary = contentTypeArray.find(item => item.startsWith(boundaryPrefix))
if (!boundary) return null
boundary = boundary.slice(boundaryPrefix.length)
if (boundary) boundary = boundary.trim()
return boundary
}
exports.readRequestDataInMemory = (request, response, body, maxLength, callback) => {
const contentLength = parseInt(request.headers['content-length'])
if (isNaN(contentLength)) {
endRequestWithError(response, body, 411, 'Length required', callback)
return
}
// Don't need to validate while reading, V8 runtime only reads what content-length specifies.
if (contentLength > maxLength) {
endRequestWithError(response, body, 413, `Content length is greater than ${maxLength} Bytes`, callback)
return
}
let contentType = request.headers['content-type']
const contentTypeArray = contentType.split(';').map(item => item.trim())
if (contentTypeArray && contentTypeArray.length) {
contentType = contentTypeArray[0]
}
if (!contentType) {
endRequestWithError(response, body, 400, 'Content type not specified', callback)
return
}
if (!/((application\/(json|x-www-form-urlencoded))|multipart\/form-data)/.test(contentType)) {
endRequestWithError(response, body, 400, 'Content type is not supported', callback)
return
}
if (contentType === kMultipartFormData) {
// Use latin1 encoding to parse binary files correctly
request.setEncoding('latin1')
} else {
request.setEncoding('utf8')
}
let rawData = ''
request.on('data', chunk => {
rawData += chunk
})
request.on('end', () => {
switch (contentType) {
case kApplicationJSON: {
try {
callback(null, JSON.parse(rawData))
} catch (e) {
endRequestWithError(response, body, 400, 'There was an error trying to parse the data as JSON')
callback(e)
}
break
}
case kApplicationFormUrlEncoded: {
try {
let parsedData = querystring.decode(rawData)
callback(null, parsedData)
} catch (e) {
endRequestWithError(response, body, 400, 'There was an error trying to parse the form data')
callback(e)
}
break
}
case kMultipartFormData: {
const boundary = getBoundary(contentTypeArray)
if (!boundary) {
endRequestWithError(response, body, 400, 'Boundary information missing', callback)
return
}
let result = {}
const rawDataArray = rawData.split(boundary)
for (let item of rawDataArray) {
// Use non-matching groups to exclude part of the result
let name = getMatching(item, /(?:name=")(.+?)(?:")/)
if (!name || !(name = name.trim())) continue
let value = getMatching(item, /(?:\r\n\r\n)([\S\s]*)(?:\r\n--$)/)
if (!value) continue
let filename = getMatching(item, /(?:filename=")(.*?)(?:")/)
if (filename && (filename = filename.trim())) {
// Add the file information in a files array
let file = {}
file[name] = value
file['filename'] = filename
let contentType = getMatching(item, /(?:Content-Type:)(.*?)(?:\r\n)/)
if (contentType && (contentType = contentType.trim())) {
file['Content-Type'] = contentType
}
if (!result.files) {
result.files = []
}
result.files.push(file)
} else {
// Key/Value pair
result[name] = value
}
}
callback(null, result)
break
}
default: {
callback(null, rawData)
}
}
})
}
// Author: Salvador Guerrero
'use strict'
const fs = require('fs')
const http = require('http')
const { pipeline, PassThrough } = require('stream')
// Project modules
const { getSupportedEncoderInfo } = require('./encoding-util')
exports.CreateServer = function CreateServer(callback) {
http.createServer((request, response) => {
let encoderInfo = getSupportedEncoderInfo(request)
if (!encoderInfo) {
// Encoded not supported by this server
response.statusCode = 406
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify({error: 'Encodings not supported'}))
return
}
let body = response
response.setHeader('Content-Encoding', encoderInfo.name)
// If encoding is not identity, encode the response =)
if (!encoderInfo.isIdentity()) {
const onError = (err) => {
if (err) {
// If an error occurs, there's not much we can do because
// the server has already sent the 200 response code and
// some amount of data has already been sent to the client.
// The best we can do is terminate the response immediately
// and log the error.
response.end()
console.error('An error occurred:', err)
}
}
body = new PassThrough()
pipeline(body, encoderInfo.createEncoder(), response, onError)
}
if (request.url === '/favicon.ico' && request.method === 'GET') {
const path = `${__dirname}/rambo.ico`
const contentType = 'image/vnd.microsoft.icon'
// Chrome & Safari have issues caching favicon's
response.setHeader('Content-Type', contentType)
fs.createReadStream(path).pipe(body)
} else {
callback(request, response, body)
}
}).listen(3000, () => {
console.log(`Server running at http://localhost:3000/`);
})
}
@amo2019
Copy link

amo2019 commented Nov 23, 2020

so useful, thanks...

@tsuccar
Copy link

tsuccar commented Jun 7, 2023

very useful. thank you

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment