2025-04-02 06:50:39 -04:00

921 lines
22 KiB
JavaScript

'use strict';
const { kConstruct } = require('../../core/symbols');
const { urlEquals, getFieldValues } = require('./util');
const { kEnumerableProperty, isDisturbed } = require('../../core/util');
const { webidl } = require('../fetch/webidl');
const {
cloneResponse,
fromInnerResponse,
getResponseState,
} = require('../fetch/response');
const {
Request,
fromInnerRequest,
getRequestState,
} = require('../fetch/request');
const { fetching } = require('../fetch/index');
const {
urlIsHttpHttpsScheme,
createDeferredPromise,
readAllBytes,
} = require('../fetch/util');
const assert = require('node:assert');
/**
* @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();
}
webidl.util.markAsUncloneable(this);
this.#relevantRequestResponseList = arguments[1];
}
async match(request, options = {}) {
webidl.brandCheck(this, Cache);
const prefix = 'Cache.match';
webidl.argumentLengthCheck(arguments, 1, prefix);
request = webidl.converters.RequestInfo(request, prefix, 'request');
options = webidl.converters.CacheQueryOptions(options, prefix, 'options');
const p = this.#internalMatchAll(request, options, 1);
if (p.length === 0) {
return;
}
return p[0];
}
async matchAll(request = undefined, options = {}) {
webidl.brandCheck(this, Cache);
const prefix = 'Cache.matchAll';
if (request !== undefined)
request = webidl.converters.RequestInfo(request, prefix, 'request');
options = webidl.converters.CacheQueryOptions(options, prefix, 'options');
return this.#internalMatchAll(request, options);
}
async add(request) {
webidl.brandCheck(this, Cache);
const prefix = 'Cache.add';
webidl.argumentLengthCheck(arguments, 1, prefix);
request = webidl.converters.RequestInfo(request, prefix, 'request');
// 1.
const requests = [request];
// 2.
const responseArrayPromise = this.addAll(requests);
// 3.
return await responseArrayPromise;
}
async addAll(requests) {
webidl.brandCheck(this, Cache);
const prefix = 'Cache.addAll';
webidl.argumentLengthCheck(arguments, 1, prefix);
// 1.
const responsePromises = [];
// 2.
const requestList = [];
// 3.
for (let request of requests) {
if (request === undefined) {
throw webidl.errors.conversionFailed({
prefix,
argument: 'Argument 1',
types: ['undefined is not allowed'],
});
}
request = webidl.converters.RequestInfo(request);
if (typeof request === 'string') {
continue;
}
// 3.1
const r = getRequestState(request);
// 3.2
if (!urlIsHttpHttpsScheme(r.url) || r.method !== 'GET') {
throw webidl.errors.exception({
header: prefix,
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 = getRequestState(new Request(request));
// 5.2
if (!urlIsHttpHttpsScheme(r.url)) {
throw webidl.errors.exception({
header: prefix,
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,
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);
const prefix = 'Cache.put';
webidl.argumentLengthCheck(arguments, 2, prefix);
request = webidl.converters.RequestInfo(request, prefix, 'request');
response = webidl.converters.Response(response, prefix, 'response');
// 1.
let innerRequest = null;
// 2.
if (webidl.is.Request(request)) {
innerRequest = getRequestState(request);
} else {
// 3.
innerRequest = getRequestState(new Request(request));
}
// 4.
if (
!urlIsHttpHttpsScheme(innerRequest.url) ||
innerRequest.method !== 'GET'
) {
throw webidl.errors.exception({
header: prefix,
message: 'Expected an http/s scheme when method is not GET',
});
}
// 5.
const innerResponse = getResponseState(response);
// 6.
if (innerResponse.status === 206) {
throw webidl.errors.exception({
header: prefix,
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: prefix,
message: 'Got * vary field value',
});
}
}
}
// 8.
if (
innerResponse.body &&
(isDisturbed(innerResponse.body.stream) ||
innerResponse.body.stream.locked)
) {
throw webidl.errors.exception({
header: prefix,
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, 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);
const prefix = 'Cache.delete';
webidl.argumentLengthCheck(arguments, 1, prefix);
request = webidl.converters.RequestInfo(request, prefix, 'request');
options = webidl.converters.CacheQueryOptions(options, prefix, 'options');
/**
* @type {Request}
*/
let r = null;
if (webidl.is.Request(request)) {
r = getRequestState(request);
if (r.method !== 'GET' && !options.ignoreMethod) {
return false;
}
} else {
assert(typeof request === 'string');
r = getRequestState(new Request(request));
}
/** @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 {Promise<readonly Request[]>}
*/
async keys(request = undefined, options = {}) {
webidl.brandCheck(this, Cache);
const prefix = 'Cache.keys';
if (request !== undefined)
request = webidl.converters.RequestInfo(request, prefix, 'request');
options = webidl.converters.CacheQueryOptions(options, prefix, 'options');
// 1.
let r = null;
// 2.
if (request !== undefined) {
// 2.1
if (webidl.is.Request(request)) {
// 2.1.1
r = getRequestState(request);
// 2.1.2
if (r.method !== 'GET' && !options.ignoreMethod) {
return [];
}
} else if (typeof request === 'string') {
// 2.2
r = getRequestState(new Request(request));
}
}
// 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 = fromInnerRequest(
request,
undefined,
new AbortController().signal,
'immutable'
);
// 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;
}
#internalMatchAll(request, options, maxResponses = Infinity) {
// 1.
let r = null;
// 2.
if (request !== undefined) {
if (webidl.is.Request(request)) {
// 2.1.1
r = getRequestState(request);
// 2.1.2
if (r.method !== 'GET' && !options.ignoreMethod) {
return [];
}
} else if (typeof request === 'string') {
// 2.2.1
r = getRequestState(new Request(request));
}
}
// 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 = fromInnerResponse(response, 'immutable');
responseList.push(responseObject.clone());
if (responseList.length >= maxResponses) {
break;
}
}
// 6.
return Object.freeze(responseList);
}
}
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(
webidl.is.Response,
'Response'
);
webidl.converters['sequence<RequestInfo>'] = webidl.sequenceConverter(
webidl.converters.RequestInfo
);
module.exports = {
Cache,
};