1'use strict' 2 3const cacache = require('cacache') 4const fetch = require('node-fetch-npm') 5const pipe = require('mississippi').pipe 6const ssri = require('ssri') 7const through = require('mississippi').through 8const to = require('mississippi').to 9const url = require('url') 10const stream = require('stream') 11 12const MAX_MEM_SIZE = 5 * 1024 * 1024 // 5MB 13 14function cacheKey (req) { 15 const parsed = url.parse(req.url) 16 return `make-fetch-happen:request-cache:${ 17 url.format({ 18 protocol: parsed.protocol, 19 slashes: parsed.slashes, 20 host: parsed.host, 21 hostname: parsed.hostname, 22 pathname: parsed.pathname 23 }) 24 }` 25} 26 27// This is a cacache-based implementation of the Cache standard, 28// using node-fetch. 29// docs: https://developer.mozilla.org/en-US/docs/Web/API/Cache 30// 31module.exports = class Cache { 32 constructor (path, opts) { 33 this._path = path 34 this.Promise = (opts && opts.Promise) || Promise 35 } 36 37 // Returns a Promise that resolves to the response associated with the first 38 // matching request in the Cache object. 39 match (req, opts) { 40 opts = opts || {} 41 const key = cacheKey(req) 42 return cacache.get.info(this._path, key).then(info => { 43 return info && cacache.get.hasContent( 44 this._path, info.integrity, opts 45 ).then(exists => exists && info) 46 }).then(info => { 47 if (info && info.metadata && matchDetails(req, { 48 url: info.metadata.url, 49 reqHeaders: new fetch.Headers(info.metadata.reqHeaders), 50 resHeaders: new fetch.Headers(info.metadata.resHeaders), 51 cacheIntegrity: info.integrity, 52 integrity: opts && opts.integrity 53 })) { 54 const resHeaders = new fetch.Headers(info.metadata.resHeaders) 55 addCacheHeaders(resHeaders, this._path, key, info.integrity, info.time) 56 if (req.method === 'HEAD') { 57 return new fetch.Response(null, { 58 url: req.url, 59 headers: resHeaders, 60 status: 200 61 }) 62 } 63 let body 64 const cachePath = this._path 65 // avoid opening cache file handles until a user actually tries to 66 // read from it. 67 if (opts.memoize !== false && info.size > MAX_MEM_SIZE) { 68 body = new stream.PassThrough() 69 const realRead = body._read 70 body._read = function (size) { 71 body._read = realRead 72 pipe( 73 cacache.get.stream.byDigest(cachePath, info.integrity, { 74 memoize: opts.memoize 75 }), 76 body, 77 err => body.emit(err)) 78 return realRead.call(this, size) 79 } 80 } else { 81 let readOnce = false 82 // cacache is much faster at bulk reads 83 body = new stream.Readable({ 84 read () { 85 if (readOnce) return this.push(null) 86 readOnce = true 87 cacache.get.byDigest(cachePath, info.integrity, { 88 memoize: opts.memoize 89 }).then(data => { 90 this.push(data) 91 this.push(null) 92 }, err => this.emit('error', err)) 93 } 94 }) 95 } 96 return this.Promise.resolve(new fetch.Response(body, { 97 url: req.url, 98 headers: resHeaders, 99 status: 200, 100 size: info.size 101 })) 102 } 103 }) 104 } 105 106 // Takes both a request and its response and adds it to the given cache. 107 put (req, response, opts) { 108 opts = opts || {} 109 const size = response.headers.get('content-length') 110 const fitInMemory = !!size && opts.memoize !== false && size < MAX_MEM_SIZE 111 const ckey = cacheKey(req) 112 const cacheOpts = { 113 algorithms: opts.algorithms, 114 metadata: { 115 url: req.url, 116 reqHeaders: req.headers.raw(), 117 resHeaders: response.headers.raw() 118 }, 119 size, 120 memoize: fitInMemory && opts.memoize 121 } 122 if (req.method === 'HEAD' || response.status === 304) { 123 // Update metadata without writing 124 return cacache.get.info(this._path, ckey).then(info => { 125 // Providing these will bypass content write 126 cacheOpts.integrity = info.integrity 127 addCacheHeaders( 128 response.headers, this._path, ckey, info.integrity, info.time 129 ) 130 return new this.Promise((resolve, reject) => { 131 pipe( 132 cacache.get.stream.byDigest(this._path, info.integrity, cacheOpts), 133 cacache.put.stream(this._path, cacheKey(req), cacheOpts), 134 err => err ? reject(err) : resolve(response) 135 ) 136 }) 137 }).then(() => response) 138 } 139 let buf = [] 140 let bufSize = 0 141 let cacheTargetStream = false 142 const cachePath = this._path 143 let cacheStream = to((chunk, enc, cb) => { 144 if (!cacheTargetStream) { 145 if (fitInMemory) { 146 cacheTargetStream = 147 to({highWaterMark: MAX_MEM_SIZE}, (chunk, enc, cb) => { 148 buf.push(chunk) 149 bufSize += chunk.length 150 cb() 151 }, done => { 152 cacache.put( 153 cachePath, 154 cacheKey(req), 155 Buffer.concat(buf, bufSize), 156 cacheOpts 157 ).then( 158 () => done(), 159 done 160 ) 161 }) 162 } else { 163 cacheTargetStream = 164 cacache.put.stream(cachePath, cacheKey(req), cacheOpts) 165 } 166 } 167 cacheTargetStream.write(chunk, enc, cb) 168 }, done => { 169 cacheTargetStream ? cacheTargetStream.end(done) : done() 170 }) 171 const oldBody = response.body 172 const newBody = through({highWaterMark: MAX_MEM_SIZE}) 173 response.body = newBody 174 oldBody.once('error', err => newBody.emit('error', err)) 175 newBody.once('error', err => oldBody.emit('error', err)) 176 cacheStream.once('error', err => newBody.emit('error', err)) 177 pipe(oldBody, to((chunk, enc, cb) => { 178 cacheStream.write(chunk, enc, () => { 179 newBody.write(chunk, enc, cb) 180 }) 181 }, done => { 182 cacheStream.end(() => { 183 newBody.end(() => { 184 done() 185 }) 186 }) 187 }), err => err && newBody.emit('error', err)) 188 return response 189 } 190 191 // Finds the Cache entry whose key is the request, and if found, deletes the 192 // Cache entry and returns a Promise that resolves to true. If no Cache entry 193 // is found, it returns false. 194 'delete' (req, opts) { 195 opts = opts || {} 196 if (typeof opts.memoize === 'object') { 197 if (opts.memoize.reset) { 198 opts.memoize.reset() 199 } else if (opts.memoize.clear) { 200 opts.memoize.clear() 201 } else { 202 Object.keys(opts.memoize).forEach(k => { 203 opts.memoize[k] = null 204 }) 205 } 206 } 207 return cacache.rm.entry( 208 this._path, 209 cacheKey(req) 210 // TODO - true/false 211 ).then(() => false) 212 } 213} 214 215function matchDetails (req, cached) { 216 const reqUrl = url.parse(req.url) 217 const cacheUrl = url.parse(cached.url) 218 const vary = cached.resHeaders.get('Vary') 219 // https://tools.ietf.org/html/rfc7234#section-4.1 220 if (vary) { 221 if (vary.match(/\*/)) { 222 return false 223 } else { 224 const fieldsMatch = vary.split(/\s*,\s*/).every(field => { 225 return cached.reqHeaders.get(field) === req.headers.get(field) 226 }) 227 if (!fieldsMatch) { 228 return false 229 } 230 } 231 } 232 if (cached.integrity) { 233 return ssri.parse(cached.integrity).match(cached.cacheIntegrity) 234 } 235 reqUrl.hash = null 236 cacheUrl.hash = null 237 return url.format(reqUrl) === url.format(cacheUrl) 238} 239 240function addCacheHeaders (resHeaders, path, key, hash, time) { 241 resHeaders.set('X-Local-Cache', encodeURIComponent(path)) 242 resHeaders.set('X-Local-Cache-Key', encodeURIComponent(key)) 243 resHeaders.set('X-Local-Cache-Hash', encodeURIComponent(hash)) 244 resHeaders.set('X-Local-Cache-Time', new Date(time).toUTCString()) 245} 246