Created
April 19, 2020 23:08
-
-
Save ObjSal/06dec0de72c7d2514024cf1b54575152 to your computer and use it in GitHub Desktop.
Enable caching from Node.js
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)) | |
// Cache for one year, but verify every on each request with no-cache | |
response.setHeader('Cache-Control', `max-age=31536000, no-cache`) | |
// Use Etag and content encoding for caching control on the client | |
response.setHeader('Vary', 'ETag, Content-Encoding') | |
let ifNoneMatchValue = request.headers['if-none-match'] | |
createDefaultFtpClient().then(ftpClient => { | |
(async () => { | |
try { | |
let lastMod = await ftpClient.lastMod(filePath) | |
let lastModHash = getSHA1(lastMod.toString()) | |
// In real life apps, for ETag use something more bullet proof like the hash of the bytes of the file. | |
response.setHeader('ETag', lastModHash) | |
if (ifNoneMatchValue && ifNoneMatchValue === lastModHash) { | |
// Content is cached, don't return a body | |
response.statusCode = 304 | |
body.end() | |
} else if (request.method.toLowerCase() === 'head') { | |
// This was a head request, don't send the actual bytes. | |
body.end() | |
} else { | |
// Content not cached, 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