Skip to content

Instantly share code, notes, and snippets.

@ObjSal
Last active April 19, 2020 20:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ObjSal/fa9ca3518ed062309b238524827f36a7 to your computer and use it in GitHub Desktop.
Save ObjSal/fa9ca3518ed062309b238524827f36a7 to your computer and use it in GitHub Desktop.
Encoding Node.js responses with gzip, deflate and bf without third-party modules
// Author: Salvador Guerrero
// This project uses supports gzip, deflate and br encoding.
// Uses ETag for caching up to one year for images
// References:
// A Web Developer's Guide to Browser Caching: https://www.codebyamir.com/blog/a-web-developers-guide-to-browser-caching
// If-None-Match: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match
// Vary: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary
'use strict'
const http = require('http')
const fs = require('fs')
const { pipeline, PassThrough } = require('stream')
// Third-party modules
const ftp = require("basic-ftp")
// Project Modules
const { getSupportedEncoderInfo } = require('./EncodingUtil')
async function createDefaultFtpClient() {
const client = new ftp.Client(/*timeout = 180000*/) // 2min timeout for debug
// client.ftp.verbose = true
try {
await client.access({
host: "localhost",
user: "anonymous",
password: "",
secure: false
})
return client
} catch (e) {
throw new Error(`Could not connect to FTP server`)
}
}
function getSHA1(string) {
const crypto = require('crypto');
const shasum = crypto.createHash('sha1');
shasum.update(string)
return shasum.digest('hex')
}
function getContentType(filename) {
// TODO(sal): use real mapping
// here's an example with mappings: https://www.lifewire.com/mime-types-by-content-type-3469108
let extension = filename.split('.').pop().toLowerCase();
return `image/${extension}`
}
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 === '/' && request.method.toLowerCase() === 'get') {
response.setHeader('Content-Type', 'text/html')
const stream = fs.createReadStream(`${__dirname}/index.html`)
stream.pipe(body)
}
else if (request.url === '/favicon.ico' && request.method.toLowerCase() === 'get') {
response.setHeader('Content-Type', 'image/vnd.microsoft.icon')
const stream = fs.createReadStream(`${__dirname}/rambo.ico`)
stream.pipe(body)
}
else if (request.url.startsWith('/images/') && /(get|head)/.test(request.method.toLowerCase())) {
let filePath = `uploads${request.url}`
response.setHeader('Content-Type', getContentType(filePath))
createDefaultFtpClient().then(ftpClient => {
(async () => {
try {
// Download the content from FTP and send the data
await ftpClient.downloadTo(body, filePath)
} catch (error) {
console.error(error)
response.statusCode = 500
response.setHeader('Content-Type', 'application/json')
body.end(JSON.stringify({error: error.message}))
}
})()
}).catch(error => {
// There was a problem connecting to the FTP server
console.error(error)
response.statusCode = 500
response.setHeader('Content-Type', 'application/json')
body.end(JSON.stringify({error: error.message}))
})
}
// Error on any other path
else {
response.setHeader('Content-Type', 'text/html')
response.statusCode = 404
body.end('<html lang="en"><body><h1>Page Doesn\'t exist<h1></body></html>')
}
}).listen(3000, () => {
console.log(`Server running at http://localhost:3000/`);
})
// 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>Encoding and Caching</title>
</head>
<body>
<h3>Encoding & Caching Example</h3>
<img id="ramboImg" alt="Rambo Gif" src="images/rambo.gif"/>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment