Skip to content

Instantly share code, notes, and snippets.

@amn
Last active July 14, 2025 21:41
Show Gist options
  • Save amn/fe452361c886b0a36cb525945e9fa0ab to your computer and use it in GitHub Desktop.
Save amn/fe452361c886b0a36cb525945e9fa0ab to your computer and use it in GitHub Desktop.
A forgiving implementation of the `Cache` class defined by the [Service Workers specification](https://www.w3.org/TR/service-workers/#cache). By forgiving I mean that it's not as strict with regards to input as the specification mandates.

This is a forgiving implementation of the Cache class defined by the Service Workers specification. By forgiving I mean that it's not as strict with regards to input, and some parts of specification aren't straightforward to implement (e.g. the crossOriginResourcePolicyCheck implementation is intentionally omitted above). I wrote it to understand how the natively implemented Cache equivalent is supposed to function, since reading the above should in theory be simpler than reading the specification, ironically.

Another reason I am publishing this, is to highlight -- for both myself and whomever this may concern -- that even a compliant Cache implementation hides a fairly simple HTTP "caching" facility, which could be more applicable across broader set of scenarios had it not been so strict with input. To attempt to explain -- by specification, Cache shall refuse attempts to add entries where the request is by HTTP "not supposed to be cacheable", which is requests with methods other than GET judging by the specification. Yet there are many applications where Cache could be useful in a way where it's not actually working as HTTP cache but merely a well-indexed database of request-response pair. Currently, the native Cache class implemented in user agents like Chrome, Firefox etc, being compliant with the spec., are unable to support such applications. Which is why this class serves as an alternative.

This is, however, very much work in progress -- the implementation can be further simplified. The question is, which parts of specification compliance should we abandon in order to "generalize" the behaviour? I might leave this readable as far as understanding Cache goes, and implement the more generic request-response database I was referring to earlier, in another class and repository.

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);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment