• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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(
383        reader,
384        (bytes) => bodyReadPromise.resolve(bytes),
385        (error) => bodyReadPromise.reject(error)
386      )
387    } else {
388      bodyReadPromise.resolve(undefined)
389    }
390
391    // 12.
392    /** @type {CacheBatchOperation[]} */
393    const operations = []
394
395    // 13.
396    /** @type {CacheBatchOperation} */
397    const operation = {
398      type: 'put', // 14.
399      request: innerRequest, // 15.
400      response: clonedResponse // 16.
401    }
402
403    // 17.
404    operations.push(operation)
405
406    // 19.
407    const bytes = await bodyReadPromise.promise
408
409    if (clonedResponse.body != null) {
410      clonedResponse.body.source = bytes
411    }
412
413    // 19.1
414    const cacheJobPromise = createDeferredPromise()
415
416    // 19.2.1
417    let errorData = null
418
419    // 19.2.2
420    try {
421      this.#batchCacheOperations(operations)
422    } catch (e) {
423      errorData = e
424    }
425
426    // 19.2.3
427    queueMicrotask(() => {
428      // 19.2.3.1
429      if (errorData === null) {
430        cacheJobPromise.resolve()
431      } else { // 19.2.3.2
432        cacheJobPromise.reject(errorData)
433      }
434    })
435
436    return cacheJobPromise.promise
437  }
438
439  async delete (request, options = {}) {
440    webidl.brandCheck(this, Cache)
441    webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.delete' })
442
443    request = webidl.converters.RequestInfo(request)
444    options = webidl.converters.CacheQueryOptions(options)
445
446    /**
447     * @type {Request}
448     */
449    let r = null
450
451    if (request instanceof Request) {
452      r = request[kState]
453
454      if (r.method !== 'GET' && !options.ignoreMethod) {
455        return false
456      }
457    } else {
458      assert(typeof request === 'string')
459
460      r = new Request(request)[kState]
461    }
462
463    /** @type {CacheBatchOperation[]} */
464    const operations = []
465
466    /** @type {CacheBatchOperation} */
467    const operation = {
468      type: 'delete',
469      request: r,
470      options
471    }
472
473    operations.push(operation)
474
475    const cacheJobPromise = createDeferredPromise()
476
477    let errorData = null
478    let requestResponses
479
480    try {
481      requestResponses = this.#batchCacheOperations(operations)
482    } catch (e) {
483      errorData = e
484    }
485
486    queueMicrotask(() => {
487      if (errorData === null) {
488        cacheJobPromise.resolve(!!requestResponses?.length)
489      } else {
490        cacheJobPromise.reject(errorData)
491      }
492    })
493
494    return cacheJobPromise.promise
495  }
496
497  /**
498   * @see https://w3c.github.io/ServiceWorker/#dom-cache-keys
499   * @param {any} request
500   * @param {import('../../types/cache').CacheQueryOptions} options
501   * @returns {readonly Request[]}
502   */
503  async keys (request = undefined, options = {}) {
504    webidl.brandCheck(this, Cache)
505
506    if (request !== undefined) request = webidl.converters.RequestInfo(request)
507    options = webidl.converters.CacheQueryOptions(options)
508
509    // 1.
510    let r = null
511
512    // 2.
513    if (request !== undefined) {
514      // 2.1
515      if (request instanceof Request) {
516        // 2.1.1
517        r = request[kState]
518
519        // 2.1.2
520        if (r.method !== 'GET' && !options.ignoreMethod) {
521          return []
522        }
523      } else if (typeof request === 'string') { // 2.2
524        r = new Request(request)[kState]
525      }
526    }
527
528    // 4.
529    const promise = createDeferredPromise()
530
531    // 5.
532    // 5.1
533    const requests = []
534
535    // 5.2
536    if (request === undefined) {
537      // 5.2.1
538      for (const requestResponse of this.#relevantRequestResponseList) {
539        // 5.2.1.1
540        requests.push(requestResponse[0])
541      }
542    } else { // 5.3
543      // 5.3.1
544      const requestResponses = this.#queryCache(r, options)
545
546      // 5.3.2
547      for (const requestResponse of requestResponses) {
548        // 5.3.2.1
549        requests.push(requestResponse[0])
550      }
551    }
552
553    // 5.4
554    queueMicrotask(() => {
555      // 5.4.1
556      const requestList = []
557
558      // 5.4.2
559      for (const request of requests) {
560        const requestObject = new Request('https://a')
561        requestObject[kState] = request
562        requestObject[kHeaders][kHeadersList] = request.headersList
563        requestObject[kHeaders][kGuard] = 'immutable'
564        requestObject[kRealm] = request.client
565
566        // 5.4.2.1
567        requestList.push(requestObject)
568      }
569
570      // 5.4.3
571      promise.resolve(Object.freeze(requestList))
572    })
573
574    return promise.promise
575  }
576
577  /**
578   * @see https://w3c.github.io/ServiceWorker/#batch-cache-operations-algorithm
579   * @param {CacheBatchOperation[]} operations
580   * @returns {requestResponseList}
581   */
582  #batchCacheOperations (operations) {
583    // 1.
584    const cache = this.#relevantRequestResponseList
585
586    // 2.
587    const backupCache = [...cache]
588
589    // 3.
590    const addedItems = []
591
592    // 4.1
593    const resultList = []
594
595    try {
596      // 4.2
597      for (const operation of operations) {
598        // 4.2.1
599        if (operation.type !== 'delete' && operation.type !== 'put') {
600          throw webidl.errors.exception({
601            header: 'Cache.#batchCacheOperations',
602            message: 'operation type does not match "delete" or "put"'
603          })
604        }
605
606        // 4.2.2
607        if (operation.type === 'delete' && operation.response != null) {
608          throw webidl.errors.exception({
609            header: 'Cache.#batchCacheOperations',
610            message: 'delete operation should not have an associated response'
611          })
612        }
613
614        // 4.2.3
615        if (this.#queryCache(operation.request, operation.options, addedItems).length) {
616          throw new DOMException('???', 'InvalidStateError')
617        }
618
619        // 4.2.4
620        let requestResponses
621
622        // 4.2.5
623        if (operation.type === 'delete') {
624          // 4.2.5.1
625          requestResponses = this.#queryCache(operation.request, operation.options)
626
627          // TODO: the spec is wrong, this is needed to pass WPTs
628          if (requestResponses.length === 0) {
629            return []
630          }
631
632          // 4.2.5.2
633          for (const requestResponse of requestResponses) {
634            const idx = cache.indexOf(requestResponse)
635            assert(idx !== -1)
636
637            // 4.2.5.2.1
638            cache.splice(idx, 1)
639          }
640        } else if (operation.type === 'put') { // 4.2.6
641          // 4.2.6.1
642          if (operation.response == null) {
643            throw webidl.errors.exception({
644              header: 'Cache.#batchCacheOperations',
645              message: 'put operation should have an associated response'
646            })
647          }
648
649          // 4.2.6.2
650          const r = operation.request
651
652          // 4.2.6.3
653          if (!urlIsHttpHttpsScheme(r.url)) {
654            throw webidl.errors.exception({
655              header: 'Cache.#batchCacheOperations',
656              message: 'expected http or https scheme'
657            })
658          }
659
660          // 4.2.6.4
661          if (r.method !== 'GET') {
662            throw webidl.errors.exception({
663              header: 'Cache.#batchCacheOperations',
664              message: 'not get method'
665            })
666          }
667
668          // 4.2.6.5
669          if (operation.options != null) {
670            throw webidl.errors.exception({
671              header: 'Cache.#batchCacheOperations',
672              message: 'options must not be defined'
673            })
674          }
675
676          // 4.2.6.6
677          requestResponses = this.#queryCache(operation.request)
678
679          // 4.2.6.7
680          for (const requestResponse of requestResponses) {
681            const idx = cache.indexOf(requestResponse)
682            assert(idx !== -1)
683
684            // 4.2.6.7.1
685            cache.splice(idx, 1)
686          }
687
688          // 4.2.6.8
689          cache.push([operation.request, operation.response])
690
691          // 4.2.6.10
692          addedItems.push([operation.request, operation.response])
693        }
694
695        // 4.2.7
696        resultList.push([operation.request, operation.response])
697      }
698
699      // 4.3
700      return resultList
701    } catch (e) { // 5.
702      // 5.1
703      this.#relevantRequestResponseList.length = 0
704
705      // 5.2
706      this.#relevantRequestResponseList = backupCache
707
708      // 5.3
709      throw e
710    }
711  }
712
713  /**
714   * @see https://w3c.github.io/ServiceWorker/#query-cache
715   * @param {any} requestQuery
716   * @param {import('../../types/cache').CacheQueryOptions} options
717   * @param {requestResponseList} targetStorage
718   * @returns {requestResponseList}
719   */
720  #queryCache (requestQuery, options, targetStorage) {
721    /** @type {requestResponseList} */
722    const resultList = []
723
724    const storage = targetStorage ?? this.#relevantRequestResponseList
725
726    for (const requestResponse of storage) {
727      const [cachedRequest, cachedResponse] = requestResponse
728      if (this.#requestMatchesCachedItem(requestQuery, cachedRequest, cachedResponse, options)) {
729        resultList.push(requestResponse)
730      }
731    }
732
733    return resultList
734  }
735
736  /**
737   * @see https://w3c.github.io/ServiceWorker/#request-matches-cached-item-algorithm
738   * @param {any} requestQuery
739   * @param {any} request
740   * @param {any | null} response
741   * @param {import('../../types/cache').CacheQueryOptions | undefined} options
742   * @returns {boolean}
743   */
744  #requestMatchesCachedItem (requestQuery, request, response = null, options) {
745    // if (options?.ignoreMethod === false && request.method === 'GET') {
746    //   return false
747    // }
748
749    const queryURL = new URL(requestQuery.url)
750
751    const cachedURL = new URL(request.url)
752
753    if (options?.ignoreSearch) {
754      cachedURL.search = ''
755
756      queryURL.search = ''
757    }
758
759    if (!urlEquals(queryURL, cachedURL, true)) {
760      return false
761    }
762
763    if (
764      response == null ||
765      options?.ignoreVary ||
766      !response.headersList.contains('vary')
767    ) {
768      return true
769    }
770
771    const fieldValues = getFieldValues(response.headersList.get('vary'))
772
773    for (const fieldValue of fieldValues) {
774      if (fieldValue === '*') {
775        return false
776      }
777
778      const requestValue = request.headersList.get(fieldValue)
779      const queryValue = requestQuery.headersList.get(fieldValue)
780
781      // If one has the header and the other doesn't, or one has
782      // a different value than the other, return false
783      if (requestValue !== queryValue) {
784        return false
785      }
786    }
787
788    return true
789  }
790}
791
792Object.defineProperties(Cache.prototype, {
793  [Symbol.toStringTag]: {
794    value: 'Cache',
795    configurable: true
796  },
797  match: kEnumerableProperty,
798  matchAll: kEnumerableProperty,
799  add: kEnumerableProperty,
800  addAll: kEnumerableProperty,
801  put: kEnumerableProperty,
802  delete: kEnumerableProperty,
803  keys: kEnumerableProperty
804})
805
806const cacheQueryOptionConverters = [
807  {
808    key: 'ignoreSearch',
809    converter: webidl.converters.boolean,
810    defaultValue: false
811  },
812  {
813    key: 'ignoreMethod',
814    converter: webidl.converters.boolean,
815    defaultValue: false
816  },
817  {
818    key: 'ignoreVary',
819    converter: webidl.converters.boolean,
820    defaultValue: false
821  }
822]
823
824webidl.converters.CacheQueryOptions = webidl.dictionaryConverter(cacheQueryOptionConverters)
825
826webidl.converters.MultiCacheQueryOptions = webidl.dictionaryConverter([
827  ...cacheQueryOptionConverters,
828  {
829    key: 'cacheName',
830    converter: webidl.converters.DOMString
831  }
832])
833
834webidl.converters.Response = webidl.interfaceConverter(Response)
835
836webidl.converters['sequence<RequestInfo>'] = webidl.sequenceConverter(
837  webidl.converters.RequestInfo
838)
839
840module.exports = {
841  Cache
842}
843