|
class TypeError extends Error { |
|
static { |
|
this.prototype.name = this.name; |
|
} |
|
} |
|
|
|
export default class Cache { |
|
#entries = []; |
|
add(request) { |
|
return this.addAll([ request ]); |
|
} |
|
addAll(requests) { |
|
if(requests.some(request => (!(new URL(request.url)).protocol.match(/https?:$/) || request.method != "GET"))) throw new TypeError; |
|
const responsePromises = []; |
|
const requestList = []; |
|
const fetchControllers = []; |
|
for(const request of requests) { |
|
const controller = new AbortController(), { signal } = controller; |
|
fetchControllers.push(controller); |
|
requestList.push(request); |
|
let responsePromise; |
|
responsePromises.push(responsePromise = fetch(request, { signal }).then(processResponse)); |
|
function processResponse(response) { |
|
if(response.type == "error" || !response.ok || response.status == 206) throw new TypeError; |
|
if(response.headers.has("Vary")) { |
|
const fieldValues = response.headers.get("Vary"); |
|
for(const fieldValue of fieldValues) { |
|
if(fieldValue == '*') throw new TypeError; |
|
for(const fetchController of fetchControllers) fetchController.abort(); |
|
break; |
|
} |
|
} |
|
} |
|
} |
|
return Promise.all(responsePromises).then(responses => { |
|
const operations = []; |
|
let index = 0; |
|
for(const response of responses) { |
|
const operation = { |
|
type: "put", |
|
request: requestList[index], |
|
response |
|
}; |
|
operations.push(operation); |
|
index = index + 1; |
|
} |
|
this.batchCacheOperations(operations); |
|
}); |
|
} |
|
delete(request, options = {}) { |
|
if(request instanceof Request) { |
|
if(request.method != "GET" && !options.ignoreMethod) return false; |
|
} else |
|
if(typeof request == "string") request = new Request(request); |
|
return this.batchCacheOperations([ { type: "delete", request, options } ]); |
|
} |
|
match(request, options = {}) { |
|
return this.matchAll(request, options)[0]; |
|
} |
|
/** |
|
* Not compliant with the spec: |
|
* |
|
* 1. Couldn't find a way to implement "[For each `response` of `responses`] add a new `Response` object associated with `response` and a new `Headers` object whose guard is "immutable" to `responseList`." |
|
*/ |
|
matchAll(request, options = {}) { |
|
if(request !== undefined) { |
|
if(request instanceof Request) { |
|
if(request.method != "GET" && options.ignoreMethod == false) return []; |
|
} else |
|
if(typeof request == "string") request = new Request(request); |
|
} |
|
return (request ? this.queryCache(request, options) : this.#entries).map(([ _, response ]) => this.copy(response)); |
|
/*if(responses.some(response => (response.type == "opaque") && this.crossOriginResourcePolicyCheck(location.origin, response) == "blocked")) throw new TypeError;*/ |
|
} |
|
async put(request, response) { |
|
if(!new URL(request.url).protocol.match(/^https?:$/) || request.method != "GET") throw new TypeError; |
|
if(response.status == 206) throw new TypeError(); |
|
if(response.headers.has("Vary")) { |
|
const fieldValues = response.headers.get("Vary").split(/\s*,\s*/); |
|
for(const fieldValue of fieldValues) if(fieldValue == '*') throw new TypeError; |
|
} |
|
if(response.body.disturbed || response.body.locked) throw new TypeError; |
|
const clonedResponse = response.clone(); |
|
let bodyReadPromise = Promise.resolve(undefined); |
|
if(response.body != null) await response.bytes(); |
|
this.batchCacheOperations([ { type: "put", request, response } ]); |
|
} |
|
batchCacheOperations(operations) { |
|
let cache = this.#entries; |
|
let backupCache = Array.from(cache); |
|
let addedItems = []; |
|
try { |
|
let resultList = []; |
|
for(const { type, request, response, options } of operations) { |
|
if(!type.match(/^delete|put$/)) throw new TypeError; |
|
if(type == "delete" && response != null) throw new TypeError; |
|
if(this.queryCache(request, options, addedItems).length) throw new DOMException("Query Cache returned a non-empty list", "InvalidStateError"); |
|
let requestResponses = []; |
|
switch(type) { |
|
case "delete":requestResponses = this.queryCache(request, options); |
|
for(const requestResponse of requestResponses) removeFromCache(requestResponse, cache); |
|
break; |
|
case "put": |
|
if(response == null) throw new TypeError; |
|
if(!new URL(request.url).protocol.match(/^https?:$/)) throw new TypeError; |
|
if(request.method != "GET") throw new TypeError; |
|
if(options != null) throw new TypeError; |
|
requestResponses = this.queryCache(request); |
|
for(const requestResponse of requestResponses) removeFromCache(requestResponse, cache); |
|
cache.push([ request, response ]); |
|
addedItems.push([ request, response ]); |
|
break; |
|
} |
|
resultList.push([ request, response ]); |
|
} |
|
return resultList; |
|
} catch(error) { |
|
this.#entries.splice(0, this.#entries.length, ...backupCache); |
|
throw error; |
|
} |
|
const removeFromCache = (requestResponse, cache) => { |
|
const index = cache.findIndex(([ _, response ]) => (response == requestResponse)); |
|
if(index < 0) throw new Error("Not found"); |
|
cache.splice(index, 1); |
|
}; |
|
} |
|
queryCache(requestQuery, options = {}, targetStorage = this.#entries) { |
|
return targetStorage.filter(entry => this.requestMatchesCachedItem(requestQuery, ...entry, options)).map(entry => entry.map(item => this.copy(item))); |
|
} |
|
requestMatchesCachedItem(requestQuery, request, response, options = {}) { |
|
if(!options.ignoreMethod && request.method != "GET") return false; |
|
const queryURL = new URL(requestQuery.url); |
|
const cachedURL = new URL(request.url); |
|
if(options.ignoreSearch) { |
|
cachedURL.search = ''; |
|
queryURL.search = ''; |
|
} |
|
queryURL.hash = cachedURL.hash = ''; |
|
if(queryURL.toString() != cachedURL.toString()) return false; |
|
if(!response || options.ignoreVary || !response.headers.has("Vary")) return true; |
|
const fieldValues = response.headers.get("Vary").split(/\s*,\s*/); |
|
return !fieldValues.includes('*') && fieldValues.every(fieldValue => (request.headers.get(fieldValue) == requestQuery.headers.get(fieldValue))); |
|
} |
|
copy(obj) { |
|
return obj; //obj.clone(); |
|
} |
|
crossOriginResourcePolicyCheck() { |
|
throw new Error("Not implemented"); |
|
} |
|
} |
|
|
|
async function* match(request, options = {}, fn) { |
|
for(const key of await this.keys(request, options)) { |
|
yield* (await this.cache.matchAll(key)).map(response => [ key, response ]); |
|
} |
|
} |
|
|
|
await (async function test() { |
|
const url_equal = (a, b, options = {}) => { |
|
const mask = options.ignoreSearch ? { search: '' } : {}; |
|
return (Object.assign(a, mask).toString() == Object.assign(b, mask).toString()); |
|
}; |
|
const responses_equal = (a, b, options) => ((a == b) || url_equal(new URL(a.url), new URL(b.url), options)); |
|
const cache = new Cache(); |
|
const request = new Request("/helo"), response = new Response("Hi"); |
|
await cache.put(request, response); |
|
console.assert(responses_equal(cache.match(request), response)); |
|
delete(request); |
|
})(); |