Last active
April 19, 2020 20:14
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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/`); | |
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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