| 'use strict' |
| |
| const { kConstruct } = require('./symbols') |
| const { urlEquals, fieldValues: getFieldValues } = require('./util') |
| const { kEnumerableProperty, isDisturbed } = require('../core/util') |
| const { kHeadersList } = require('../core/symbols') |
| const { webidl } = require('../fetch/webidl') |
| const { Response, cloneResponse } = require('../fetch/response') |
| const { Request } = require('../fetch/request') |
| const { kState, kHeaders, kGuard, kRealm } = require('../fetch/symbols') |
| const { fetching } = require('../fetch/index') |
| const { urlIsHttpHttpsScheme, createDeferredPromise, readAllBytes } = require('../fetch/util') |
| const assert = require('assert') |
| const { getGlobalDispatcher } = require('../global') |
| |
| /** |
| * @see https://w3c.github.io/ServiceWorker/#dfn-cache-batch-operation |
| * @typedef {Object} CacheBatchOperation |
| * @property {'delete' | 'put'} type |
| * @property {any} request |
| * @property {any} response |
| * @property {import('../../types/cache').CacheQueryOptions} options |
| */ |
| |
| /** |
| * @see https://w3c.github.io/ServiceWorker/#dfn-request-response-list |
| * @typedef {[any, any][]} requestResponseList |
| */ |
| |
| class Cache { |
| /** |
| * @see https://w3c.github.io/ServiceWorker/#dfn-relevant-request-response-list |
| * @type {requestResponseList} |
| */ |
| #relevantRequestResponseList |
| |
| constructor () { |
| if (arguments[0] !== kConstruct) { |
| webidl.illegalConstructor() |
| } |
| |
| this.#relevantRequestResponseList = arguments[1] |
| } |
| |
| async match (request, options = {}) { |
| webidl.brandCheck(this, Cache) |
| webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.match' }) |
| |
| request = webidl.converters.RequestInfo(request) |
| options = webidl.converters.CacheQueryOptions(options) |
| |
| const p = await this.matchAll(request, options) |
| |
| if (p.length === 0) { |
| return |
| } |
| |
| return p[0] |
| } |
| |
| async matchAll (request = undefined, options = {}) { |
| webidl.brandCheck(this, Cache) |
| |
| if (request !== undefined) request = webidl.converters.RequestInfo(request) |
| options = webidl.converters.CacheQueryOptions(options) |
| |
| // 1. |
| let r = null |
| |
| // 2. |
| if (request !== undefined) { |
| if (request instanceof Request) { |
| // 2.1.1 |
| r = request[kState] |
| |
| // 2.1.2 |
| if (r.method !== 'GET' && !options.ignoreMethod) { |
| return [] |
| } |
| } else if (typeof request === 'string') { |
| // 2.2.1 |
| r = new Request(request)[kState] |
| } |
| } |
| |
| // 5. |
| // 5.1 |
| const responses = [] |
| |
| // 5.2 |
| if (request === undefined) { |
| // 5.2.1 |
| for (const requestResponse of this.#relevantRequestResponseList) { |
| responses.push(requestResponse[1]) |
| } |
| } else { // 5.3 |
| // 5.3.1 |
| const requestResponses = this.#queryCache(r, options) |
| |
| // 5.3.2 |
| for (const requestResponse of requestResponses) { |
| responses.push(requestResponse[1]) |
| } |
| } |
| |
| // 5.4 |
| // We don't implement CORs so we don't need to loop over the responses, yay! |
| |
| // 5.5.1 |
| const responseList = [] |
| |
| // 5.5.2 |
| for (const response of responses) { |
| // 5.5.2.1 |
| const responseObject = new Response(response.body?.source ?? null) |
| const body = responseObject[kState].body |
| responseObject[kState] = response |
| responseObject[kState].body = body |
| responseObject[kHeaders][kHeadersList] = response.headersList |
| responseObject[kHeaders][kGuard] = 'immutable' |
| |
| responseList.push(responseObject) |
| } |
| |
| // 6. |
| return Object.freeze(responseList) |
| } |
| |
| async add (request) { |
| webidl.brandCheck(this, Cache) |
| webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.add' }) |
| |
| request = webidl.converters.RequestInfo(request) |
| |
| // 1. |
| const requests = [request] |
| |
| // 2. |
| const responseArrayPromise = this.addAll(requests) |
| |
| // 3. |
| return await responseArrayPromise |
| } |
| |
| async addAll (requests) { |
| webidl.brandCheck(this, Cache) |
| webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.addAll' }) |
| |
| requests = webidl.converters['sequence<RequestInfo>'](requests) |
| |
| // 1. |
| const responsePromises = [] |
| |
| // 2. |
| const requestList = [] |
| |
| // 3. |
| for (const request of requests) { |
| if (typeof request === 'string') { |
| continue |
| } |
| |
| // 3.1 |
| const r = request[kState] |
| |
| // 3.2 |
| if (!urlIsHttpHttpsScheme(r.url) || r.method !== 'GET') { |
| throw webidl.errors.exception({ |
| header: 'Cache.addAll', |
| message: 'Expected http/s scheme when method is not GET.' |
| }) |
| } |
| } |
| |
| // 4. |
| /** @type {ReturnType<typeof fetching>[]} */ |
| const fetchControllers = [] |
| |
| // 5. |
| for (const request of requests) { |
| // 5.1 |
| const r = new Request(request)[kState] |
| |
| // 5.2 |
| if (!urlIsHttpHttpsScheme(r.url)) { |
| throw webidl.errors.exception({ |
| header: 'Cache.addAll', |
| message: 'Expected http/s scheme.' |
| }) |
| } |
| |
| // 5.4 |
| r.initiator = 'fetch' |
| r.destination = 'subresource' |
| |
| // 5.5 |
| requestList.push(r) |
| |
| // 5.6 |
| const responsePromise = createDeferredPromise() |
| |
| // 5.7 |
| fetchControllers.push(fetching({ |
| request: r, |
| dispatcher: getGlobalDispatcher(), |
| processResponse (response) { |
| // 1. |
| if (response.type === 'error' || response.status === 206 || response.status < 200 || response.status > 299) { |
| responsePromise.reject(webidl.errors.exception({ |
| header: 'Cache.addAll', |
| message: 'Received an invalid status code or the request failed.' |
| })) |
| } else if (response.headersList.contains('vary')) { // 2. |
| // 2.1 |
| const fieldValues = getFieldValues(response.headersList.get('vary')) |
| |
| // 2.2 |
| for (const fieldValue of fieldValues) { |
| // 2.2.1 |
| if (fieldValue === '*') { |
| responsePromise.reject(webidl.errors.exception({ |
| header: 'Cache.addAll', |
| message: 'invalid vary field value' |
| })) |
| |
| for (const controller of fetchControllers) { |
| controller.abort() |
| } |
| |
| return |
| } |
| } |
| } |
| }, |
| processResponseEndOfBody (response) { |
| // 1. |
| if (response.aborted) { |
| responsePromise.reject(new DOMException('aborted', 'AbortError')) |
| return |
| } |
| |
| // 2. |
| responsePromise.resolve(response) |
| } |
| })) |
| |
| // 5.8 |
| responsePromises.push(responsePromise.promise) |
| } |
| |
| // 6. |
| const p = Promise.all(responsePromises) |
| |
| // 7. |
| const responses = await p |
| |
| // 7.1 |
| const operations = [] |
| |
| // 7.2 |
| let index = 0 |
| |
| // 7.3 |
| for (const response of responses) { |
| // 7.3.1 |
| /** @type {CacheBatchOperation} */ |
| const operation = { |
| type: 'put', // 7.3.2 |
| request: requestList[index], // 7.3.3 |
| response // 7.3.4 |
| } |
| |
| operations.push(operation) // 7.3.5 |
| |
| index++ // 7.3.6 |
| } |
| |
| // 7.5 |
| const cacheJobPromise = createDeferredPromise() |
| |
| // 7.6.1 |
| let errorData = null |
| |
| // 7.6.2 |
| try { |
| this.#batchCacheOperations(operations) |
| } catch (e) { |
| errorData = e |
| } |
| |
| // 7.6.3 |
| queueMicrotask(() => { |
| // 7.6.3.1 |
| if (errorData === null) { |
| cacheJobPromise.resolve(undefined) |
| } else { |
| // 7.6.3.2 |
| cacheJobPromise.reject(errorData) |
| } |
| }) |
| |
| // 7.7 |
| return cacheJobPromise.promise |
| } |
| |
| async put (request, response) { |
| webidl.brandCheck(this, Cache) |
| webidl.argumentLengthCheck(arguments, 2, { header: 'Cache.put' }) |
| |
| request = webidl.converters.RequestInfo(request) |
| response = webidl.converters.Response(response) |
| |
| // 1. |
| let innerRequest = null |
| |
| // 2. |
| if (request instanceof Request) { |
| innerRequest = request[kState] |
| } else { // 3. |
| innerRequest = new Request(request)[kState] |
| } |
| |
| // 4. |
| if (!urlIsHttpHttpsScheme(innerRequest.url) || innerRequest.method !== 'GET') { |
| throw webidl.errors.exception({ |
| header: 'Cache.put', |
| message: 'Expected an http/s scheme when method is not GET' |
| }) |
| } |
| |
| // 5. |
| const innerResponse = response[kState] |
| |
| // 6. |
| if (innerResponse.status === 206) { |
| throw webidl.errors.exception({ |
| header: 'Cache.put', |
| message: 'Got 206 status' |
| }) |
| } |
| |
| // 7. |
| if (innerResponse.headersList.contains('vary')) { |
| // 7.1. |
| const fieldValues = getFieldValues(innerResponse.headersList.get('vary')) |
| |
| // 7.2. |
| for (const fieldValue of fieldValues) { |
| // 7.2.1 |
| if (fieldValue === '*') { |
| throw webidl.errors.exception({ |
| header: 'Cache.put', |
| message: 'Got * vary field value' |
| }) |
| } |
| } |
| } |
| |
| // 8. |
| if (innerResponse.body && (isDisturbed(innerResponse.body.stream) || innerResponse.body.stream.locked)) { |
| throw webidl.errors.exception({ |
| header: 'Cache.put', |
| message: 'Response body is locked or disturbed' |
| }) |
| } |
| |
| // 9. |
| const clonedResponse = cloneResponse(innerResponse) |
| |
| // 10. |
| const bodyReadPromise = createDeferredPromise() |
| |
| // 11. |
| if (innerResponse.body != null) { |
| // 11.1 |
| const stream = innerResponse.body.stream |
| |
| // 11.2 |
| const reader = stream.getReader() |
| |
| // 11.3 |
| readAllBytes(reader).then(bodyReadPromise.resolve, bodyReadPromise.reject) |
| } else { |
| bodyReadPromise.resolve(undefined) |
| } |
| |
| // 12. |
| /** @type {CacheBatchOperation[]} */ |
| const operations = [] |
| |
| // 13. |
| /** @type {CacheBatchOperation} */ |
| const operation = { |
| type: 'put', // 14. |
| request: innerRequest, // 15. |
| response: clonedResponse // 16. |
| } |
| |
| // 17. |
| operations.push(operation) |
| |
| // 19. |
| const bytes = await bodyReadPromise.promise |
| |
| if (clonedResponse.body != null) { |
| clonedResponse.body.source = bytes |
| } |
| |
| // 19.1 |
| const cacheJobPromise = createDeferredPromise() |
| |
| // 19.2.1 |
| let errorData = null |
| |
| // 19.2.2 |
| try { |
| this.#batchCacheOperations(operations) |
| } catch (e) { |
| errorData = e |
| } |
| |
| // 19.2.3 |
| queueMicrotask(() => { |
| // 19.2.3.1 |
| if (errorData === null) { |
| cacheJobPromise.resolve() |
| } else { // 19.2.3.2 |
| cacheJobPromise.reject(errorData) |
| } |
| }) |
| |
| return cacheJobPromise.promise |
| } |
| |
| async delete (request, options = {}) { |
| webidl.brandCheck(this, Cache) |
| webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.delete' }) |
| |
| request = webidl.converters.RequestInfo(request) |
| options = webidl.converters.CacheQueryOptions(options) |
| |
| /** |
| * @type {Request} |
| */ |
| let r = null |
| |
| if (request instanceof Request) { |
| r = request[kState] |
| |
| if (r.method !== 'GET' && !options.ignoreMethod) { |
| return false |
| } |
| } else { |
| assert(typeof request === 'string') |
| |
| r = new Request(request)[kState] |
| } |
| |
| /** @type {CacheBatchOperation[]} */ |
| const operations = [] |
| |
| /** @type {CacheBatchOperation} */ |
| const operation = { |
| type: 'delete', |
| request: r, |
| options |
| } |
| |
| operations.push(operation) |
| |
| const cacheJobPromise = createDeferredPromise() |
| |
| let errorData = null |
| let requestResponses |
| |
| try { |
| requestResponses = this.#batchCacheOperations(operations) |
| } catch (e) { |
| errorData = e |
| } |
| |
| queueMicrotask(() => { |
| if (errorData === null) { |
| cacheJobPromise.resolve(!!requestResponses?.length) |
| } else { |
| cacheJobPromise.reject(errorData) |
| } |
| }) |
| |
| return cacheJobPromise.promise |
| } |
| |
| /** |
| * @see https://w3c.github.io/ServiceWorker/#dom-cache-keys |
| * @param {any} request |
| * @param {import('../../types/cache').CacheQueryOptions} options |
| * @returns {readonly Request[]} |
| */ |
| async keys (request = undefined, options = {}) { |
| webidl.brandCheck(this, Cache) |
| |
| if (request !== undefined) request = webidl.converters.RequestInfo(request) |
| options = webidl.converters.CacheQueryOptions(options) |
| |
| // 1. |
| let r = null |
| |
| // 2. |
| if (request !== undefined) { |
| // 2.1 |
| if (request instanceof Request) { |
| // 2.1.1 |
| r = request[kState] |
| |
| // 2.1.2 |
| if (r.method !== 'GET' && !options.ignoreMethod) { |
| return [] |
| } |
| } else if (typeof request === 'string') { // 2.2 |
| r = new Request(request)[kState] |
| } |
| } |
| |
| // 4. |
| const promise = createDeferredPromise() |
| |
| // 5. |
| // 5.1 |
| const requests = [] |
| |
| // 5.2 |
| if (request === undefined) { |
| // 5.2.1 |
| for (const requestResponse of this.#relevantRequestResponseList) { |
| // 5.2.1.1 |
| requests.push(requestResponse[0]) |
| } |
| } else { // 5.3 |
| // 5.3.1 |
| const requestResponses = this.#queryCache(r, options) |
| |
| // 5.3.2 |
| for (const requestResponse of requestResponses) { |
| // 5.3.2.1 |
| requests.push(requestResponse[0]) |
| } |
| } |
| |
| // 5.4 |
| queueMicrotask(() => { |
| // 5.4.1 |
| const requestList = [] |
| |
| // 5.4.2 |
| for (const request of requests) { |
| const requestObject = new Request('https://a') |
| requestObject[kState] = request |
| requestObject[kHeaders][kHeadersList] = request.headersList |
| requestObject[kHeaders][kGuard] = 'immutable' |
| requestObject[kRealm] = request.client |
| |
| // 5.4.2.1 |
| requestList.push(requestObject) |
| } |
| |
| // 5.4.3 |
| promise.resolve(Object.freeze(requestList)) |
| }) |
| |
| return promise.promise |
| } |
| |
| /** |
| * @see https://w3c.github.io/ServiceWorker/#batch-cache-operations-algorithm |
| * @param {CacheBatchOperation[]} operations |
| * @returns {requestResponseList} |
| */ |
| #batchCacheOperations (operations) { |
| // 1. |
| const cache = this.#relevantRequestResponseList |
| |
| // 2. |
| const backupCache = [...cache] |
| |
| // 3. |
| const addedItems = [] |
| |
| // 4.1 |
| const resultList = [] |
| |
| try { |
| // 4.2 |
| for (const operation of operations) { |
| // 4.2.1 |
| if (operation.type !== 'delete' && operation.type !== 'put') { |
| throw webidl.errors.exception({ |
| header: 'Cache.#batchCacheOperations', |
| message: 'operation type does not match "delete" or "put"' |
| }) |
| } |
| |
| // 4.2.2 |
| if (operation.type === 'delete' && operation.response != null) { |
| throw webidl.errors.exception({ |
| header: 'Cache.#batchCacheOperations', |
| message: 'delete operation should not have an associated response' |
| }) |
| } |
| |
| // 4.2.3 |
| if (this.#queryCache(operation.request, operation.options, addedItems).length) { |
| throw new DOMException('???', 'InvalidStateError') |
| } |
| |
| // 4.2.4 |
| let requestResponses |
| |
| // 4.2.5 |
| if (operation.type === 'delete') { |
| // 4.2.5.1 |
| requestResponses = this.#queryCache(operation.request, operation.options) |
| |
| // TODO: the spec is wrong, this is needed to pass WPTs |
| if (requestResponses.length === 0) { |
| return [] |
| } |
| |
| // 4.2.5.2 |
| for (const requestResponse of requestResponses) { |
| const idx = cache.indexOf(requestResponse) |
| assert(idx !== -1) |
| |
| // 4.2.5.2.1 |
| cache.splice(idx, 1) |
| } |
| } else if (operation.type === 'put') { // 4.2.6 |
| // 4.2.6.1 |
| if (operation.response == null) { |
| throw webidl.errors.exception({ |
| header: 'Cache.#batchCacheOperations', |
| message: 'put operation should have an associated response' |
| }) |
| } |
| |
| // 4.2.6.2 |
| const r = operation.request |
| |
| // 4.2.6.3 |
| if (!urlIsHttpHttpsScheme(r.url)) { |
| throw webidl.errors.exception({ |
| header: 'Cache.#batchCacheOperations', |
| message: 'expected http or https scheme' |
| }) |
| } |
| |
| // 4.2.6.4 |
| if (r.method !== 'GET') { |
| throw webidl.errors.exception({ |
| header: 'Cache.#batchCacheOperations', |
| message: 'not get method' |
| }) |
| } |
| |
| // 4.2.6.5 |
| if (operation.options != null) { |
| throw webidl.errors.exception({ |
| header: 'Cache.#batchCacheOperations', |
| message: 'options must not be defined' |
| }) |
| } |
| |
| // 4.2.6.6 |
| requestResponses = this.#queryCache(operation.request) |
| |
| // 4.2.6.7 |
| for (const requestResponse of requestResponses) { |
| const idx = cache.indexOf(requestResponse) |
| assert(idx !== -1) |
| |
| // 4.2.6.7.1 |
| cache.splice(idx, 1) |
| } |
| |
| // 4.2.6.8 |
| cache.push([operation.request, operation.response]) |
| |
| // 4.2.6.10 |
| addedItems.push([operation.request, operation.response]) |
| } |
| |
| // 4.2.7 |
| resultList.push([operation.request, operation.response]) |
| } |
| |
| // 4.3 |
| return resultList |
| } catch (e) { // 5. |
| // 5.1 |
| this.#relevantRequestResponseList.length = 0 |
| |
| // 5.2 |
| this.#relevantRequestResponseList = backupCache |
| |
| // 5.3 |
| throw e |
| } |
| } |
| |
| /** |
| * @see https://w3c.github.io/ServiceWorker/#query-cache |
| * @param {any} requestQuery |
| * @param {import('../../types/cache').CacheQueryOptions} options |
| * @param {requestResponseList} targetStorage |
| * @returns {requestResponseList} |
| */ |
| #queryCache (requestQuery, options, targetStorage) { |
| /** @type {requestResponseList} */ |
| const resultList = [] |
| |
| const storage = targetStorage ?? this.#relevantRequestResponseList |
| |
| for (const requestResponse of storage) { |
| const [cachedRequest, cachedResponse] = requestResponse |
| if (this.#requestMatchesCachedItem(requestQuery, cachedRequest, cachedResponse, options)) { |
| resultList.push(requestResponse) |
| } |
| } |
| |
| return resultList |
| } |
| |
| /** |
| * @see https://w3c.github.io/ServiceWorker/#request-matches-cached-item-algorithm |
| * @param {any} requestQuery |
| * @param {any} request |
| * @param {any | null} response |
| * @param {import('../../types/cache').CacheQueryOptions | undefined} options |
| * @returns {boolean} |
| */ |
| #requestMatchesCachedItem (requestQuery, request, response = null, options) { |
| // if (options?.ignoreMethod === false && request.method === 'GET') { |
| // return false |
| // } |
| |
| const queryURL = new URL(requestQuery.url) |
| |
| const cachedURL = new URL(request.url) |
| |
| if (options?.ignoreSearch) { |
| cachedURL.search = '' |
| |
| queryURL.search = '' |
| } |
| |
| if (!urlEquals(queryURL, cachedURL, true)) { |
| return false |
| } |
| |
| if ( |
| response == null || |
| options?.ignoreVary || |
| !response.headersList.contains('vary') |
| ) { |
| return true |
| } |
| |
| const fieldValues = getFieldValues(response.headersList.get('vary')) |
| |
| for (const fieldValue of fieldValues) { |
| if (fieldValue === '*') { |
| return false |
| } |
| |
| const requestValue = request.headersList.get(fieldValue) |
| const queryValue = requestQuery.headersList.get(fieldValue) |
| |
| // If one has the header and the other doesn't, or one has |
| // a different value than the other, return false |
| if (requestValue !== queryValue) { |
| return false |
| } |
| } |
| |
| return true |
| } |
| } |
| |
| Object.defineProperties(Cache.prototype, { |
| [Symbol.toStringTag]: { |
| value: 'Cache', |
| configurable: true |
| }, |
| match: kEnumerableProperty, |
| matchAll: kEnumerableProperty, |
| add: kEnumerableProperty, |
| addAll: kEnumerableProperty, |
| put: kEnumerableProperty, |
| delete: kEnumerableProperty, |
| keys: kEnumerableProperty |
| }) |
| |
| const cacheQueryOptionConverters = [ |
| { |
| key: 'ignoreSearch', |
| converter: webidl.converters.boolean, |
| defaultValue: false |
| }, |
| { |
| key: 'ignoreMethod', |
| converter: webidl.converters.boolean, |
| defaultValue: false |
| }, |
| { |
| key: 'ignoreVary', |
| converter: webidl.converters.boolean, |
| defaultValue: false |
| } |
| ] |
| |
| webidl.converters.CacheQueryOptions = webidl.dictionaryConverter(cacheQueryOptionConverters) |
| |
| webidl.converters.MultiCacheQueryOptions = webidl.dictionaryConverter([ |
| ...cacheQueryOptionConverters, |
| { |
| key: 'cacheName', |
| converter: webidl.converters.DOMString |
| } |
| ]) |
| |
| webidl.converters.Response = webidl.interfaceConverter(Response) |
| |
| webidl.converters['sequence<RequestInfo>'] = webidl.sequenceConverter( |
| webidl.converters.RequestInfo |
| ) |
| |
| module.exports = { |
| Cache |
| } |