xref: /third_party/node/deps/undici/src/lib/cache/cache.js (revision 1cb0ef41)
1'use strict'
2
3const { kConstruct } = require('./symbols')
4const { urlEquals, fieldValues: getFieldValues } = require('./util')
5const { kEnumerableProperty, isDisturbed } = require('../core/util')
6const { kHeadersList } = require('../core/symbols')
7const { webidl } = require('../fetch/webidl')
8const { Response, cloneResponse } = require('../fetch/response')
9const { Request } = require('../fetch/request')
10const { kState, kHeaders, kGuard, kRealm } = require('../fetch/symbols')
11const { fetching } = require('../fetch/index')
12const { urlIsHttpHttpsScheme, createDeferredPromise, readAllBytes } = require('../fetch/util')
13const assert = require('assert')
14const { getGlobalDispatcher } = require('../global')
15
16/**
17 * @see https://w3c.github.io/ServiceWorker/#dfn-cache-batch-operation
18 * @typedef {Object} CacheBatchOperation
19 * @property {'delete' | 'put'} type
20 * @property {any} request
21 * @property {any} response
22 * @property {import('../../types/cache').CacheQueryOptions} options
23 */
24
25/**
26 * @see https://w3c.github.io/ServiceWorker/#dfn-request-response-list
27 * @typedef {[any, any][]} requestResponseList
28 */
29
30class Cache {
31  /**
32   * @see https://w3c.github.io/ServiceWorker/#dfn-relevant-request-response-list
33   * @type {requestResponseList}
34   */
35  #relevantRequestResponseList
36
37  constructor () {
38    if (arguments[0] !== kConstruct) {
39      webidl.illegalConstructor()
40    }
41
42    this.#relevantRequestResponseList = arguments[1]
43  }
44
45  async match (request, options = {}) {
46    webidl.brandCheck(this, Cache)
47    webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.match' })
48
49    request = webidl.converters.RequestInfo(request)
50    options = webidl.converters.CacheQueryOptions(options)
51
52    const p = await this.matchAll(request, options)
53
54    if (p.length === 0) {
55      return
56    }
57
58    return p[0]
59  }
60
61  async matchAll (request = undefined, options = {}) {
62    webidl.brandCheck(this, Cache)
63
64    if (request !== undefined) request = webidl.converters.RequestInfo(request)
65    options = webidl.converters.CacheQueryOptions(options)
66
67    // 1.
68    let r = null
69
70    // 2.
71    if (request !== undefined) {
72      if (request instanceof Request) {
73        // 2.1.1
74        r = request[kState]
75
76        // 2.1.2
77        if (r.method !== 'GET' && !options.ignoreMethod) {
78          return []
79        }
80      } else if (typeof request === 'string') {
81        // 2.2.1
82        r = new Request(request)[kState]
83      }
84    }
85
86    // 5.
87    // 5.1
88    const responses = []
89
90    // 5.2
91    if (request === undefined) {
92      // 5.2.1
93      for (const requestResponse of this.#relevantRequestResponseList) {
94        responses.push(requestResponse[1])
95      }
96    } else { // 5.3
97      // 5.3.1
98      const requestResponses = this.#queryCache(r, options)
99
100      // 5.3.2
101      for (const requestResponse of requestResponses) {
102        responses.push(requestResponse[1])
103      }
104    }
105
106    // 5.4
107    // We don't implement CORs so we don't need to loop over the responses, yay!
108
109    // 5.5.1
110    const responseList = []
111
112    // 5.5.2
113    for (const response of responses) {
114      // 5.5.2.1
115      const responseObject = new Response(response.body?.source ?? null)
116      const body = responseObject[kState].body
117      responseObject[kState] = response
118      responseObject[kState].body = body
119      responseObject[kHeaders][kHeadersList] = response.headersList
120      responseObject[kHeaders][kGuard] = 'immutable'
121
122      responseList.push(responseObject)
123    }
124
125    // 6.
126    return Object.freeze(responseList)
127  }
128
129  async add (request) {
130    webidl.brandCheck(this, Cache)
131    webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.add' })
132
133    request = webidl.converters.RequestInfo(request)
134
135    // 1.
136    const requests = [request]
137
138    // 2.
139    const responseArrayPromise = this.addAll(requests)
140
141    // 3.
142    return await responseArrayPromise
143  }
144
145  async addAll (requests) {
146    webidl.brandCheck(this, Cache)
147    webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.addAll' })
148
149    requests = webidl.converters['sequence<RequestInfo>'](requests)
150
151    // 1.
152    const responsePromises = []
153
154    // 2.
155    const requestList = []
156
157    // 3.
158    for (const request of requests) {
159      if (typeof request === 'string') {
160        continue
161      }
162
163      // 3.1
164      const r = request[kState]
165
166      // 3.2
167      if (!urlIsHttpHttpsScheme(r.url) || r.method !== 'GET') {
168        throw webidl.errors.exception({
169          header: 'Cache.addAll',
170          message: 'Expected http/s scheme when method is not GET.'
171        })
172      }
173    }
174
175    // 4.
176    /** @type {ReturnType<typeof fetching>[]} */
177    const fetchControllers = []
178
179    // 5.
180    for (const request of requests) {
181      // 5.1
182      const r = new Request(request)[kState]
183
184      // 5.2
185      if (!urlIsHttpHttpsScheme(r.url)) {
186        throw webidl.errors.exception({
187          header: 'Cache.addAll',
188          message: 'Expected http/s scheme.'
189        })
190      }
191
192      // 5.4
193      r.initiator = 'fetch'
194      r.destination = 'subresource'
195
196      // 5.5
197      requestList.push(r)
198
199      // 5.6
200      const responsePromise = createDeferredPromise()
201
202      // 5.7
203      fetchControllers.push(fetching({
204        request: r,
205        dispatcher: getGlobalDispatcher(),
206        processResponse (response) {
207          // 1.
208          if (response.type === 'error' || response.status === 206 || response.status < 200 || response.status > 299) {
209            responsePromise.reject(webidl.errors.exception({
210              header: 'Cache.addAll',
211              message: 'Received an invalid status code or the request failed.'
212            }))
213          } else if (response.headersList.contains('vary')) { // 2.
214            // 2.1
215            const fieldValues = getFieldValues(response.headersList.get('vary'))
216
217            // 2.2
218            for (const fieldValue of fieldValues) {
219              // 2.2.1
220              if (fieldValue === '*') {
221                responsePromise.reject(webidl.errors.exception({
222                  header: 'Cache.addAll',
223                  message: 'invalid vary field value'
224                }))
225
226                for (const controller of fetchControllers) {
227                  controller.abort()
228                }
229
230                return
231              }
232            }
233          }
234        },
235        processResponseEndOfBody (response) {
236          // 1.
237          if (response.aborted) {
238            responsePromise.reject(new DOMException('aborted', 'AbortError'))
239            return
240          }
241
242          // 2.
243          responsePromise.resolve(response)
244        }
245      }))
246
247      // 5.8
248      responsePromises.push(responsePromise.promise)
249    }
250
251    // 6.
252    const p = Promise.all(responsePromises)
253
254    // 7.
255    const responses = await p
256
257    // 7.1
258    const operations = []
259
260    // 7.2
261    let index = 0
262
263    // 7.3
264    for (const response of responses) {
265      // 7.3.1
266      /** @type {CacheBatchOperation} */
267      const operation = {
268        type: 'put', // 7.3.2
269        request: requestList[index], // 7.3.3
270        response // 7.3.4
271      }
272
273      operations.push(operation) // 7.3.5
274
275      index++ // 7.3.6
276    }
277
278    // 7.5
279    const cacheJobPromise = createDeferredPromise()
280
281    // 7.6.1
282    let errorData = null
283
284    // 7.6.2
285    try {
286      this.#batchCacheOperations(operations)
287    } catch (e) {
288      errorData = e
289    }
290
291    // 7.6.3
292    queueMicrotask(() => {
293      // 7.6.3.1
294      if (errorData === null) {
295        cacheJobPromise.resolve(undefined)
296      } else {
297        // 7.6.3.2
298        cacheJobPromise.reject(errorData)
299      }
300    })
301
302    // 7.7
303    return cacheJobPromise.promise
304  }
305
306  async put (request, response) {
307    webidl.brandCheck(this, Cache)
308    webidl.argumentLengthCheck(arguments, 2, { header: 'Cache.put' })
309
310    request = webidl.converters.RequestInfo(request)
311    response = webidl.converters.Response(response)
312
313    // 1.
314    let innerRequest = null
315
316    // 2.
317    if (request instanceof Request) {
318      innerRequest = request[kState]
319    } else { // 3.
320      innerRequest = new Request(request)[kState]
321    }
322
323    // 4.
324    if (!urlIsHttpHttpsScheme(innerRequest.url) || innerRequest.method !== 'GET') {
325      throw webidl.errors.exception({
326        header: 'Cache.put',
327        message: 'Expected an http/s scheme when method is not GET'
328      })
329    }
330
331    // 5.
332    const innerResponse = response[kState]
333
334    // 6.
335    if (innerResponse.status === 206) {
336      throw webidl.errors.exception({
337        header: 'Cache.put',
338        message: 'Got 206 status'
339      })
340    }
341
342    // 7.
343    if (innerResponse.headersList.contains('vary')) {
344      // 7.1.
345      const fieldValues = getFieldValues(innerResponse.headersList.get('vary'))
346
347      // 7.2.
348      for (const fieldValue of fieldValues) {
349        // 7.2.1
350        if (fieldValue === '*') {
351          throw webidl.errors.exception({
352            header: 'Cache.put',
353            message: 'Got * vary field value'
354          })
355        }
356      }
357    }
358
359    // 8.
360    if (innerResponse.body && (isDisturbed(innerResponse.body.stream) || innerResponse.body.stream.locked)) {
361      throw webidl.errors.exception({
362        header: 'Cache.put',
363        message: 'Response body is locked or disturbed'
364      })
365    }
366
367    // 9.
368    const clonedResponse = cloneResponse(innerResponse)
369
370    // 10.
371    const bodyReadPromise = createDeferredPromise()
372
373    // 11.
374    if (innerResponse.body != null) {
375      // 11.1
376      const stream = innerResponse.body.stream
377
378      // 11.2
379      const reader = stream.getReader()
380
381      // 11.3
382      readAllBytes(reader).then(bodyReadPromise.resolve, bodyReadPromise.reject)
383    } else {
384      bodyReadPromise.resolve(undefined)
385    }
386
387    // 12.
388    /** @type {CacheBatchOperation[]} */
389    const operations = []
390
391    // 13.
392    /** @type {CacheBatchOperation} */
393    const operation = {
394      type: 'put', // 14.
395      request: innerRequest, // 15.
396      response: clonedResponse // 16.
397    }
398
399    // 17.
400    operations.push(operation)
401
402    // 19.
403    const bytes = await bodyReadPromise.promise
404
405    if (clonedResponse.body != null) {
406      clonedResponse.body.source = bytes
407    }
408
409    // 19.1
410    const cacheJobPromise = createDeferredPromise()
411
412    // 19.2.1
413    let errorData = null
414
415    // 19.2.2
416    try {
417      this.#batchCacheOperations(operations)
418    } catch (e) {
419      errorData = e
420    }
421
422    // 19.2.3
423    queueMicrotask(() => {
424      // 19.2.3.1
425      if (errorData === null) {
426        cacheJobPromise.resolve()
427      } else { // 19.2.3.2
428        cacheJobPromise.reject(errorData)
429      }
430    })
431
432    return cacheJobPromise.promise
433  }
434
435  async delete (request, options = {}) {
436    webidl.brandCheck(this, Cache)
437    webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.delete' })
438
439    request = webidl.converters.RequestInfo(request)
440    options = webidl.converters.CacheQueryOptions(options)
441
442    /**
443     * @type {Request}
444     */
445    let r = null
446
447    if (request instanceof Request) {
448      r = request[kState]
449
450      if (r.method !== 'GET' && !options.ignoreMethod) {
451        return false
452      }
453    } else {
454      assert(typeof request === 'string')
455
456      r = new Request(request)[kState]
457    }
458
459    /** @type {CacheBatchOperation[]} */
460    const operations = []
461
462    /** @type {CacheBatchOperation} */
463    const operation = {
464      type: 'delete',
465      request: r,
466      options
467    }
468
469    operations.push(operation)
470
471    const cacheJobPromise = createDeferredPromise()
472
473    let errorData = null
474    let requestResponses
475
476    try {
477      requestResponses = this.#batchCacheOperations(operations)
478    } catch (e) {
479      errorData = e
480    }
481
482    queueMicrotask(() => {
483      if (errorData === null) {
484        cacheJobPromise.resolve(!!requestResponses?.length)
485      } else {
486        cacheJobPromise.reject(errorData)
487      }
488    })
489
490    return cacheJobPromise.promise
491  }
492
493  /**
494   * @see https://w3c.github.io/ServiceWorker/#dom-cache-keys
495   * @param {any} request
496   * @param {import('../../types/cache').CacheQueryOptions} options
497   * @returns {readonly Request[]}
498   */
499  async keys (request = undefined, options = {}) {
500    webidl.brandCheck(this, Cache)
501
502    if (request !== undefined) request = webidl.converters.RequestInfo(request)
503    options = webidl.converters.CacheQueryOptions(options)
504
505    // 1.
506    let r = null
507
508    // 2.
509    if (request !== undefined) {
510      // 2.1
511      if (request instanceof Request) {
512        // 2.1.1
513        r = request[kState]
514
515        // 2.1.2
516        if (r.method !== 'GET' && !options.ignoreMethod) {
517          return []
518        }
519      } else if (typeof request === 'string') { // 2.2
520        r = new Request(request)[kState]
521      }
522    }
523
524    // 4.
525    const promise = createDeferredPromise()
526
527    // 5.
528    // 5.1
529    const requests = []
530
531    // 5.2
532    if (request === undefined) {
533      // 5.2.1
534      for (const requestResponse of this.#relevantRequestResponseList) {
535        // 5.2.1.1
536        requests.push(requestResponse[0])
537      }
538    } else { // 5.3
539      // 5.3.1
540      const requestResponses = this.#queryCache(r, options)
541
542      // 5.3.2
543      for (const requestResponse of requestResponses) {
544        // 5.3.2.1
545        requests.push(requestResponse[0])
546      }
547    }
548
549    // 5.4
550    queueMicrotask(() => {
551      // 5.4.1
552      const requestList = []
553
554      // 5.4.2
555      for (const request of requests) {
556        const requestObject = new Request('https://a')
557        requestObject[kState] = request
558        requestObject[kHeaders][kHeadersList] = request.headersList
559        requestObject[kHeaders][kGuard] = 'immutable'
560        requestObject[kRealm] = request.client
561
562        // 5.4.2.1
563        requestList.push(requestObject)
564      }
565
566      // 5.4.3
567      promise.resolve(Object.freeze(requestList))
568    })
569
570    return promise.promise
571  }
572
573  /**
574   * @see https://w3c.github.io/ServiceWorker/#batch-cache-operations-algorithm
575   * @param {CacheBatchOperation[]} operations
576   * @returns {requestResponseList}
577   */
578  #batchCacheOperations (operations) {
579    // 1.
580    const cache = this.#relevantRequestResponseList
581
582    // 2.
583    const backupCache = [...cache]
584
585    // 3.
586    const addedItems = []
587
588    // 4.1
589    const resultList = []
590
591    try {
592      // 4.2
593      for (const operation of operations) {
594        // 4.2.1
595        if (operation.type !== 'delete' && operation.type !== 'put') {
596          throw webidl.errors.exception({
597            header: 'Cache.#batchCacheOperations',
598            message: 'operation type does not match "delete" or "put"'
599          })
600        }
601
602        // 4.2.2
603        if (operation.type === 'delete' && operation.response != null) {
604          throw webidl.errors.exception({
605            header: 'Cache.#batchCacheOperations',
606            message: 'delete operation should not have an associated response'
607          })
608        }
609
610        // 4.2.3
611        if (this.#queryCache(operation.request, operation.options, addedItems).length) {
612          throw new DOMException('???', 'InvalidStateError')
613        }
614
615        // 4.2.4
616        let requestResponses
617
618        // 4.2.5
619        if (operation.type === 'delete') {
620          // 4.2.5.1
621          requestResponses = this.#queryCache(operation.request, operation.options)
622
623          // TODO: the spec is wrong, this is needed to pass WPTs
624          if (requestResponses.length === 0) {
625            return []
626          }
627
628          // 4.2.5.2
629          for (const requestResponse of requestResponses) {
630            const idx = cache.indexOf(requestResponse)
631            assert(idx !== -1)
632
633            // 4.2.5.2.1
634            cache.splice(idx, 1)
635          }
636        } else if (operation.type === 'put') { // 4.2.6
637          // 4.2.6.1
638          if (operation.response == null) {
639            throw webidl.errors.exception({
640              header: 'Cache.#batchCacheOperations',
641              message: 'put operation should have an associated response'
642            })
643          }
644
645          // 4.2.6.2
646          const r = operation.request
647
648          // 4.2.6.3
649          if (!urlIsHttpHttpsScheme(r.url)) {
650            throw webidl.errors.exception({
651              header: 'Cache.#batchCacheOperations',
652              message: 'expected http or https scheme'
653            })
654          }
655
656          // 4.2.6.4
657          if (r.method !== 'GET') {
658            throw webidl.errors.exception({
659              header: 'Cache.#batchCacheOperations',
660              message: 'not get method'
661            })
662          }
663
664          // 4.2.6.5
665          if (operation.options != null) {
666            throw webidl.errors.exception({
667              header: 'Cache.#batchCacheOperations',
668              message: 'options must not be defined'
669            })
670          }
671
672          // 4.2.6.6
673          requestResponses = this.#queryCache(operation.request)
674
675          // 4.2.6.7
676          for (const requestResponse of requestResponses) {
677            const idx = cache.indexOf(requestResponse)
678            assert(idx !== -1)
679
680            // 4.2.6.7.1
681            cache.splice(idx, 1)
682          }
683
684          // 4.2.6.8
685          cache.push([operation.request, operation.response])
686
687          // 4.2.6.10
688          addedItems.push([operation.request, operation.response])
689        }
690
691        // 4.2.7
692        resultList.push([operation.request, operation.response])
693      }
694
695      // 4.3
696      return resultList
697    } catch (e) { // 5.
698      // 5.1
699      this.#relevantRequestResponseList.length = 0
700
701      // 5.2
702      this.#relevantRequestResponseList = backupCache
703
704      // 5.3
705      throw e
706    }
707  }
708
709  /**
710   * @see https://w3c.github.io/ServiceWorker/#query-cache
711   * @param {any} requestQuery
712   * @param {import('../../types/cache').CacheQueryOptions} options
713   * @param {requestResponseList} targetStorage
714   * @returns {requestResponseList}
715   */
716  #queryCache (requestQuery, options, targetStorage) {
717    /** @type {requestResponseList} */
718    const resultList = []
719
720    const storage = targetStorage ?? this.#relevantRequestResponseList
721
722    for (const requestResponse of storage) {
723      const [cachedRequest, cachedResponse] = requestResponse
724      if (this.#requestMatchesCachedItem(requestQuery, cachedRequest, cachedResponse, options)) {
725        resultList.push(requestResponse)
726      }
727    }
728
729    return resultList
730  }
731
732  /**
733   * @see https://w3c.github.io/ServiceWorker/#request-matches-cached-item-algorithm
734   * @param {any} requestQuery
735   * @param {any} request
736   * @param {any | null} response
737   * @param {import('../../types/cache').CacheQueryOptions | undefined} options
738   * @returns {boolean}
739   */
740  #requestMatchesCachedItem (requestQuery, request, response = null, options) {
741    // if (options?.ignoreMethod === false && request.method === 'GET') {
742    //   return false
743    // }
744
745    const queryURL = new URL(requestQuery.url)
746
747    const cachedURL = new URL(request.url)
748
749    if (options?.ignoreSearch) {
750      cachedURL.search = ''
751
752      queryURL.search = ''
753    }
754
755    if (!urlEquals(queryURL, cachedURL, true)) {
756      return false
757    }
758
759    if (
760      response == null ||
761      options?.ignoreVary ||
762      !response.headersList.contains('vary')
763    ) {
764      return true
765    }
766
767    const fieldValues = getFieldValues(response.headersList.get('vary'))
768
769    for (const fieldValue of fieldValues) {
770      if (fieldValue === '*') {
771        return false
772      }
773
774      const requestValue = request.headersList.get(fieldValue)
775      const queryValue = requestQuery.headersList.get(fieldValue)
776
777      // If one has the header and the other doesn't, or one has
778      // a different value than the other, return false
779      if (requestValue !== queryValue) {
780        return false
781      }
782    }
783
784    return true
785  }
786}
787
788Object.defineProperties(Cache.prototype, {
789  [Symbol.toStringTag]: {
790    value: 'Cache',
791    configurable: true
792  },
793  match: kEnumerableProperty,
794  matchAll: kEnumerableProperty,
795  add: kEnumerableProperty,
796  addAll: kEnumerableProperty,
797  put: kEnumerableProperty,
798  delete: kEnumerableProperty,
799  keys: kEnumerableProperty
800})
801
802const cacheQueryOptionConverters = [
803  {
804    key: 'ignoreSearch',
805    converter: webidl.converters.boolean,
806    defaultValue: false
807  },
808  {
809    key: 'ignoreMethod',
810    converter: webidl.converters.boolean,
811    defaultValue: false
812  },
813  {
814    key: 'ignoreVary',
815    converter: webidl.converters.boolean,
816    defaultValue: false
817  }
818]
819
820webidl.converters.CacheQueryOptions = webidl.dictionaryConverter(cacheQueryOptionConverters)
821
822webidl.converters.MultiCacheQueryOptions = webidl.dictionaryConverter([
823  ...cacheQueryOptionConverters,
824  {
825    key: 'cacheName',
826    converter: webidl.converters.DOMString
827  }
828])
829
830webidl.converters.Response = webidl.interfaceConverter(Response)
831
832webidl.converters['sequence<RequestInfo>'] = webidl.sequenceConverter(
833  webidl.converters.RequestInfo
834)
835
836module.exports = {
837  Cache
838}
839