• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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