diff --git a/.changeset/silly-lamps-flash.md b/.changeset/silly-lamps-flash.md
new file mode 100644
index 000000000..82d1ac736
--- /dev/null
+++ b/.changeset/silly-lamps-flash.md
@@ -0,0 +1,5 @@
+---
+'@lion/ajax': patch
+---
+
+Fix cache session race condition for in-flight requests
diff --git a/.changeset/tall-adults-act.md b/.changeset/tall-adults-act.md
new file mode 100644
index 000000000..ed3c4e889
--- /dev/null
+++ b/.changeset/tall-adults-act.md
@@ -0,0 +1,8 @@
+---
+'@lion/ajax': minor
+---
+
+**BREAKING** public API changes:
+
+ - Changed `timeToLive` to `maxAge`
+ - Renamed `requestIdentificationFn` to `requestIdFunction`
diff --git a/docs/docs/tools/ajax/features.md b/docs/docs/tools/ajax/features.md
index afd094501..033093fe2 100644
--- a/docs/docs/tools/ajax/features.md
+++ b/docs/docs/tools/ajax/features.md
@@ -15,9 +15,11 @@ const getCacheIdentifier = () => {
return userId;
};
+const TEN_MINUTES = 1000 * 60 * 10; // in milliseconds
+
const cacheOptions = {
useCache: true,
- timeToLive: 1000 * 60 * 10, // 10 minutes
+ maxAge: TEN_MINUTES,
};
const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
@@ -72,9 +74,13 @@ const newUser = await response.json();
### JSON requests
-We usually deal with JSON requests and responses. With `fetchJson` you don't need to specifically stringify the request body or parse the response body.
+We usually deal with JSON requests and responses. `ajax.fetchJson` supports JSON by:
-The result will have the Response object on `.response` property, and the decoded json will be available on `.body`.
+- Serializing request body as JSON
+- Deserializing response payload as JSON
+- Adding the correct Content-Type and Accept headers
+
+> Note that, the result will have the Response object on `.response` property, and the parsed JSON will be available on `.body`.
## GET JSON request
@@ -133,7 +139,7 @@ export const errorHandling = () => {
}
} else {
// an error happened before receiving a response,
- // ex. an incorrect request or network error
+ // Example: an incorrect request or network error
actionLogger.log(error);
}
}
@@ -157,32 +163,28 @@ For IE11 you will need a polyfill for fetch. You should add this on your top lev
[This is the polyfill we recommend](https://github.com/github/fetch). It also has a [section for polyfilling AbortController](https://github.com/github/fetch#aborting-requests)
-## Ajax Cache
+## Ajax Caching Support
-A caching library that uses `@lion/ajax` and adds cache interceptors to provide caching for use in
-frontend `services`.
+Ajax package provides in-memory cache support through interceptors. And cache interceptors can be added manually or by configuring the Ajax instance.
-The **request interceptor**'s main goal is to determine whether or not to
-**return the cached object**. This is done based on the options that are being
-passed.
+The cache request interceptor and cache response interceptor are designed to work together to support caching of network requests/responses.
-The **response interceptor**'s goal is to determine **when to cache** the
-requested response, based on the options that are being passed.
+> The **request interceptor** checks whether the response for this particular request is cached, and if so returns the cached response.
+> And the **response interceptor** caches the response for this particular request.
### Getting started
Consume the global `ajax` instance and add interceptors to it, using a cache configuration which is applied on application level. If a developer wants to add specifics to cache behaviour they have to provide a cache config per action (`get`, `post`, etc.) via `cacheOptions` field of local ajax config,
see examples below.
-> **Note**: make sure to add the **interceptors** only **once**. This is usually
-> done on app-level
+> **Note**: make sure to add the **interceptors** only **once**. This is usually done on app-level
```js
import { ajax, createCacheInterceptors } from '@lion-web/ajax';
const globalCacheOptions = {
useCache: true,
- timeToLive: 1000 * 60 * 5, // 5 minutes
+ maxAge: 1000 * 60 * 5, // 5 minutes
};
// Cache is removed each time an identifier changes,
@@ -208,7 +210,7 @@ import { Ajax } from '@lion/ajax';
export const ajax = new Ajax({
cacheOptions: {
useCache: true,
- timeToLive: 1000 * 60 * 5, // 5 minutes
+ maxAge: 1000 * 60 * 5, // 5 minutes
getCacheIdentifier: () => getActiveProfile().profileId,
},
});
@@ -218,8 +220,7 @@ export const ajax = new Ajax({
> Let's assume that we have a user session, for this demo purposes we already created an identifier function for this and set the cache interceptors.
-We can see if a response is served from the cache by checking the `response.fromCache` property,
-which is either undefined for normal requests, or set to true for responses that were served from cache.
+We can see if a response is served from the cache by checking the `response.fromCache` property, which is either undefined for normal requests, or set to true for responses that were served from cache.
```js preview-story
export const cache = () => {
@@ -284,28 +285,28 @@ export const cacheActionOptions = () => {
Invalidating the cache, or cache busting, can be done in multiple ways:
-- Going past the `timeToLive` of the cache object
+- Going past the `maxAge` of the cache object
- Changing cache identifier (e.g. user session or active profile changes)
- Doing a non GET request to the cached endpoint
- Invalidates the cache of that endpoint
- Invalidates the cache of all other endpoints matching `invalidatesUrls` and `invalidateUrlsRegex`
-## Time to live
+## maxAge
-In this demo we pass a timeToLive of three seconds.
-Try clicking the fetch button and watch fromCache change whenever TTL expires.
+In this demo we pass a maxAge of three seconds.
+Try clicking the fetch button and watch fromCache change whenever maxAge expires.
-After TTL expires, the next request will set the cache again, and for the next 3 seconds you will get cached responses for subsequent requests.
+After maxAge expires, the next request will set the cache again, and for the next 3 seconds you will get cached responses for subsequent requests.
```js preview-story
-export const cacheTimeToLive = () => {
+export const cacheMaxAge = () => {
const actionLogger = renderLitAsNode(html``);
const fetchHandler = () => {
ajax
.fetchJson(`../assets/pabu.json`, {
cacheOptions: {
- timeToLive: 1000 * 3, // 3 seconds
+ maxAge: 1000 * 3, // 3 seconds
},
})
.then(result => {
diff --git a/docs/docs/tools/ajax/overview.md b/docs/docs/tools/ajax/overview.md
index dafaae40d..64b93ac28 100644
--- a/docs/docs/tools/ajax/overview.md
+++ b/docs/docs/tools/ajax/overview.md
@@ -1,10 +1,7 @@
# Tools >> Ajax >> Overview ||10
```js script
-import { html } from '@mdjs/mdjs-preview';
-import { renderLitAsNode } from '@lion/helpers';
import { ajax, createCacheInterceptors } from '@lion/ajax';
-import '@lion/helpers/define';
const getCacheIdentifier = () => {
let userId = localStorage.getItem('lion-ajax-cache-demo-user-id');
@@ -15,9 +12,11 @@ const getCacheIdentifier = () => {
return userId;
};
+const TEN_MINUTES = 1000 * 60 * 10; // in milliseconds
+
const cacheOptions = {
useCache: true,
- timeToLive: 1000 * 60 * 10, // 10 minutes
+ maxAge: TEN_MINUTES,
};
const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
@@ -33,8 +32,8 @@ ajax.addResponseInterceptor(cacheResponseInterceptor);
- Allows globally registering request and response interceptors
- Throws on 4xx and 5xx status codes
-- Prevents network request if a request interceptor returns a response
-- Supports a JSON request which automatically encodes/decodes body request and response payload as JSON
+- Supports caching, so a request can be prevented from reaching to network, by returning the cached response.
+- Supports JSON with `ajax.fetchJSON` by automatically serializing request body and deserializing response payload as JSON, and adding the correct Content-Type and Accept headers.
- Adds accept-language header to requests based on application language
- Adds XSRF header to request if the cookie is present
diff --git a/packages/ajax/src/Ajax.js b/packages/ajax/src/Ajax.js
index ea4cf19cc..337337b41 100644
--- a/packages/ajax/src/Ajax.js
+++ b/packages/ajax/src/Ajax.js
@@ -8,9 +8,14 @@ import { AjaxFetchError } from './AjaxFetchError.js';
import './typedef.js';
/**
- * HTTP Client which acts as a small wrapper around `fetch`. Allows registering hooks which
- * intercept request and responses, for example to add authorization headers or logging. A
- * request can also be prevented from reaching the network at all by returning the Response directly.
+ * A small wrapper around `fetch`.
+- Allows globally registering request and response interceptors
+- Throws on 4xx and 5xx status codes
+- Supports caching, so a request can be prevented from reaching to network, by returning the cached response.
+- Supports JSON with `ajax.fetchJSON` by automatically serializing request body and
+ deserializing response payload as JSON, and adding the correct Content-Type and Accept headers.
+- Adds accept-language header to requests based on application language
+- Adds XSRF header to request if the cookie is present
*/
export class Ajax {
/**
@@ -49,18 +54,18 @@ export class Ajax {
const { cacheOptions } = this.__config;
if (cacheOptions?.useCache) {
- const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
+ const { cacheRequestInterceptor, cacheResponseInterceptor } = createCacheInterceptors(
cacheOptions.getCacheIdentifier,
cacheOptions,
);
- this.addRequestInterceptor(/** @type {RequestInterceptor} */ (cacheRequestInterceptor));
- this.addResponseInterceptor(/** @type {ResponseInterceptor} */ (cacheResponseInterceptor));
+ this.addRequestInterceptor(cacheRequestInterceptor);
+ this.addResponseInterceptor(cacheResponseInterceptor);
}
}
/**
- * Sets the config for the instance
- * @param {Partial} config configuration for the AjaxClass instance
+ * Configures the Ajax instance
+ * @param {Partial} config configuration for the Ajax instance
*/
set options(config) {
this.__config = config;
@@ -95,8 +100,7 @@ export class Ajax {
}
/**
- * Makes a fetch request, calling the registered fetch request and response
- * interceptors.
+ * Fetch by calling the registered request and response interceptors.
*
* @param {RequestInfo} info
* @param {RequestInit & Partial} [init]
@@ -126,8 +130,11 @@ export class Ajax {
}
/**
- * Makes a fetch request, calling the registered fetch request and response
- * interceptors. Encodes/decodes the request and response body as JSON.
+ * Fetch by calling the registered request and response
+ * interceptors. And supports JSON by:
+ * - Serializing request body as JSON
+ * - Deserializing response payload as JSON
+ * - Adding the correct Content-Type and Accept headers
*
* @param {RequestInfo} info
* @param {LionRequestInit} [init]
@@ -149,7 +156,7 @@ export class Ajax {
lionInit.body = JSON.stringify(lionInit.body);
}
- // Now that we stringified lionInit.body, we can safely typecast LionRequestInit back to RequestInit
+ // typecast LionRequestInit back to RequestInit
const jsonInit = /** @type {RequestInit} */ (lionInit);
const response = await this.fetch(info, jsonInit);
let responseText = await response.text();
diff --git a/packages/ajax/src/Cache.js b/packages/ajax/src/Cache.js
new file mode 100644
index 000000000..4f64a7011
--- /dev/null
+++ b/packages/ajax/src/Cache.js
@@ -0,0 +1,65 @@
+import './typedef.js';
+
+export default class Cache {
+ constructor() {
+ /**
+ * @type {{ [requestId: string]: { createdAt: number, response: CacheResponse } }}
+ * @private
+ */
+ this._cachedRequests = {};
+ }
+
+ /**
+ * Store an item in the cache
+ * @param {string} requestId key by which the request is stored
+ * @param {Response} response the cached response
+ */
+ set(requestId, response) {
+ this._cachedRequests[requestId] = {
+ createdAt: Date.now(),
+ response,
+ };
+ }
+
+ /**
+ * Retrieve an item from the cache
+ * @param {string} requestId key by which the cache is stored
+ * @param {number} maxAge maximum age of a cached request to serve from cache, in milliseconds
+ * @returns {CacheResponse | undefined}
+ */
+ get(requestId, maxAge = 0) {
+ const cachedRequest = this._cachedRequests[requestId];
+ if (!cachedRequest) {
+ return;
+ }
+ const cachedRequestAge = Date.now() - cachedRequest.createdAt;
+ if (Number.isFinite(maxAge) && cachedRequestAge < maxAge) {
+ // eslint-disable-next-line consistent-return
+ return cachedRequest.response;
+ }
+ }
+
+ /**
+ * Delete the item with the given requestId from the cache
+ * @param {string } requestId the request id to delete from the cache
+ */
+ delete(requestId) {
+ delete this._cachedRequests[requestId];
+ }
+
+ /**
+ * Delete all items from the cache that match given regex
+ * @param {RegExp} regex a regular expression to match cache entries
+ */
+ deleteMatching(regex) {
+ Object.keys(this._cachedRequests).forEach(requestId => {
+ if (new RegExp(regex).test(requestId)) {
+ this.delete(requestId);
+ }
+ });
+ }
+
+ reset() {
+ this._cachedRequests = {};
+ }
+}
diff --git a/packages/ajax/src/PendingRequestStore.js b/packages/ajax/src/PendingRequestStore.js
new file mode 100644
index 000000000..413519604
--- /dev/null
+++ b/packages/ajax/src/PendingRequestStore.js
@@ -0,0 +1,62 @@
+import './typedef.js';
+
+export default class PendingRequestStore {
+ constructor() {
+ /**
+ * @type {{ [requestId: string]: { promise: Promise, resolve: (value?: any) => void } }}
+ * @private
+ */
+ this._pendingRequests = {};
+ }
+
+ /**
+ * Creates a promise for a pending request with given key
+ * @param {string} requestId
+ */
+ set(requestId) {
+ if (this._pendingRequests[requestId]) {
+ return;
+ }
+ /** @type {(value?: any) => void } */
+ let resolve;
+ const promise = new Promise(_resolve => {
+ resolve = _resolve;
+ });
+ // @ts-ignore
+ this._pendingRequests[requestId] = { promise, resolve };
+ }
+
+ /**
+ * Gets the promise for a pending request with given key
+ * @param {string} requestId
+ * @returns {Promise | undefined}
+ */
+ get(requestId) {
+ return this._pendingRequests[requestId]?.promise;
+ }
+
+ /**
+ * Resolves the promise of a pending request that matches the given string
+ * @param { string } requestId the requestId to resolve
+ */
+ resolve(requestId) {
+ this._pendingRequests[requestId]?.resolve();
+ delete this._pendingRequests[requestId];
+ }
+
+ /**
+ * Resolves the promise of pending requests that match the given regex
+ * @param { RegExp } regex an regular expression to match store entries
+ */
+ resolveMatching(regex) {
+ Object.keys(this._pendingRequests).forEach(pendingRequestId => {
+ if (regex.test(pendingRequestId)) {
+ this.resolve(pendingRequestId);
+ }
+ });
+ }
+
+ reset() {
+ this._pendingRequests = {};
+ }
+}
diff --git a/packages/ajax/src/cache.js b/packages/ajax/src/cache.js
deleted file mode 100644
index ff046bfcc..000000000
--- a/packages/ajax/src/cache.js
+++ /dev/null
@@ -1,221 +0,0 @@
-/* eslint-disable consistent-return */
-/* eslint-disable no-param-reassign */
-import './typedef.js';
-
-const SECOND = 1000;
-const MINUTE = SECOND * 60;
-const HOUR = MINUTE * 60;
-const DEFAULT_TIME_TO_LIVE = HOUR;
-
-class Cache {
- constructor() {
- this.expiration = new Date().getTime() + DEFAULT_TIME_TO_LIVE;
- /**
- * @type {{[url: string]: {expires: number, response: CacheResponse} }}
- * @private
- */
- this._cacheObject = {};
- /**
- * @type {{ [url: string]: { promise: Promise, resolve: (v?: any) => void } }}
- * @private
- */
- this._pendingRequests = {};
- }
-
- /** @param {string} url */
- setPendingRequest(url) {
- /** @type {(v: any) => void} */
- let resolve = () => {};
- const promise = new Promise(_resolve => {
- resolve = _resolve;
- });
- this._pendingRequests[url] = { promise, resolve };
- }
-
- /**
- * @param {string} url
- * @returns {Promise | undefined}
- */
- getPendingRequest(url) {
- if (this._pendingRequests[url]) {
- return this._pendingRequests[url].promise;
- }
- }
-
- /** @param {string} url */
- resolvePendingRequest(url) {
- if (this._pendingRequests[url]) {
- this._pendingRequests[url].resolve();
- delete this._pendingRequests[url];
- }
- }
-
- /**
- * Store an item in the cache
- * @param {string} url key by which the cache is stored
- * @param {Response} response the cached response
- */
- set(url, response) {
- this._validateCache();
- this._cacheObject[url] = {
- expires: new Date().getTime(),
- response,
- };
- }
-
- /**
- * Retrieve an item from the cache
- * @param {string} url key by which the cache is stored
- * @param {number} timeToLive maximum time to allow cache to live
- * @returns {CacheResponse | false}
- */
- get(url, timeToLive) {
- this._validateCache();
-
- const cacheResult = this._cacheObject[url];
- if (!cacheResult) {
- return false;
- }
- const cacheAge = new Date().getTime() - cacheResult.expires;
-
- if (timeToLive !== null && cacheAge > timeToLive) {
- return false;
- }
- return cacheResult.response;
- }
-
- /**
- * Delete all items from the cache that contain the given url
- * @param {string} url key by which the cache is stored
- */
- delete(url) {
- this._validateCache();
-
- Object.keys(this._cacheObject).forEach(key => {
- if (key.includes(url)) {
- delete this._cacheObject[key];
- this.resolvePendingRequest(key);
- }
- });
- }
-
- /**
- * Delete all items from the cache that match given regex
- * @param {RegExp} regex an regular expression to match cache entries
- */
- deleteMatched(regex) {
- this._validateCache();
-
- Object.keys(this._cacheObject).forEach(key => {
- const notMatch = !new RegExp(regex).test(key);
- if (notMatch) return;
- delete this._cacheObject[key];
- this.resolvePendingRequest(key);
- });
- }
-
- /**
- * Validate cache on each call to the Cache
- * When the expiration date has passed, the _cacheObject will be replaced by an
- * empty object
- * @protected
- */
- _validateCache() {
- if (new Date().getTime() > this.expiration) {
- this._cacheObject = {};
- return false;
- }
- return true;
- }
-}
-
-let caches = {};
-
-/**
- * Serialize search parameters into url query string parameters.
- * If params === null, returns ''
- * @param {Params} params query string parameters object
- * @returns {string} of querystring parameters WITHOUT `?` or empty string ''
- */
-export const stringifySearchParams = (params = {}) =>
- typeof params === 'object' && params !== null ? new URLSearchParams(params).toString() : '';
-
-/**
- * Returns the active cache instance for the current session
- * If 'cacheIdentifier' changes the cache is reset, we avoid situation of accessing old cache
- * and proactively clean it
- * @param {string} cacheIdentifier usually the refreshToken of the owner of the cache
- */
-export const getCache = cacheIdentifier => {
- if (caches[cacheIdentifier]?._validateCache()) {
- return caches[cacheIdentifier];
- }
- // invalidate old caches
- caches = {};
- // create new cache
- caches[cacheIdentifier] = new Cache();
- return caches[cacheIdentifier];
-};
-
-/**
- * @param {CacheOptions} options Options to match cache
- * @returns {ValidatedCacheOptions}
- */
-export const validateCacheOptions = ({
- useCache = false,
- methods = ['get'],
- timeToLive,
- invalidateUrls,
- invalidateUrlsRegex,
- requestIdentificationFn,
-}) => {
- // validate 'cache'
- if (typeof useCache !== 'boolean') {
- throw new Error('Property `useCache` should be `true` or `false`');
- }
-
- if (methods[0] !== 'get' || methods.length !== 1) {
- throw new Error('Functionality to use cache on anything except "get" is not yet supported');
- }
-
- // validate 'timeToLive', default 1 hour
- if (timeToLive === undefined) {
- timeToLive = DEFAULT_TIME_TO_LIVE;
- }
- if (Number.isNaN(parseInt(String(timeToLive), 10))) {
- throw new Error('Property `timeToLive` must be of type `number`');
- }
- // validate 'invalidateUrls', must be an `Array` or `falsy`
- if (invalidateUrls) {
- if (!Array.isArray(invalidateUrls)) {
- throw new Error('Property `invalidateUrls` must be of type `Array` or `falsy`');
- }
- }
- // validate 'invalidateUrlsRegex', must be an regex expression or `falsy`
- if (invalidateUrlsRegex) {
- if (!(invalidateUrlsRegex instanceof RegExp)) {
- throw new Error('Property `invalidateUrlsRegex` must be of type `RegExp` or `falsy`');
- }
- }
- // validate 'requestIdentificationFn', default is url + searchParams
- if (requestIdentificationFn) {
- if (typeof requestIdentificationFn !== 'function') {
- throw new Error('Property `requestIdentificationFn` must be of type `function`');
- }
- } else {
- // eslint-disable-next-line no-shadow
- requestIdentificationFn = /** @param {any} data */ ({ url, params }, stringifySearchParams) => {
- const serializedParams = stringifySearchParams(params);
- return serializedParams ? `${url}?${serializedParams}` : url;
- };
- }
-
- return {
- useCache,
- methods,
- timeToLive,
- invalidateUrls,
- invalidateUrlsRegex,
- requestIdentificationFn,
- };
-};
diff --git a/packages/ajax/src/cacheManager.js b/packages/ajax/src/cacheManager.js
new file mode 100644
index 000000000..67ad5c3b0
--- /dev/null
+++ b/packages/ajax/src/cacheManager.js
@@ -0,0 +1,157 @@
+import './typedef.js';
+import Cache from './Cache.js';
+import PendingRequestStore from './PendingRequestStore.js';
+
+/**
+ * The id for the cache session
+ * @type {string | undefined}
+ */
+let cacheSessionId;
+
+/**
+ * The ajax cache
+ * @type {Cache}
+ */
+export const ajaxCache = new Cache();
+
+/**
+ * The pending request store
+ * @type {PendingRequestStore}
+ */
+export const pendingRequestStore = new PendingRequestStore();
+
+/**
+ * Checks whether the given cacheSessionId matches the currently active id.
+ *
+ * @param {string|undefined} cacheId The cache id to check
+ */
+export const isCurrentSessionId = cacheId => cacheId === cacheSessionId;
+
+/**
+ * Resets the cache session when the cacheId changes.
+ *
+ * There can be only 1 active session at all times.
+ * @param {string} cacheId The cache id that is tied to the current session
+ */
+export const resetCacheSession = cacheId => {
+ if (!cacheId) {
+ throw new Error('Invalid cache identifier');
+ }
+ if (!isCurrentSessionId(cacheId)) {
+ cacheSessionId = cacheId;
+ ajaxCache.reset();
+ pendingRequestStore.reset();
+ }
+};
+
+/**
+ * Stringify URL search params
+ * @param {Params} params query string parameters object
+ * @returns {string} of querystring parameters WITHOUT `?` or empty string ''
+ */
+const stringifySearchParams = (params = {}) =>
+ typeof params === 'object' && params !== null ? new URLSearchParams(params).toString() : '';
+
+/**
+ * Returns request key string, which uniquely identifies a Request
+ * @param {Partial} request Request object
+ * @param {function} serializeSearchParams Function to serialize URL search params
+ * @returns {string} requestId to uniquely identify a request
+ */
+const DEFAULT_GET_REQUEST_ID = (
+ { url = '', params },
+ serializeSearchParams = stringifySearchParams,
+) => {
+ const serializedParams = serializeSearchParams(params);
+ return serializedParams ? `${url}?${serializedParams}` : url;
+};
+
+/**
+ * Defaults to 1 hour
+ */
+const DEFAULT_MAX_AGE = 1000 * 60 * 60;
+
+/**
+ * @param {CacheOptions} options Cache options
+ * @returns {ValidatedCacheOptions}
+ */
+export const extendCacheOptions = ({
+ useCache = false,
+ methods = ['get'],
+ maxAge = DEFAULT_MAX_AGE,
+ requestIdFunction = DEFAULT_GET_REQUEST_ID,
+ invalidateUrls,
+ invalidateUrlsRegex,
+}) => ({
+ useCache,
+ methods,
+ maxAge,
+ requestIdFunction,
+ invalidateUrls,
+ invalidateUrlsRegex,
+});
+
+/**
+ * @param {CacheOptions} options Cache options
+ */
+export const validateCacheOptions = ({
+ useCache,
+ methods,
+ maxAge,
+ requestIdFunction,
+ invalidateUrls,
+ invalidateUrlsRegex,
+} = {}) => {
+ if (useCache !== undefined && typeof useCache !== 'boolean') {
+ throw new Error('Property `useCache` must be a `boolean`');
+ }
+ if (methods !== undefined && JSON.stringify(methods) !== JSON.stringify(['get'])) {
+ throw new Error('Cache can only be utilized with `GET` method');
+ }
+ if (maxAge !== undefined && !Number.isFinite(maxAge)) {
+ throw new Error('Property `maxAge` must be a finite `number`');
+ }
+ if (invalidateUrls !== undefined && !Array.isArray(invalidateUrls)) {
+ throw new Error('Property `invalidateUrls` must be an `Array` or `falsy`');
+ }
+ if (invalidateUrlsRegex !== undefined && !(invalidateUrlsRegex instanceof RegExp)) {
+ throw new Error('Property `invalidateUrlsRegex` must be a `RegExp` or `falsy`');
+ }
+ if (requestIdFunction !== undefined && typeof requestIdFunction !== 'function') {
+ throw new Error('Property `requestIdFunction` must be a `function`');
+ }
+};
+
+/**
+ * Invalidates matching requestIds in the cache and pendingRequestStore
+ *
+ * There are two kinds of invalidate rules:
+ * invalidateUrls (array of URL like strings)
+ * invalidateUrlsRegex (RegExp)
+ * If a non-GET method is fired, by default it only invalidates its own endpoint.
+ * Invalidating /api/users cache by doing a PATCH, will not invalidate /api/accounts cache.
+ * However, in the case of users and accounts, they may be very interconnected,
+ * so perhaps you do want to invalidate /api/accounts when invalidating /api/users.
+ * If it's NOT one of the config.methods, invalidate caches
+ *
+ * @param requestId { string }
+ * @param cacheOptions { CacheOptions }
+ */
+export const invalidateMatchingCache = (requestId, { invalidateUrls, invalidateUrlsRegex }) => {
+ // invalidate this request
+ ajaxCache.delete(requestId);
+ pendingRequestStore.resolve(requestId);
+
+ // also invalidate caches matching to invalidateUrls
+ if (Array.isArray(invalidateUrls)) {
+ invalidateUrls.forEach(url => {
+ ajaxCache.delete(url);
+ pendingRequestStore.resolve(url);
+ });
+ }
+ // also invalidate caches matching to invalidateUrlsRegex
+ if (invalidateUrlsRegex) {
+ ajaxCache.deleteMatching(invalidateUrlsRegex);
+ pendingRequestStore.resolveMatching(invalidateUrlsRegex);
+ }
+};
diff --git a/packages/ajax/src/interceptors/cacheInterceptors.js b/packages/ajax/src/interceptors/cacheInterceptors.js
index fa85904ab..f00c1803b 100644
--- a/packages/ajax/src/interceptors/cacheInterceptors.js
+++ b/packages/ajax/src/interceptors/cacheInterceptors.js
@@ -1,137 +1,112 @@
/* eslint-disable no-param-reassign */
import '../typedef.js';
-import { validateCacheOptions, stringifySearchParams, getCache } from '../cache.js';
+import {
+ ajaxCache,
+ resetCacheSession,
+ extendCacheOptions,
+ validateCacheOptions,
+ invalidateMatchingCache,
+ pendingRequestStore,
+ isCurrentSessionId,
+} from '../cacheManager.js';
/**
* Request interceptor to return relevant cached requests
- * @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
+ * @param {function(): string} getCacheId used to invalidate cache if identifier is changed
* @param {CacheOptions} globalCacheOptions
* @returns {RequestInterceptor}
*/
-const createCacheRequestInterceptor = (getCacheIdentifier, globalCacheOptions) => {
- const validatedInitialCacheOptions = validateCacheOptions(globalCacheOptions);
+const createCacheRequestInterceptor =
+ (getCacheId, globalCacheOptions) => /** @param {CacheRequest} request */ async request => {
+ validateCacheOptions(request.cacheOptions);
+ const cacheSessionId = getCacheId();
+ resetCacheSession(cacheSessionId); // cacheSessionId is used to bind the cache to the current session
- return /** @param {CacheRequest} cacheRequest */ async cacheRequest => {
- const cacheOptions = validateCacheOptions({
- ...validatedInitialCacheOptions,
- ...cacheRequest.cacheOptions,
+ const cacheOptions = extendCacheOptions({
+ ...globalCacheOptions,
+ ...request.cacheOptions,
});
- cacheRequest.cacheOptions = cacheOptions;
+ // store cacheOptions and cacheSessionId in the request, to use it in the response interceptor.
+ request.cacheOptions = cacheOptions;
+ request.cacheSessionId = cacheSessionId;
- // don't use cache if 'useCache' === false
if (!cacheOptions.useCache) {
- return cacheRequest;
+ return request;
}
- const cacheId = cacheOptions.requestIdentificationFn(cacheRequest, stringifySearchParams);
- // cacheIdentifier is used to bind the cache to the current session
- const currentCache = getCache(getCacheIdentifier());
- const { method } = cacheRequest;
+ const requestId = cacheOptions.requestIdFunction(request);
+ const isMethodSupported = cacheOptions.methods.includes(request.method.toLowerCase());
- // don't use cache if the request method is not part of the configs methods
- if (!cacheOptions.methods.includes(method.toLowerCase())) {
- // If it's NOT one of the config.methods, invalidate caches
- currentCache.delete(cacheId);
- // also invalidate caches matching to cacheOptions
- if (cacheOptions.invalidateUrls) {
- cacheOptions.invalidateUrls.forEach(
- /** @type {string} */ invalidateUrl => {
- currentCache.delete(invalidateUrl);
- },
- );
- }
- // also invalidate caches matching to invalidateUrlsRegex
- if (cacheOptions.invalidateUrlsRegex) {
- currentCache.deleteMatched(cacheOptions.invalidateUrlsRegex);
- }
-
- return cacheRequest;
+ if (!isMethodSupported) {
+ invalidateMatchingCache(requestId, cacheOptions);
+ return request;
}
- const pendingRequest = currentCache.getPendingRequest(cacheId);
+ const pendingRequest = pendingRequestStore.get(requestId);
if (pendingRequest) {
// there is another concurrent request, wait for it to finish
await pendingRequest;
}
- const cacheResponse = currentCache.get(cacheId, cacheOptions.timeToLive);
- if (cacheResponse) {
- cacheRequest.cacheOptions = cacheRequest.cacheOptions ?? { useCache: false };
- const response = /** @type {CacheResponse} */ cacheResponse.clone();
- response.request = cacheRequest;
+ const cachedResponse = ajaxCache.get(requestId, cacheOptions.maxAge);
+ if (cachedResponse) {
+ // Return the response from cache
+ request.cacheOptions = request.cacheOptions ?? { useCache: false };
+ /** @type {CacheResponse} */
+ const response = cachedResponse.clone();
+ response.request = request;
response.fromCache = true;
return response;
}
- // we do want to use caching for this requesting, but it's not already cached
- // mark this as a pending request, so that concurrent requests can reuse it from the cache
- currentCache.setPendingRequest(cacheId);
-
- return cacheRequest;
+ // Mark this as a pending request, so that concurrent requests can use the response from this request
+ pendingRequestStore.set(requestId);
+ return request;
};
-};
/**
* Response interceptor to cache relevant requests
- * @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
* @param {CacheOptions} globalCacheOptions
* @returns {ResponseInterceptor}
*/
-const createCacheResponseInterceptor = (getCacheIdentifier, globalCacheOptions) => {
- const validatedInitialCacheOptions = validateCacheOptions(globalCacheOptions);
-
- /**
- * Axios response https://github.com/axios/axios#response-schema
- */
- return /** @param {CacheResponse} cacheResponse */ async cacheResponse => {
- if (!getCacheIdentifier()) {
- throw new Error(`getCacheIdentifier returns falsy`);
+const createCacheResponseInterceptor =
+ globalCacheOptions => /** @param {CacheResponse} response */ async response => {
+ if (!response.request) {
+ throw new Error('Missing request in response');
}
- if (!cacheResponse.request) {
- throw new Error('Missing request in response.');
- }
-
- const cacheOptions = validateCacheOptions({
- ...validatedInitialCacheOptions,
- ...cacheResponse.request?.cacheOptions,
+ const cacheOptions = extendCacheOptions({
+ ...globalCacheOptions,
+ ...response.request.cacheOptions,
});
- // string that identifies cache entry
- const cacheId = cacheOptions.requestIdentificationFn(
- cacheResponse.request,
- stringifySearchParams,
- );
- const currentCache = getCache(getCacheIdentifier());
- const isAlreadyFromCache = !!cacheResponse.fromCache;
- // caching all responses with not default `timeToLive`
- const isCacheActive = cacheOptions.timeToLive > 0;
- const isMethodSupported = cacheOptions.methods.includes(
- cacheResponse.request.method.toLowerCase(),
- );
- // if the request is one of the options.methods; store response in cache
- if (!isAlreadyFromCache && isCacheActive && isMethodSupported) {
- // store the response data in the cache and mark request as resolved
- currentCache.set(cacheId, cacheResponse.clone());
- }
+ const requestId = cacheOptions.requestIdFunction(response.request);
+ const isAlreadyFromCache = !!response.fromCache;
+ const isCacheActive = cacheOptions.useCache;
+ const isMethodSupported = cacheOptions.methods.includes(response.request?.method.toLowerCase());
- currentCache.resolvePendingRequest(cacheId);
- return cacheResponse;
+ if (!isAlreadyFromCache && isCacheActive && isMethodSupported) {
+ if (isCurrentSessionId(response.request.cacheSessionId)) {
+ // Cache the response
+ ajaxCache.set(requestId, response.clone());
+ }
+
+ // Mark the pending request as resolved
+ pendingRequestStore.resolve(requestId);
+ }
+ return response;
};
-};
/**
* Response interceptor to cache relevant requests
- * @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
+ * @param {function(): string} getCacheId used to invalidate cache if identifier is changed
* @param {CacheOptions} globalCacheOptions
- * @returns [{RequestInterceptor}, {ResponseInterceptor}]
+ * @returns {{cacheRequestInterceptor: RequestInterceptor, cacheResponseInterceptor: ResponseInterceptor}}
*/
-export const createCacheInterceptors = (getCacheIdentifier, globalCacheOptions) => {
- const requestInterceptor = createCacheRequestInterceptor(getCacheIdentifier, globalCacheOptions);
- const responseInterceptor = createCacheResponseInterceptor(
- getCacheIdentifier,
- globalCacheOptions,
- );
- return [requestInterceptor, responseInterceptor];
+export const createCacheInterceptors = (getCacheId, globalCacheOptions) => {
+ validateCacheOptions(globalCacheOptions);
+ const cacheRequestInterceptor = createCacheRequestInterceptor(getCacheId, globalCacheOptions);
+ const cacheResponseInterceptor = createCacheResponseInterceptor(globalCacheOptions);
+ return { cacheRequestInterceptor, cacheResponseInterceptor };
};
diff --git a/packages/ajax/src/typedef.js b/packages/ajax/src/typedef.js
index 201b0de78..62c31af80 100644
--- a/packages/ajax/src/typedef.js
+++ b/packages/ajax/src/typedef.js
@@ -5,7 +5,7 @@
* @typedef {import('../types/types').ResponseInterceptor} ResponseInterceptor
* @typedef {import('../types/types').CacheConfig} CacheConfig
* @typedef {import('../types/types').Params} Params
- * @typedef {import('../types/types').RequestIdentificationFn} RequestIdentificationFn
+ * @typedef {import('../types/types').RequestIdFunction} RequestIdFunction
* @typedef {import('../types/types').CacheOptions} CacheOptions
* @typedef {import('../types/types').ValidatedCacheOptions} ValidatedCacheOptions
* @typedef {import('../types/types').CacheRequestExtension} CacheRequestExtension
diff --git a/packages/ajax/test/Ajax.test.js b/packages/ajax/test/Ajax.test.js
index f7ae7dc9b..786931b9a 100644
--- a/packages/ajax/test/Ajax.test.js
+++ b/packages/ajax/test/Ajax.test.js
@@ -26,7 +26,7 @@ describe('Ajax', () => {
jsonPrefix: ")]}',",
cacheOptions: {
useCache: true,
- timeToLive: 1000 * 60 * 5, // 5 minutes
+ maxAge: 1000 * 60 * 5, // 5 minutes
getCacheIdentifier,
},
};
@@ -37,7 +37,7 @@ describe('Ajax', () => {
jsonPrefix: ")]}',",
cacheOptions: {
useCache: true,
- timeToLive: 300000,
+ maxAge: 300000,
getCacheIdentifier,
},
};
@@ -53,7 +53,7 @@ describe('Ajax', () => {
const config = {
cacheOptions: {
useCache: true,
- timeToLive: 1000 * 60 * 5, // 5 minutes
+ maxAge: 1000 * 60 * 5, // 5 minutes
},
};
// When
@@ -288,7 +288,7 @@ describe('Ajax', () => {
const customAjax = new Ajax({
cacheOptions: {
useCache: true,
- timeToLive: 100,
+ maxAge: 100,
getCacheIdentifier,
},
});
diff --git a/packages/ajax/test/Cache.test.js b/packages/ajax/test/Cache.test.js
new file mode 100644
index 000000000..7e27fb77c
--- /dev/null
+++ b/packages/ajax/test/Cache.test.js
@@ -0,0 +1,196 @@
+// @ts-nocheck
+import { expect } from '@open-wc/testing';
+import Cache from '../src/Cache.js';
+
+const A_MINUTE_IN_MS = 60 * 1000;
+const TWO_MINUTES_IN_MS = 2 * A_MINUTE_IN_MS;
+const TEN_MINUTES_IN_MS = 10 * A_MINUTE_IN_MS;
+
+describe('Cache', () => {
+ describe('public interface', () => {
+ const cache = new Cache();
+
+ it('Cache has `set` method', () => {
+ expect(cache.set).to.exist;
+ });
+
+ it('Cache has `get` method', () => {
+ expect(cache.get).to.exist;
+ });
+
+ it('Cache has `delete` method', () => {
+ expect(cache.delete).to.exist;
+ });
+
+ it('Cache has `reset` method', () => {
+ expect(cache.reset).to.exist;
+ });
+ });
+
+ describe('cache.get', () => {
+ // Mock cache data
+ const cache = new Cache();
+
+ cache._cachedRequests = {
+ requestId1: { createdAt: Date.now() - TWO_MINUTES_IN_MS, response: 'cached data 1' },
+ requestId2: { createdAt: Date.now(), response: 'cached data 2' },
+ };
+
+ it('returns undefined if no cached request found for requestId', () => {
+ // Given
+ const maxAge = TEN_MINUTES_IN_MS;
+ const expected = undefined;
+ // When
+ const result = cache.get('nonCachedRequestId', maxAge);
+ // Then
+ expect(result).to.equal(expected);
+ });
+
+ it('returns undefined if maxAge is not a number', () => {
+ // Given
+ const maxAge = 'some string';
+ const expected = undefined;
+ // When
+ const result = cache.get('requestId1', maxAge);
+ // Then
+ expect(result).to.equal(expected);
+ });
+
+ it('returns undefined if maxAge is not finite', () => {
+ // Given
+ const maxAge = 1 / 0;
+ const expected = undefined;
+ // When
+ const result = cache.get('requestId1', maxAge);
+ // Then
+ expect(result).to.equal(expected);
+ });
+
+ it('returns undefined if maxAge is negative', () => {
+ // Given
+ const maxAge = -10;
+ const expected = undefined;
+ // When
+ const result = cache.get('requestId1', maxAge);
+ // Then
+ expect(result).to.equal(expected);
+ });
+
+ it('returns undefined if cached request age is not less than maxAge', () => {
+ // Given
+ const maxAge = A_MINUTE_IN_MS;
+ const expected = undefined;
+ // When
+ const result = cache.get('requestId1', maxAge);
+ // Then
+ expect(result).to.equal(expected);
+ });
+
+ it('gets the cached request by requestId if cached request age is less than maxAge', () => {
+ // Given
+ const maxAge = TEN_MINUTES_IN_MS;
+ const expected = cache._cachedRequests?.requestId1?.response;
+ // When
+ const result = cache.get('requestId1', maxAge);
+ // Then
+ expect(result).to.deep.equal(expected);
+ });
+ });
+
+ describe('cache.set', () => {
+ it('stores the `response` for the given `requestId`', () => {
+ // Given
+ const cache = new Cache();
+ const maxAge = TEN_MINUTES_IN_MS;
+ const response1 = 'response of request1';
+ const response2 = 'response of request2';
+ // When
+ cache.set('requestId1', response1);
+ cache.set('requestId2', response2);
+ // Then
+ expect(cache.get('requestId1', maxAge)).to.equal(response1);
+ expect(cache.get('requestId2', maxAge)).to.equal(response2);
+ });
+
+ it('updates the `response` for the given `requestId`, if already cached', () => {
+ // Given
+ const cache = new Cache();
+ const maxAge = TEN_MINUTES_IN_MS;
+ const response = 'response of request1';
+ const updatedResponse = 'updated response of request1';
+ // When
+ cache.set('requestId1', response);
+ // Then
+ expect(cache.get('requestId1', maxAge)).to.equal(response);
+ // When
+ cache.set('requestId1', updatedResponse);
+ // Then
+ expect(cache.get('requestId1', maxAge)).to.equal(updatedResponse);
+ });
+ });
+
+ describe('cache.delete', () => {
+ it('deletes cache by `requestId`', () => {
+ // Given
+ const cache = new Cache();
+ const maxAge = TEN_MINUTES_IN_MS;
+ const response1 = 'response of request1';
+ const response2 = 'response of request2';
+ // When
+ cache.set('requestId1', response1);
+ cache.set('requestId2', response2);
+ // Then
+ expect(cache.get('requestId1', maxAge)).to.equal(response1);
+ expect(cache.get('requestId2', maxAge)).to.equal(response2);
+ // When
+ cache.delete('requestId1');
+ // Then
+ expect(cache.get('requestId1', maxAge)).to.be.undefined;
+ expect(cache.get('requestId2', maxAge)).to.equal(response2);
+ });
+
+ it('deletes cache by regex', () => {
+ // Given
+ const cache = new Cache();
+ const maxAge = TEN_MINUTES_IN_MS;
+ const response1 = 'response of request1';
+ const response2 = 'response of request2';
+ const response3 = 'response of request3';
+ // When
+ cache.set('requestId1', response1);
+ cache.set('requestId2', response2);
+ cache.set('anotherRequestId', response3);
+ // Then
+ expect(cache.get('requestId1', maxAge)).to.equal(response1);
+ expect(cache.get('requestId2', maxAge)).to.equal(response2);
+ expect(cache.get('anotherRequestId', maxAge)).to.equal(response3);
+ // When
+ cache.deleteMatching(/^requestId/);
+ // Then
+ expect(cache.get('requestId1', maxAge)).to.be.undefined;
+ expect(cache.get('requestId2', maxAge)).to.be.undefined;
+ expect(cache.get('anotherRequestId', maxAge)).to.equal(response3);
+ });
+ });
+
+ describe('cache.reset', () => {
+ it('resets the cache', () => {
+ // Given
+ const cache = new Cache();
+ const maxAge = TEN_MINUTES_IN_MS;
+ const response1 = 'response of request1';
+ const response2 = 'response of request2';
+ // When
+ cache.set('requestId1', response1);
+ cache.set('requestId2', response2);
+ // Then
+ expect(cache.get('requestId1', maxAge)).to.equal(response1);
+ expect(cache.get('requestId2', maxAge)).to.equal(response2);
+ // When
+ cache.reset();
+ // Then
+ expect(cache.get('requestId1', maxAge)).to.be.undefined;
+ expect(cache.get('requestId2', maxAge)).to.be.undefined;
+ });
+ });
+});
diff --git a/packages/ajax/test/PendingRequestStore.test.js b/packages/ajax/test/PendingRequestStore.test.js
new file mode 100644
index 000000000..19d906b71
--- /dev/null
+++ b/packages/ajax/test/PendingRequestStore.test.js
@@ -0,0 +1,159 @@
+// @ts-nocheck
+import { expect } from '@open-wc/testing';
+import PendingRequestStore from '../src/PendingRequestStore.js';
+
+describe('PendingRequestStore', () => {
+ let pendingRequestStore;
+
+ beforeEach(() => {
+ pendingRequestStore = new PendingRequestStore();
+ });
+
+ describe('public interface', () => {
+ it('PendingRequestStore has `set` method', () => {
+ expect(pendingRequestStore.set).to.exist;
+ });
+
+ it('PendingRequestStore has `get` method', () => {
+ expect(pendingRequestStore.get).to.exist;
+ });
+
+ it('PendingRequestStore has `resolve` method', () => {
+ expect(pendingRequestStore.resolve).to.exist;
+ });
+
+ it('PendingRequestStore has `reset` method', () => {
+ expect(pendingRequestStore.reset).to.exist;
+ });
+ });
+
+ describe('getting and setting', () => {
+ it('will return undefined for an unknown key', () => {
+ expect(pendingRequestStore.get('unknown-key')).to.be.undefined;
+ });
+
+ it('will return a promise for a key that has been added earlier', () => {
+ // Given
+ pendingRequestStore.set('a-key');
+
+ // Then
+ expect(pendingRequestStore.get('a-key')).to.be.a('Promise');
+ });
+
+ it('will not replace an already known entry', () => {
+ // Given
+ pendingRequestStore.set('the-original-key');
+ const theOriginalPromise = pendingRequestStore.get('the-original-key');
+
+ // When
+ pendingRequestStore.set('the-original-key');
+
+ // Then
+ expect(pendingRequestStore.get('the-original-key')).to.equal(theOriginalPromise);
+ });
+
+ it('will return the same promise when retrieved twice', () => {
+ // Given
+ pendingRequestStore.set('a-key');
+
+ // When
+ const a1 = pendingRequestStore.get('a-key');
+ const a2 = pendingRequestStore.get('a-key');
+
+ // Then
+ expect(a1).to.equal(a2);
+ });
+
+ it('will return undefined when the store is reset', () => {
+ // Given
+ pendingRequestStore.set('a-key');
+
+ // When
+ pendingRequestStore.reset();
+
+ // Then
+ expect(pendingRequestStore.get('a-key')).to.be.undefined;
+ });
+ });
+
+ describe('resolving', () => {
+ it('will resolve a named promise and delete it', async () => {
+ // Given
+ pendingRequestStore.set('do-groceries');
+ const backFromTheStore = pendingRequestStore
+ .get('do-groceries')
+ .catch(() => expect.fail('Promise was rejected before it could be resolved'));
+
+ // When
+ pendingRequestStore.resolve('do-groceries');
+ await backFromTheStore;
+
+ // Then
+ expect(pendingRequestStore.get('do-groceries')).to.be.undefined;
+ });
+
+ it('will use the same promise when retrieving or resolving the same key', async () => {
+ // Given
+ const fridge = [];
+
+ pendingRequestStore.set('do-groceries');
+ pendingRequestStore.get('do-groceries').then(() => fridge.push('milk'));
+ pendingRequestStore.get('do-groceries').then(() => fridge.push('eggs'));
+
+ const backFromTheStore = pendingRequestStore.get('do-groceries');
+
+ // When
+ pendingRequestStore.resolve('do-groceries');
+ await backFromTheStore;
+
+ // Then
+ expect(fridge).to.contain('milk');
+ expect(fridge).to.contain('eggs');
+
+ expect(pendingRequestStore.get('do-groceries')).to.be.undefined;
+ });
+ });
+
+ describe('resolving multiple requestIds by regular expression', () => {
+ it('will resolve multiple promises matching a regular expression and delete them', async () => {
+ // Given
+ let canIPlayNow = false;
+
+ pendingRequestStore.set('do-dishes');
+ pendingRequestStore.set('do-groceries');
+
+ const choresAllDone = Promise.all([
+ pendingRequestStore.get('do-dishes'),
+ pendingRequestStore.get('do-groceries'),
+ ]);
+
+ // When
+ pendingRequestStore.resolveMatching(/^do-/);
+ await choresAllDone.then(() => {
+ canIPlayNow = true;
+ });
+
+ // Then
+ expect(canIPlayNow).to.be.ok;
+
+ expect(pendingRequestStore.get('do-groceries')).to.be.undefined;
+ expect(pendingRequestStore.get('do-dishes')).to.be.undefined;
+ });
+ });
+
+ it('will leave unmatched requests alone when resolving', () => {
+ // Given
+ pendingRequestStore.set('do-dishes');
+ pendingRequestStore.set('do-groceries');
+ pendingRequestStore.set('ponder-meaning-of-life');
+
+ // When
+ pendingRequestStore.resolveMatching(/^do-/);
+
+ // Then
+ expect(pendingRequestStore.get('do-groceries')).to.be.undefined;
+ expect(pendingRequestStore.get('do-dishes')).to.be.undefined;
+
+ expect(pendingRequestStore.get('ponder-meaning-of-life')).not.to.be.undefined;
+ });
+});
diff --git a/packages/ajax/test/cacheManager.test.js b/packages/ajax/test/cacheManager.test.js
new file mode 100644
index 000000000..904119849
--- /dev/null
+++ b/packages/ajax/test/cacheManager.test.js
@@ -0,0 +1,313 @@
+// @ts-nocheck
+import { expect } from '@open-wc/testing';
+import * as sinon from 'sinon';
+import {
+ ajaxCache,
+ pendingRequestStore,
+ resetCacheSession,
+ extendCacheOptions,
+ validateCacheOptions,
+ invalidateMatchingCache,
+ isCurrentSessionId,
+} from '../src/cacheManager.js';
+import Cache from '../src/Cache.js';
+import PendingRequestStore from '../src/PendingRequestStore.js';
+
+describe('cacheManager', () => {
+ describe('ajaxCache', () => {
+ it('is an instance of the Cache class', () => {
+ expect(ajaxCache).to.be.instanceOf(Cache);
+ });
+ });
+
+ describe('pendingRequestStore', () => {
+ it('is an instance of the PendingRequestStore class', () => {
+ expect(pendingRequestStore).to.be.instanceOf(PendingRequestStore);
+ });
+ });
+
+ describe('resetCacheSession', () => {
+ let ajaxCacheSpy;
+ let pendingRequestStoreSpy;
+
+ beforeEach(() => {
+ ajaxCacheSpy = sinon.spy(ajaxCache, 'reset');
+ pendingRequestStoreSpy = sinon.spy(pendingRequestStore, 'reset');
+ });
+
+ afterEach(() => {
+ ajaxCacheSpy.restore();
+ pendingRequestStoreSpy.restore();
+ });
+
+ it('throws an Error when no cacheId is passed', () => {
+ try {
+ resetCacheSession();
+ } catch (e) {
+ expect(e).to.be.an.instanceOf(Error);
+ }
+ });
+
+ it('assigns the passed cacheId to the cacheSessionId', () => {
+ // Arrange
+ const cacheId = 'a-new-cache-id';
+ // Act
+ resetCacheSession(cacheId);
+ // Assert
+ expect(ajaxCacheSpy.calledOnce).to.be.true;
+ expect(pendingRequestStoreSpy.calledOnce).to.be.true;
+ });
+ });
+
+ describe('extendCacheOptions', () => {
+ // Arrange
+ const DEFAULT_MAX_AGE = 1000 * 60 * 60;
+ const invalidateUrls = ['https://f00.bar/', 'https://share.ware/'];
+ const invalidateUrlsRegex = /f00/;
+
+ it('returns object with default values', () => {
+ // Act
+ const {
+ useCache,
+ methods,
+ maxAge,
+ requestIdFunction,
+ invalidateUrls: invalidateUrlsResult,
+ invalidateUrlsRegex: invalidateUrlsRegexResult,
+ } = extendCacheOptions({ invalidateUrls, invalidateUrlsRegex });
+ // Assert
+ expect(useCache).to.be.false;
+ expect(methods).to.eql(['get']);
+ expect(maxAge).to.equal(DEFAULT_MAX_AGE);
+ expect(typeof requestIdFunction).to.eql('function');
+ expect(invalidateUrlsResult).to.equal(invalidateUrls);
+ expect(invalidateUrlsRegexResult).to.equal(invalidateUrlsRegex);
+ });
+
+ it('the DEFAULT_GET_REQUEST_ID function throws when called with no arguments', () => {
+ // Arrange
+ const { requestIdFunction } = extendCacheOptions({
+ invalidateUrls,
+ invalidateUrlsRegex,
+ });
+ // Act
+ expect(requestIdFunction).to.throw(TypeError);
+ });
+
+ it('the DEFAULT_GET_REQUEST_ID function returns a url when URLSearchParams cannot be serialized', () => {
+ // Arrange
+ const { requestIdFunction } = extendCacheOptions({
+ invalidateUrls,
+ invalidateUrlsRegex,
+ });
+ // Act
+ const formattedUrl = requestIdFunction({
+ url: 'http://f00.bar/',
+ params: {},
+ });
+ // Assert
+ expect(formattedUrl).to.equal('http://f00.bar/');
+ });
+
+ it('the DEFAULT_GET_REQUEST_ID function returns a correctly formatted url with URLSearchParams', () => {
+ // Arrange
+ const { requestIdFunction } = extendCacheOptions({
+ invalidateUrls,
+ invalidateUrlsRegex,
+ });
+ // Act
+ const formattedUrl = requestIdFunction({
+ url: 'http://f00.bar/',
+ params: { f00: 'bar', bar: 'f00' },
+ });
+ // Assert
+ expect(formattedUrl).to.equal('http://f00.bar/?f00=bar&bar=f00');
+ });
+ });
+
+ describe('validateCacheOptions', () => {
+ it('does not accept null as argument', () => {
+ expect(() => validateCacheOptions(null)).to.throw(TypeError);
+ });
+ it('accepts an empty object', () => {
+ expect(() => validateCacheOptions({})).not.to.throw(
+ 'Property `useCache` must be a `boolean`',
+ );
+ });
+ describe('the useCache property', () => {
+ it('accepts a boolean', () => {
+ expect(() => validateCacheOptions({ useCache: false })).not.to.throw;
+ });
+ it('accepts undefined', () => {
+ expect(() => validateCacheOptions({ useCache: undefined })).not.to.throw;
+ });
+ it('does not accept anything else', () => {
+ // @ts-ignore
+ expect(() => validateCacheOptions({ useCache: '' })).to.throw(
+ 'Property `useCache` must be a `boolean`',
+ );
+ });
+ });
+ describe('the methods property', () => {
+ it('accepts an array with the value `get`', () => {
+ expect(() => validateCacheOptions({ methods: ['get'] })).not.to.throw;
+ });
+ it('accepts undefined', () => {
+ expect(() => validateCacheOptions({ methods: undefined })).not.to.throw;
+ });
+ it('does not accept anything else', () => {
+ expect(() => validateCacheOptions({ methods: [] })).to.throw(
+ 'Cache can only be utilized with `GET` method',
+ );
+ expect(() => validateCacheOptions({ methods: ['post'] })).to.throw(
+ 'Cache can only be utilized with `GET` method',
+ );
+ expect(() => validateCacheOptions({ methods: ['get', 'post'] })).to.throw(
+ 'Cache can only be utilized with `GET` method',
+ );
+ });
+ });
+ describe('the maxAge property', () => {
+ it('accepts a finite number', () => {
+ expect(() => validateCacheOptions({ maxAge: 42 })).not.to.throw;
+ });
+ it('accepts undefined', () => {
+ expect(() => validateCacheOptions({ maxAge: undefined })).not.to.throw;
+ });
+ it('does not accept anything else', () => {
+ // @ts-ignore
+ expect(() => validateCacheOptions({ maxAge: 'string' })).to.throw(
+ 'Property `maxAge` must be a finite `number`',
+ );
+ expect(() => validateCacheOptions({ maxAge: Infinity })).to.throw(
+ 'Property `maxAge` must be a finite `number`',
+ );
+ });
+ });
+ describe('the invalidateUrls property', () => {
+ it('accepts an array', () => {
+ // @ts-ignore Typescript requires this to be an array of string, but this is not checked by validateCacheOptions
+ expect(() =>
+ validateCacheOptions({ invalidateUrls: [6, 'elements', 'in', 1, true, Array] }),
+ ).not.to.throw;
+ });
+ it('accepts undefined', () => {
+ expect(() => validateCacheOptions({ invalidateUrls: undefined })).not.to.throw;
+ });
+ it('does not accept anything else', () => {
+ // @ts-ignore
+ expect(() => validateCacheOptions({ invalidateUrls: 'not-an-array' })).to.throw(
+ 'Property `invalidateUrls` must be an `Array` or `falsy`',
+ );
+ });
+ });
+ describe('the invalidateUrlsRegex property', () => {
+ it('accepts a regular expression', () => {
+ expect(() => validateCacheOptions({ invalidateUrlsRegex: /this is a very picky regex/ }))
+ .not.to.throw;
+ });
+ it('accepts undefined', () => {
+ expect(() => validateCacheOptions({ invalidateUrlsRegex: undefined })).not.to.throw;
+ });
+ it('does not accept anything else', () => {
+ // @ts-ignore
+ expect(() =>
+ validateCacheOptions({ invalidateUrlsRegex: 'a string is not a regex' }),
+ ).to.throw('Property `invalidateUrlsRegex` must be a `RegExp` or `falsy`');
+ });
+ });
+ describe('the requestIdFunction property', () => {
+ it('accepts a function', () => {
+ // @ts-ignore Typescript requires the requestIdFunction to return a string, but this is not checked by validateCacheOptions
+ expect(() =>
+ validateCacheOptions({ requestIdFunction: () => ['this-is-ok-outside-typescript'] }),
+ ).not.to.throw;
+ });
+ it('accepts undefined', () => {
+ expect(() => validateCacheOptions({ requestIdFunction: undefined })).not.to.throw;
+ });
+ it('does not accept anything else', () => {
+ // @ts-ignore
+ expect(() => validateCacheOptions({ requestIdFunction: 'not a function' })).to.throw(
+ 'Property `requestIdFunction` must be a `function`',
+ );
+ });
+ });
+ });
+
+ describe('invalidateMatchingCache', () => {
+ beforeEach(() => {
+ sinon.spy(ajaxCache, 'delete');
+ sinon.spy(ajaxCache, 'deleteMatching');
+ sinon.spy(pendingRequestStore, 'resolve');
+ sinon.spy(pendingRequestStore, 'resolveMatching');
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('calls delete on the ajaxCache and calls resolve on the pendingRequestStore', () => {
+ // Arrange
+ const requestId = 'request-id';
+ // Act
+ invalidateMatchingCache(requestId, {});
+ // Assert
+ expect(ajaxCache.delete).to.have.been.calledOnce;
+ expect(pendingRequestStore.resolve.calledOnce).to.be.true;
+
+ expect(ajaxCache.delete.calledWith(requestId)).to.be.true;
+ expect(pendingRequestStore.resolve.calledWith(requestId)).to.be.true;
+ });
+
+ it('calls invalidateMatching for all URL items in the invalidateUrls argument', () => {
+ // Arrange
+ const requestId = 'request-id';
+ const invalidateUrls = ['https://f00.bar/'];
+ // Act
+ invalidateMatchingCache(requestId, { invalidateUrls });
+ // Assert
+ expect(ajaxCache.delete.calledTwice).to.be.true;
+ expect(pendingRequestStore.resolve.calledTwice).to.be.true;
+
+ expect(ajaxCache.delete.calledWith(requestId)).to.be.true;
+ expect(pendingRequestStore.resolve.calledWith(requestId)).to.be.true;
+
+ expect(ajaxCache.delete.calledWith('https://f00.bar/')).to.be.true;
+ expect(pendingRequestStore.resolve.calledWith('https://f00.bar/')).to.be.true;
+ });
+
+ it('calls invalidateMatching when the invalidateUrlsRegex argument is passed', () => {
+ // Arrange
+ const requestId = 'request-id';
+ const invalidateUrlsRegex = 'f00';
+ // Act
+ invalidateMatchingCache(requestId, { invalidateUrlsRegex });
+ // Assert
+ expect(ajaxCache.delete.calledOnce).to.be.true;
+ expect(ajaxCache.deleteMatching.calledOnce).to.be.true;
+ expect(pendingRequestStore.resolve.calledOnce).to.be.true;
+ expect(pendingRequestStore.resolveMatching.calledOnce).to.be.true;
+
+ expect(ajaxCache.delete.calledWith(requestId)).to.be.true;
+ expect(pendingRequestStore.resolve.calledWith(requestId)).to.be.true;
+
+ expect(ajaxCache.deleteMatching.calledWith('f00')).to.be.true;
+ expect(pendingRequestStore.resolveMatching.calledWith('f00')).to.be.true;
+ });
+ });
+
+ describe('isCurrentSessionId', () => {
+ it('returns true for the current session id', () => {
+ resetCacheSession('the-id');
+
+ expect(isCurrentSessionId('the-id')).to.equal(true);
+ });
+
+ it('returns true for the current session id', () => {
+ resetCacheSession('the-id');
+
+ expect(isCurrentSessionId('a-different-id')).to.equal(false);
+ });
+ });
+});
diff --git a/packages/ajax/test/interceptors.test.js b/packages/ajax/test/interceptors.test.js
deleted file mode 100644
index 778a34fd3..000000000
--- a/packages/ajax/test/interceptors.test.js
+++ /dev/null
@@ -1,586 +0,0 @@
-import { aTimeout, expect } from '@open-wc/testing';
-import { spy, stub, useFakeTimers } from 'sinon';
-import '../src/typedef.js';
-import { acceptLanguageRequestInterceptor } from '../src/interceptors/acceptLanguageHeader.js';
-import { createXsrfRequestInterceptor, getCookie } from '../src/interceptors/xsrfHeader.js';
-import { createCacheInterceptors } from '../src/interceptors/cacheInterceptors.js';
-import { Ajax } from '../index.js';
-
-const ajax = new Ajax();
-
-describe('interceptors', () => {
- describe('getCookie()', () => {
- it('returns the cookie value', () => {
- expect(getCookie('foo', { cookie: 'foo=bar' })).to.equal('bar');
- });
-
- it('returns the cookie value when there are multiple cookies', () => {
- expect(getCookie('foo', { cookie: 'foo=bar; bar=foo;lorem=ipsum' })).to.equal('bar');
- });
-
- it('returns null when the cookie cannot be found', () => {
- expect(getCookie('foo', { cookie: 'bar=foo;lorem=ipsum' })).to.equal(null);
- });
-
- it('decodes the cookie vaue', () => {
- expect(getCookie('foo', { cookie: `foo=${decodeURIComponent('/foo/ bar "')}` })).to.equal(
- '/foo/ bar "',
- );
- });
- });
-
- describe('acceptLanguageRequestInterceptor()', () => {
- it('adds the locale as accept-language header', () => {
- const request = new Request('/foo/');
- acceptLanguageRequestInterceptor(request);
- expect(request.headers.get('accept-language')).to.equal('en');
- });
-
- it('does not change an existing accept-language header', () => {
- const request = new Request('/foo/', { headers: { 'accept-language': 'my-accept' } });
- acceptLanguageRequestInterceptor(request);
- expect(request.headers.get('accept-language')).to.equal('my-accept');
- });
- });
-
- describe('createXsrfRequestInterceptor()', () => {
- it('adds the xsrf token header to the request', () => {
- const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
- cookie: 'XSRF-TOKEN=foo',
- });
- const request = new Request('/foo/');
- interceptor(request);
- expect(request.headers.get('X-XSRF-TOKEN')).to.equal('foo');
- });
-
- it('does not set anything if the cookie is not there', () => {
- const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
- cookie: 'XXSRF-TOKEN=foo',
- });
- const request = new Request('/foo/');
- interceptor(request);
- expect(request.headers.get('X-XSRF-TOKEN')).to.equal(null);
- });
- });
-
- describe('cache interceptors', () => {
- /** @type {number | undefined} */
- let cacheId;
- /** @type {import('sinon').SinonStub} */
- let fetchStub;
- /** @type {() => string} */
- let getCacheIdentifier;
-
- const newCacheId = () => {
- if (!cacheId) {
- cacheId = 1;
- } else {
- cacheId += 1;
- }
- return cacheId;
- };
-
- /**
- * @param {ajax} ajaxInstance
- * @param {CacheOptions} options
- */
- const addCacheInterceptors = (ajaxInstance, options) => {
- const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
- getCacheIdentifier,
- options,
- );
-
- const requestInterceptorIndex =
- ajaxInstance._requestInterceptors.push(
- /** @type {RequestInterceptor} */ (cacheRequestInterceptor),
- ) - 1;
-
- const responseInterceptorIndex =
- ajaxInstance._responseInterceptors.push(
- /** @type {ResponseInterceptor} */ (cacheResponseInterceptor),
- ) - 1;
-
- return {
- requestInterceptorIndex,
- responseInterceptorIndex,
- };
- };
-
- /**
- * @param {ajax} ajaxInstance
- * @param {{requestInterceptorIndex: number, responseInterceptorIndex: number}} indexes
- */
- const removeCacheInterceptors = (
- ajaxInstance,
- { requestInterceptorIndex, responseInterceptorIndex },
- ) => {
- ajaxInstance._requestInterceptors.splice(requestInterceptorIndex, 1);
- ajaxInstance._responseInterceptors.splice(responseInterceptorIndex, 1);
- };
-
- beforeEach(() => {
- getCacheIdentifier = () => String(cacheId);
- fetchStub = stub(window, 'fetch');
- fetchStub.returns(Promise.resolve(new Response('mock response')));
- });
-
- afterEach(() => {
- fetchStub.restore();
- });
-
- describe('Original ajax instance', () => {
- it('allows direct ajax calls without cache interceptors configured', async () => {
- await ajax.fetch('/test');
- expect(fetchStub.callCount).to.equal(1);
- await ajax.fetch('/test');
- expect(fetchStub.callCount).to.equal(2);
- });
- });
-
- describe('Cache config validation', () => {
- it('validates `useCache`', () => {
- newCacheId();
- const test = () => {
- const indexes = addCacheInterceptors(ajax, {
- // @ts-ignore needed for test
- useCache: 'fakeUseCacheType',
- });
- removeCacheInterceptors(ajax, indexes);
- };
- expect(test).to.throw();
- });
-
- it('validates property `timeToLive` throws if not type `number`', () => {
- newCacheId();
- expect(() => {
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- // @ts-ignore needed for test
- timeToLive: '',
- });
- removeCacheInterceptors(ajax, indexes);
- }).to.throw();
- });
-
- it('validates cache identifier function', () => {
- // @ts-ignore needed for test
- cacheId = '';
-
- const indexes = addCacheInterceptors(ajax, { useCache: true });
-
- return ajax.fetch('/test').catch(
- /** @param {Error} err */ err => {
- expect(err.message).to.equal('getCacheIdentifier returns falsy');
-
- removeCacheInterceptors(ajax, indexes);
- },
- );
- });
-
- it("throws when using methods other than `['get']`", () => {
- newCacheId();
-
- expect(() => {
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- methods: ['get', 'post'],
- });
- removeCacheInterceptors(ajax, indexes);
- }).to.throw(/not yet supported/);
- });
-
- it('throws error when requestIdentificationFn is not a function', () => {
- newCacheId();
-
- expect(() => {
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- // @ts-ignore needed for test
- requestIdentificationFn: 'not a function',
- });
- removeCacheInterceptors(ajax, indexes);
- }).to.throw(/Property `requestIdentificationFn` must be of type `function`/);
- });
- });
-
- describe('Cached responses', () => {
- it('returns the cached object on second call with `useCache: true`', async () => {
- newCacheId();
-
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- timeToLive: 100,
- });
- const ajaxRequestSpy = spy(ajax, 'fetch');
-
- await ajax.fetch('/test');
- expect(ajaxRequestSpy.calledOnce).to.be.true;
- expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
- await ajax.fetch('/test');
- expect(fetchStub.callCount).to.equal(1);
-
- ajaxRequestSpy.restore();
- removeCacheInterceptors(ajax, indexes);
- });
-
- it('all calls with non-default `timeToLive` are cached proactively', async () => {
- newCacheId();
-
- const indexes = addCacheInterceptors(ajax, {
- useCache: false,
- timeToLive: 100,
- });
- const ajaxRequestSpy = spy(ajax, 'fetch');
-
- await ajax.fetch('/test');
- expect(ajaxRequestSpy.calledOnce).to.be.true;
- expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
- expect(fetchStub.callCount).to.equal(1);
- await ajax.fetch('/test');
- expect(fetchStub.callCount).to.equal(2);
- await ajax.fetch('/test', {
- cacheOptions: {
- useCache: true,
- },
- });
- expect(fetchStub.callCount).to.equal(2);
- ajaxRequestSpy.restore();
- removeCacheInterceptors(ajax, indexes);
- });
-
- it('returns the cached object on second call with `useCache: true`, with querystring parameters', async () => {
- newCacheId();
-
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- timeToLive: 100,
- });
-
- const ajaxRequestSpy = spy(ajax, 'fetch');
-
- await ajax.fetch('/test', {
- params: {
- q: 'test',
- page: 1,
- },
- });
- expect(ajaxRequestSpy.calledOnce).to.be.true;
- expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
- await ajax.fetch('/test', {
- params: {
- q: 'test',
- page: 1,
- },
- });
- expect(fetchStub.callCount).to.equal(1);
- // a request with different param should not be cached
- await ajax.fetch('/test', {
- params: {
- q: 'test',
- page: 2,
- },
- });
- expect(fetchStub.callCount).to.equal(2);
- ajaxRequestSpy.restore();
- removeCacheInterceptors(ajax, indexes);
- });
-
- it('uses cache when inside `timeToLive: 5000` window', async () => {
- newCacheId();
- const clock = useFakeTimers({
- shouldAdvanceTime: true,
- });
-
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- timeToLive: 5000,
- });
- const ajaxRequestSpy = spy(ajax, 'fetch');
-
- await ajax.fetch('/test');
- expect(ajaxRequestSpy.calledOnce).to.be.true;
- expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
- expect(fetchStub.callCount).to.equal(1);
- clock.tick(4900);
- await ajax.fetch('/test');
- expect(fetchStub.callCount).to.equal(1);
- clock.tick(5100);
- await ajax.fetch('/test');
- expect(fetchStub.callCount).to.equal(2);
- ajaxRequestSpy.restore();
- clock.restore();
- removeCacheInterceptors(ajax, indexes);
- });
-
- it('uses custom requestIdentificationFn when passed', async () => {
- newCacheId();
-
- const customRequestIdFn = /** @type {RequestIdentificationFn} */ (request, serializer) => {
- let serializedRequestParams = '';
- if (request.params) {
- serializedRequestParams = `?${serializer(request.params)}`;
- }
- return `${new URL(/** @type {string} */ (request.url)).pathname}-${request.headers?.get(
- 'x-id',
- )}${serializedRequestParams}`;
- };
- const reqIdSpy = spy(customRequestIdFn);
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- requestIdentificationFn: reqIdSpy,
- });
-
- await ajax.fetch('/test', { headers: { 'x-id': '1' } });
- expect(reqIdSpy.calledOnce);
- expect(reqIdSpy.returnValues[0]).to.equal(`/test-1`);
- removeCacheInterceptors(ajax, indexes);
- });
- });
-
- describe('Cache invalidation', () => {
- it('previously cached data has to be invalidated when regex invalidation rule triggered', async () => {
- newCacheId();
-
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- timeToLive: 1000,
- invalidateUrlsRegex: /foo/gi,
- });
-
- await ajax.fetch('/test'); // new url
- expect(fetchStub.callCount).to.equal(1);
- await ajax.fetch('/test'); // cached
- expect(fetchStub.callCount).to.equal(1);
-
- await ajax.fetch('/foo-request-1'); // new url
- expect(fetchStub.callCount).to.equal(2);
- await ajax.fetch('/foo-request-1'); // cached
- expect(fetchStub.callCount).to.equal(2);
-
- await ajax.fetch('/foo-request-3'); // new url
- expect(fetchStub.callCount).to.equal(3);
-
- await ajax.fetch('/test', { method: 'POST' }); // clear cache
- expect(fetchStub.callCount).to.equal(4);
- await ajax.fetch('/foo-request-1'); // not cached anymore
- expect(fetchStub.callCount).to.equal(5);
- await ajax.fetch('/foo-request-2'); // not cached anymore
- expect(fetchStub.callCount).to.equal(6);
-
- removeCacheInterceptors(ajax, indexes);
- });
-
- it('previously cached data has to be invalidated when regex invalidation rule triggered and urls are nested', async () => {
- newCacheId();
-
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- timeToLive: 1000,
- invalidateUrlsRegex: /posts/gi,
- });
-
- await ajax.fetch('/test');
- await ajax.fetch('/test'); // cached
- expect(fetchStub.callCount).to.equal(1);
- await ajax.fetch('/posts');
- expect(fetchStub.callCount).to.equal(2);
- await ajax.fetch('/posts'); // cached
- expect(fetchStub.callCount).to.equal(2);
- await ajax.fetch('/posts/1');
- expect(fetchStub.callCount).to.equal(3);
- await ajax.fetch('/posts/1'); // cached
- expect(fetchStub.callCount).to.equal(3);
- // cleans cache for defined urls
- await ajax.fetch('/test', { method: 'POST' });
- expect(fetchStub.callCount).to.equal(4);
- await ajax.fetch('/posts'); // no longer cached => new request
- expect(fetchStub.callCount).to.equal(5);
- await ajax.fetch('/posts/1'); // no longer cached => new request
- expect(fetchStub.callCount).to.equal(6);
-
- removeCacheInterceptors(ajax, indexes);
- });
-
- it('deletes cache after one hour', async () => {
- newCacheId();
- const clock = useFakeTimers({
- shouldAdvanceTime: true,
- });
-
- const ajaxRequestSpy = spy(ajax, 'fetch');
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- timeToLive: 1000 * 60 * 60,
- });
-
- await ajax.fetch('/test-hour');
- expect(ajaxRequestSpy.calledOnce).to.be.true;
- expect(ajaxRequestSpy.calledWith('/test-hour')).to.be.true;
- expect(fetchStub.callCount).to.equal(1);
- clock.tick(1000 * 60 * 59); // 0:59 hour
- await ajax.fetch('/test-hour');
- expect(fetchStub.callCount).to.equal(1);
- clock.tick(1000 * 60 * 2); // +2 minutes => 1:01 hour
- await ajax.fetch('/test-hour');
- expect(fetchStub.callCount).to.equal(2);
- ajaxRequestSpy.restore();
- clock.restore();
- removeCacheInterceptors(ajax, indexes);
- });
-
- it('invalidates invalidateUrls endpoints', async () => {
- newCacheId();
-
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- timeToLive: 500,
- });
-
- const actionConfig = {
- cacheOptions: {
- invalidateUrls: ['/test-invalid-url'],
- },
- };
-
- await ajax.fetch('/test-valid-url', { ...actionConfig });
- expect(fetchStub.callCount).to.equal(1);
- await ajax.fetch('/test-invalid-url');
- expect(fetchStub.callCount).to.equal(2);
- // 'post' will invalidate 'own' cache and the one mentioned in config
- await ajax.fetch('/test-valid-url', { ...actionConfig, method: 'POST' });
- expect(fetchStub.callCount).to.equal(3);
- await ajax.fetch('/test-invalid-url');
- // indicates that 'test-invalid-url' cache was removed
- // because the server registered new request
- expect(fetchStub.callCount).to.equal(4);
- removeCacheInterceptors(ajax, indexes);
- });
-
- it('invalidates cache on a post', async () => {
- newCacheId();
-
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- timeToLive: 100,
- });
- const ajaxRequestSpy = spy(ajax, 'fetch');
-
- await ajax.fetch('/test-post');
- expect(ajaxRequestSpy.calledOnce).to.be.true;
- expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true;
- expect(fetchStub.callCount).to.equal(1);
- await ajax.fetch('/test-post', { method: 'POST', body: 'data-post' });
- expect(ajaxRequestSpy.calledTwice).to.be.true;
- expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true;
- expect(fetchStub.callCount).to.equal(2);
- await ajax.fetch('/test-post');
- expect(fetchStub.callCount).to.equal(3);
- ajaxRequestSpy.restore();
- removeCacheInterceptors(ajax, indexes);
- });
-
- it('caches response but does not return it when expiration time is 0', async () => {
- newCacheId();
-
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- timeToLive: 0,
- });
-
- const ajaxRequestSpy = spy(ajax, 'fetch');
-
- await ajax.fetch('/test');
- const clock = useFakeTimers();
- expect(ajaxRequestSpy.calledOnce).to.be.true;
- expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
- clock.tick(1);
- clock.restore();
- await ajax.fetch('/test');
- expect(fetchStub.callCount).to.equal(2);
- ajaxRequestSpy.restore();
- removeCacheInterceptors(ajax, indexes);
- });
-
- it('does not use cache when `useCache: false` in the action', async () => {
- newCacheId();
- getCacheIdentifier = () => 'cacheIdentifier2';
-
- const ajaxAlwaysRequestSpy = spy(ajax, 'fetch');
- const indexes = addCacheInterceptors(ajax, { useCache: true });
-
- await ajax.fetch('/test');
- expect(ajaxAlwaysRequestSpy.calledOnce, 'calledOnce').to.be.true;
- expect(ajaxAlwaysRequestSpy.calledWith('/test'));
- await ajax.fetch('/test', { cacheOptions: { useCache: false } });
- expect(fetchStub.callCount).to.equal(2);
- ajaxAlwaysRequestSpy.restore();
- removeCacheInterceptors(ajax, indexes);
- });
-
- it('caches concurrent requests', async () => {
- newCacheId();
-
- let i = 0;
- fetchStub.returns(
- new Promise(resolve => {
- i += 1;
- setTimeout(() => {
- resolve(new Response(`mock response ${i}`));
- }, 5);
- }),
- );
-
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- timeToLive: 100,
- });
- const ajaxRequestSpy = spy(ajax, 'fetch');
-
- const request1 = ajax.fetch('/test');
- const request2 = ajax.fetch('/test');
- await aTimeout(1);
- const request3 = ajax.fetch('/test');
- await aTimeout(3);
- const request4 = ajax.fetch('/test');
- const responses = await Promise.all([request1, request2, request3, request4]);
- expect(fetchStub.callCount).to.equal(1);
- const responseTexts = await Promise.all(responses.map(r => r.text()));
- expect(responseTexts).to.eql([
- 'mock response 1',
- 'mock response 1',
- 'mock response 1',
- 'mock response 1',
- ]);
-
- ajaxRequestSpy.restore();
- removeCacheInterceptors(ajax, indexes);
- });
-
- it('preserves status and headers when returning cached response', async () => {
- newCacheId();
- fetchStub.returns(
- Promise.resolve(
- new Response('mock response', { status: 206, headers: { 'x-foo': 'x-bar' } }),
- ),
- );
-
- const indexes = addCacheInterceptors(ajax, {
- useCache: true,
- timeToLive: 100,
- });
- const ajaxRequestSpy = spy(ajax, 'fetch');
-
- const response1 = await ajax.fetch('/test');
- const response2 = await ajax.fetch('/test');
- expect(fetchStub.callCount).to.equal(1);
- expect(response1.status).to.equal(206);
- expect(response1.headers.get('x-foo')).to.equal('x-bar');
- expect(response2.status).to.equal(206);
- expect(response2.headers.get('x-foo')).to.equal('x-bar');
-
- ajaxRequestSpy.restore();
- removeCacheInterceptors(ajax, indexes);
- });
- });
- });
-});
diff --git a/packages/ajax/test/interceptors/acceptLanguageHeader.test.js b/packages/ajax/test/interceptors/acceptLanguageHeader.test.js
new file mode 100644
index 000000000..a0f64de4a
--- /dev/null
+++ b/packages/ajax/test/interceptors/acceptLanguageHeader.test.js
@@ -0,0 +1,16 @@
+import { expect } from '@open-wc/testing';
+import { acceptLanguageRequestInterceptor } from '../../src/interceptors/acceptLanguageHeader.js';
+
+describe('acceptLanguageRequestInterceptor()', () => {
+ it('adds the locale as accept-language header', () => {
+ const request = new Request('/foo/');
+ acceptLanguageRequestInterceptor(request);
+ expect(request.headers.get('accept-language')).to.equal('en');
+ });
+
+ it('does not change an existing accept-language header', () => {
+ const request = new Request('/foo/', { headers: { 'accept-language': 'my-accept' } });
+ acceptLanguageRequestInterceptor(request);
+ expect(request.headers.get('accept-language')).to.equal('my-accept');
+ });
+});
diff --git a/packages/ajax/test/interceptors/cacheInterceptors.test.js b/packages/ajax/test/interceptors/cacheInterceptors.test.js
new file mode 100644
index 000000000..da9157f83
--- /dev/null
+++ b/packages/ajax/test/interceptors/cacheInterceptors.test.js
@@ -0,0 +1,595 @@
+import { expect } from '@open-wc/testing';
+import * as sinon from 'sinon';
+import '../../src/typedef.js';
+import { Ajax } from '../../index.js';
+import { extendCacheOptions, resetCacheSession, ajaxCache } from '../../src/cacheManager.js';
+import { createCacheInterceptors } from '../../src/interceptors/cacheInterceptors.js';
+
+/** @type {Ajax} */
+let ajax;
+
+describe('cache interceptors', () => {
+ /**
+ * @param {number | undefined} timeout
+ * @param {number} i
+ */
+ const returnResponseOnTick = (timeout, i) =>
+ new Promise(resolve =>
+ window.setTimeout(() => resolve(new Response(`mock response ${i}`)), timeout),
+ );
+
+ /** @type {number | undefined} */
+ let cacheId;
+ /** @type {sinon.SinonStub} */
+ let fetchStub;
+ const getCacheIdentifier = () => String(cacheId);
+ /** @type {sinon.SinonSpy} */
+ let ajaxRequestSpy;
+
+ const newCacheId = () => {
+ if (!cacheId) {
+ cacheId = 1;
+ } else {
+ cacheId += 1;
+ }
+ return cacheId;
+ };
+
+ /**
+ * @param {Ajax} ajaxInstance
+ * @param {CacheOptions} options
+ */
+ const addCacheInterceptors = (ajaxInstance, options) => {
+ const { cacheRequestInterceptor, cacheResponseInterceptor } = createCacheInterceptors(
+ getCacheIdentifier,
+ options,
+ );
+
+ ajaxInstance._requestInterceptors.push(cacheRequestInterceptor);
+ ajaxInstance._responseInterceptors.push(cacheResponseInterceptor);
+ };
+
+ beforeEach(() => {
+ ajax = new Ajax();
+ fetchStub = sinon.stub(window, 'fetch');
+ fetchStub.returns(Promise.resolve(new Response('mock response')));
+ ajaxRequestSpy = sinon.spy(ajax, 'fetch');
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ describe('Original ajax instance', () => {
+ it('allows direct ajax calls without cache interceptors configured', async () => {
+ await ajax.fetch('/test');
+ expect(fetchStub.callCount).to.equal(1);
+ await ajax.fetch('/test');
+ expect(fetchStub.callCount).to.equal(2);
+ });
+ });
+
+ describe('Cache config validation', () => {
+ it('validates `useCache`', () => {
+ newCacheId();
+ const test = () => {
+ addCacheInterceptors(ajax, {
+ // @ts-ignore needed for test
+ useCache: 'fakeUseCacheType',
+ });
+ };
+ expect(test).to.throw();
+ });
+
+ it('validates property `maxAge` throws if not type `number`', () => {
+ newCacheId();
+ expect(() => {
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ // @ts-ignore needed for test
+ maxAge: '',
+ });
+ }).to.throw();
+ });
+
+ it('validates cache identifier function', async () => {
+ const cacheSessionId = cacheId;
+ // @ts-ignore needed for test
+ cacheId = '';
+
+ addCacheInterceptors(ajax, { useCache: true });
+ await ajax
+ .fetch('/test')
+ .then(() => expect.fail('fetch should not resolve here'))
+ .catch(
+ /** @param {Error} err */ err => {
+ expect(err.message).to.equal('Invalid cache identifier');
+ },
+ )
+ .finally(() => {});
+ cacheId = cacheSessionId;
+ });
+
+ it("throws when using methods other than `['get']`", () => {
+ newCacheId();
+
+ expect(() => {
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ methods: ['get', 'post'],
+ });
+ }).to.throw(/Cache can only be utilized with `GET` method/);
+ });
+
+ it('throws error when requestIdFunction is not a function', () => {
+ newCacheId();
+
+ expect(() => {
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ // @ts-ignore needed for test
+ requestIdFunction: 'not a function',
+ });
+ }).to.throw(/Property `requestIdFunction` must be a `function`/);
+ });
+ });
+
+ describe('Cached responses', () => {
+ it('returns the cached object on second call with `useCache: true`', async () => {
+ newCacheId();
+
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ maxAge: 100,
+ });
+
+ await ajax.fetch('/test');
+ expect(ajaxRequestSpy.calledOnce).to.be.true;
+ expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
+ await ajax.fetch('/test');
+ expect(fetchStub.callCount).to.equal(1);
+ });
+
+ // TODO: Check if this is the behaviour we want
+ it('all calls with non-default `maxAge` are cached proactively', async () => {
+ // Given
+ newCacheId();
+
+ addCacheInterceptors(ajax, {
+ useCache: false,
+ maxAge: 100,
+ });
+
+ // When
+ await ajax.fetch('/test');
+
+ // Then
+ expect(ajaxRequestSpy.calledOnce).to.be.true;
+ expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
+ expect(fetchStub.callCount).to.equal(1);
+
+ // When
+ await ajax.fetch('/test', {
+ cacheOptions: {
+ useCache: true,
+ },
+ });
+
+ // Then
+ expect(fetchStub.callCount).to.equal(2);
+
+ // When
+ await ajax.fetch('/test', {
+ cacheOptions: {
+ useCache: true,
+ },
+ });
+
+ // Then
+ expect(fetchStub.callCount).to.equal(2);
+ });
+
+ it('returns the cached object on second call with `useCache: true`, with querystring parameters', async () => {
+ // Given
+ newCacheId();
+
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ maxAge: 100,
+ });
+
+ // When
+ await ajax.fetch('/test', {
+ params: {
+ q: 'test',
+ page: 1,
+ },
+ });
+
+ // Then
+ expect(ajaxRequestSpy.calledOnce).to.be.true;
+ expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
+ expect(fetchStub.callCount).to.equal(1);
+
+ // When
+ await ajax.fetch('/test', {
+ params: {
+ q: 'test',
+ page: 1,
+ },
+ });
+
+ // Then
+ expect(fetchStub.callCount).to.equal(1);
+
+ // a request with different param should not be cached
+
+ // When
+ await ajax.fetch('/test', {
+ params: {
+ q: 'test',
+ page: 2,
+ },
+ });
+
+ // Then
+ expect(fetchStub.callCount).to.equal(2);
+ });
+
+ it('uses cache when inside `maxAge: 5000` window', async () => {
+ newCacheId();
+ const clock = sinon.useFakeTimers({
+ shouldAdvanceTime: true,
+ });
+
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ maxAge: 5000,
+ });
+
+ await ajax.fetch('/test');
+ expect(ajaxRequestSpy.calledOnce).to.be.true;
+ expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
+ expect(fetchStub.callCount).to.equal(1);
+ clock.tick(4900);
+ await ajax.fetch('/test');
+ expect(fetchStub.callCount).to.equal(1);
+ clock.tick(5100);
+ await ajax.fetch('/test');
+ expect(fetchStub.callCount).to.equal(2);
+ clock.restore();
+ });
+
+ it('uses custom requestIdFunction when passed', async () => {
+ newCacheId();
+
+ const customRequestIdFn = /** @type {RequestIdFunction} */ (request, serializer) => {
+ let serializedRequestParams = '';
+ if (request.params) {
+ // @ts-ignore assume serializer is defined
+ serializedRequestParams = `?${serializer(request.params)}`;
+ }
+ return `${new URL(/** @type {string} */ (request.url)).pathname}-${request.headers?.get(
+ 'x-id',
+ )}${serializedRequestParams}`;
+ };
+ const reqIdSpy = sinon.spy(customRequestIdFn);
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ requestIdFunction: reqIdSpy,
+ });
+
+ await ajax.fetch('/test', { headers: { 'x-id': '1' } });
+ expect(reqIdSpy.calledOnce);
+ expect(reqIdSpy.returnValues[0]).to.equal(`/test-1`);
+ });
+
+ it('throws when the request object is missing from the response', async () => {
+ const { cacheResponseInterceptor } = createCacheInterceptors(() => 'cache-id', {});
+
+ // @ts-ignore not an actual valid CacheResponse object
+ await cacheResponseInterceptor({})
+ .then(() => expect.fail('cacheResponseInterceptor should not resolve here'))
+ .catch(
+ /** @param {Error} err */ err => {
+ expect(err.message).to.equal('Missing request in response');
+ },
+ );
+
+ // @ts-ignore not an actual valid CacheResponse object
+ await cacheResponseInterceptor({ request: { method: 'get' } })
+ .then(() => expect('everything').to.be.ok)
+ .catch(err =>
+ expect.fail(
+ `cacheResponseInterceptor should resolve here, but threw an error: ${err.message}`,
+ ),
+ );
+ });
+ });
+
+ describe('Cache invalidation', () => {
+ it('previously cached data has to be invalidated when regex invalidation rule triggered', async () => {
+ newCacheId();
+
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ maxAge: 1000,
+ invalidateUrlsRegex: /foo/gi,
+ });
+
+ await ajax.fetch('/test'); // new url
+ expect(fetchStub.callCount).to.equal(1);
+ await ajax.fetch('/test'); // cached
+ expect(fetchStub.callCount).to.equal(1);
+
+ await ajax.fetch('/foo-request-1'); // new url
+ expect(fetchStub.callCount).to.equal(2);
+ await ajax.fetch('/foo-request-1'); // cached
+ expect(fetchStub.callCount).to.equal(2);
+
+ await ajax.fetch('/foo-request-3'); // new url
+ expect(fetchStub.callCount).to.equal(3);
+
+ await ajax.fetch('/test', { method: 'POST' }); // clear cache
+ expect(fetchStub.callCount).to.equal(4);
+ await ajax.fetch('/foo-request-1'); // not cached anymore
+ expect(fetchStub.callCount).to.equal(5);
+ await ajax.fetch('/foo-request-2'); // not cached anymore
+ expect(fetchStub.callCount).to.equal(6);
+ });
+
+ it('previously cached data has to be invalidated when regex invalidation rule triggered and urls are nested', async () => {
+ newCacheId();
+
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ maxAge: 1000,
+ invalidateUrlsRegex: /posts/gi,
+ });
+
+ await ajax.fetch('/test');
+ await ajax.fetch('/test'); // cached
+ expect(fetchStub.callCount).to.equal(1);
+ await ajax.fetch('/posts');
+ expect(fetchStub.callCount).to.equal(2);
+ await ajax.fetch('/posts'); // cached
+ expect(fetchStub.callCount).to.equal(2);
+ await ajax.fetch('/posts/1');
+ expect(fetchStub.callCount).to.equal(3);
+ await ajax.fetch('/posts/1'); // cached
+ expect(fetchStub.callCount).to.equal(3);
+ // cleans cache for defined urls
+ await ajax.fetch('/test', { method: 'POST' });
+ expect(fetchStub.callCount).to.equal(4);
+ await ajax.fetch('/posts'); // no longer cached => new request
+ expect(fetchStub.callCount).to.equal(5);
+ await ajax.fetch('/posts/1'); // no longer cached => new request
+ expect(fetchStub.callCount).to.equal(6);
+ });
+
+ it('deletes cache after one hour', async () => {
+ newCacheId();
+ const clock = sinon.useFakeTimers({
+ shouldAdvanceTime: true,
+ });
+
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ maxAge: 1000 * 60 * 60,
+ });
+
+ await ajax.fetch('/test-hour');
+ expect(ajaxRequestSpy.calledOnce).to.be.true;
+ expect(ajaxRequestSpy.calledWith('/test-hour')).to.be.true;
+ expect(fetchStub.callCount).to.equal(1);
+ clock.tick(1000 * 60 * 59); // 0:59 hour
+ await ajax.fetch('/test-hour');
+ expect(fetchStub.callCount).to.equal(1);
+ clock.tick(1000 * 60 * 2); // +2 minutes => 1:01 hour
+ await ajax.fetch('/test-hour');
+ expect(fetchStub.callCount).to.equal(2);
+ clock.restore();
+ });
+
+ it('invalidates invalidateUrls endpoints', async () => {
+ const { requestIdFunction } = extendCacheOptions({});
+
+ newCacheId();
+
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ maxAge: 500,
+ });
+
+ const cacheOptions = {
+ invalidateUrls: [
+ requestIdFunction({
+ url: new URL('/test-invalid-url', window.location.href).toString(),
+ params: { foo: 1, bar: 2 },
+ }),
+ ],
+ };
+
+ await ajax.fetch('/test-valid-url', { cacheOptions });
+ expect(fetchStub.callCount).to.equal(1);
+
+ await ajax.fetch('/test-invalid-url?foo=1&bar=2');
+ expect(fetchStub.callCount).to.equal(2);
+
+ await ajax.fetch('/test-invalid-url?foo=1&bar=2');
+ expect(fetchStub.callCount).to.equal(2);
+
+ // 'post' will invalidate 'own' cache and the one mentioned in config
+ await ajax.fetch('/test-valid-url', { cacheOptions, method: 'POST' });
+ expect(fetchStub.callCount).to.equal(3);
+
+ await ajax.fetch('/test-invalid-url?foo=1&bar=2');
+ // indicates that 'test-invalid-url' cache was removed
+ // because the server registered new request
+ expect(fetchStub.callCount).to.equal(4);
+ });
+
+ it('invalidates cache on a post', async () => {
+ newCacheId();
+
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ maxAge: 100,
+ });
+
+ await ajax.fetch('/test-post');
+ expect(ajaxRequestSpy.calledOnce).to.be.true;
+ expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true;
+ expect(fetchStub.callCount).to.equal(1);
+ await ajax.fetch('/test-post', { method: 'POST', body: 'data-post' });
+ expect(ajaxRequestSpy.calledTwice).to.be.true;
+ expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true;
+ expect(fetchStub.callCount).to.equal(2);
+ await ajax.fetch('/test-post');
+ expect(fetchStub.callCount).to.equal(3);
+ });
+
+ it('caches response but does not return it when expiration time is 0', async () => {
+ newCacheId();
+
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ maxAge: 0,
+ });
+
+ const clock = sinon.useFakeTimers();
+
+ await ajax.fetch('/test');
+
+ expect(ajaxRequestSpy.calledOnce).to.be.true;
+
+ expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
+
+ clock.tick(1);
+
+ await ajax.fetch('/test');
+
+ clock.restore();
+
+ expect(fetchStub.callCount).to.equal(2);
+ });
+
+ it('does not use cache when cacheOption `useCache: false` is passed to fetch method', async () => {
+ // Given
+ addCacheInterceptors(ajax, { useCache: true });
+
+ // When
+ await ajax.fetch('/test');
+ await ajax.fetch('/test');
+
+ // Then
+ expect(fetchStub.callCount).to.equal(1);
+
+ // When
+ await ajax.fetch('/test', { cacheOptions: { useCache: false } });
+
+ // Then
+ expect(fetchStub.callCount).to.equal(2);
+ });
+
+ it('caches concurrent requests', async () => {
+ newCacheId();
+
+ const clock = sinon.useFakeTimers();
+
+ fetchStub.onFirstCall().returns(returnResponseOnTick(900, 1));
+ fetchStub.onSecondCall().returns(returnResponseOnTick(1900, 2));
+
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ maxAge: 750,
+ });
+
+ const firstRequest = ajax.fetch('/test').then(r => r.text());
+ const concurrentFirstRequest1 = ajax.fetch('/test').then(r => r.text());
+ const concurrentFirstRequest2 = ajax.fetch('/test').then(r => r.text());
+
+ clock.tick(1000);
+
+ // firstRequest is cached at tick 1000 in the next line!
+ const firstResponses = await Promise.all([
+ firstRequest,
+ concurrentFirstRequest1,
+ concurrentFirstRequest2,
+ ]);
+
+ expect(fetchStub.callCount).to.equal(1);
+
+ const cachedFirstRequest = ajax.fetch('/test').then(r => r.text());
+
+ clock.tick(500);
+
+ const cachedFirstResponse = await cachedFirstRequest;
+
+ expect(fetchStub.callCount).to.equal(1);
+
+ const secondRequest = ajax.fetch('/test').then(r => r.text());
+ const secondConcurrentRequest = ajax.fetch('/test').then(r => r.text());
+
+ clock.tick(1000);
+
+ const secondResponses = await Promise.all([secondRequest, secondConcurrentRequest]);
+
+ expect(fetchStub.callCount).to.equal(2);
+
+ expect(firstResponses).to.eql(['mock response 1', 'mock response 1', 'mock response 1']);
+
+ expect(cachedFirstResponse).to.equal('mock response 1');
+
+ expect(secondResponses).to.eql(['mock response 2', 'mock response 2']);
+ });
+
+ it('discards responses that are requested in a different cache session', async () => {
+ newCacheId();
+
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ maxAge: 10000,
+ });
+
+ // Switch the cache after the cache request interceptor, but before the fetch
+ // @ts-ignore
+ ajax._requestInterceptors.push(async request => {
+ newCacheId();
+ resetCacheSession(getCacheIdentifier());
+ return request;
+ });
+
+ const firstRequest = ajax.fetch('/test').then(r => r.text());
+
+ const firstResponse = await firstRequest;
+
+ expect(firstResponse).to.equal('mock response');
+ // @ts-ignore
+ expect(ajaxCache._cachedRequests).to.deep.equal({});
+ expect(fetchStub.callCount).to.equal(1);
+ });
+
+ it('preserves status and headers when returning cached response', async () => {
+ newCacheId();
+ fetchStub.returns(
+ Promise.resolve(
+ new Response('mock response', { status: 206, headers: { 'x-foo': 'x-bar' } }),
+ ),
+ );
+
+ addCacheInterceptors(ajax, {
+ useCache: true,
+ maxAge: 100,
+ });
+
+ const response1 = await ajax.fetch('/test');
+ const response2 = await ajax.fetch('/test');
+ expect(fetchStub.callCount).to.equal(1);
+ expect(response1.status).to.equal(206);
+ expect(response1.headers.get('x-foo')).to.equal('x-bar');
+ expect(response2.status).to.equal(206);
+ expect(response2.headers.get('x-foo')).to.equal('x-bar');
+ });
+ });
+});
diff --git a/packages/ajax/test/interceptors/interceptors.test.js b/packages/ajax/test/interceptors/interceptors.test.js
new file mode 100644
index 000000000..e47c1d0c6
--- /dev/null
+++ b/packages/ajax/test/interceptors/interceptors.test.js
@@ -0,0 +1,16 @@
+import { expect } from '@open-wc/testing';
+import * as interceptors from '../../src/interceptors/index.js';
+
+describe('interceptors interface', () => {
+ it('exposes the acceptLanguageRequestInterceptor function', () => {
+ expect(interceptors.acceptLanguageRequestInterceptor).to.be.a('Function');
+ });
+
+ it('exposes the createXsrfRequestInterceptor function', () => {
+ expect(interceptors.createXsrfRequestInterceptor).to.be.a('Function');
+ });
+
+ it('exposes the createCacheInterceptors function', () => {
+ expect(interceptors.createCacheInterceptors).to.be.a('Function');
+ });
+});
diff --git a/packages/ajax/test/interceptors/xsrfHeader.test.js b/packages/ajax/test/interceptors/xsrfHeader.test.js
new file mode 100644
index 000000000..938d26082
--- /dev/null
+++ b/packages/ajax/test/interceptors/xsrfHeader.test.js
@@ -0,0 +1,42 @@
+import { expect } from '@open-wc/testing';
+import { createXsrfRequestInterceptor, getCookie } from '../../src/interceptors/xsrfHeader.js';
+
+describe('getCookie()', () => {
+ it('returns the cookie value', () => {
+ expect(getCookie('foo', { cookie: 'foo=bar' })).to.equal('bar');
+ });
+
+ it('returns the cookie value when there are multiple cookies', () => {
+ expect(getCookie('foo', { cookie: 'foo=bar; bar=foo;lorem=ipsum' })).to.equal('bar');
+ });
+
+ it('returns null when the cookie cannot be found', () => {
+ expect(getCookie('foo', { cookie: 'bar=foo;lorem=ipsum' })).to.equal(null);
+ });
+
+ it('decodes the cookie vaue', () => {
+ expect(getCookie('foo', { cookie: `foo=${decodeURIComponent('/foo/ bar "')}` })).to.equal(
+ '/foo/ bar "',
+ );
+ });
+});
+
+describe('createXsrfRequestInterceptor()', () => {
+ it('adds the xsrf token header to the request', () => {
+ const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
+ cookie: 'XSRF-TOKEN=foo',
+ });
+ const request = new Request('/foo/');
+ interceptor(request);
+ expect(request.headers.get('X-XSRF-TOKEN')).to.equal('foo');
+ });
+
+ it('does not set anything if the cookie is not there', () => {
+ const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
+ cookie: 'XXSRF-TOKEN=foo',
+ });
+ const request = new Request('/foo/');
+ interceptor(request);
+ expect(request.headers.get('X-XSRF-TOKEN')).to.equal(null);
+ });
+});
diff --git a/packages/ajax/types/types.d.ts b/packages/ajax/types/types.d.ts
index ba4e9494a..0a8a231ab 100644
--- a/packages/ajax/types/types.d.ts
+++ b/packages/ajax/types/types.d.ts
@@ -27,18 +27,18 @@ export interface CacheConfig {
export type Params = { [key: string]: any };
-export type RequestIdentificationFn = (
+export type RequestIdFunction = (
request: Partial,
- stringifySearchParams: (params: Params) => string,
+ serializeSearchParams?: (params: Params) => string,
) => string;
export interface CacheOptions {
useCache?: boolean;
methods?: string[];
- timeToLive?: number;
+ maxAge?: number;
invalidateUrls?: string[];
invalidateUrlsRegex?: RegExp;
- requestIdentificationFn?: RequestIdentificationFn;
+ requestIdFunction?: RequestIdFunction;
}
export interface CacheOptionsWithIdentifier extends CacheOptions {
@@ -48,11 +48,12 @@ export interface CacheOptionsWithIdentifier extends CacheOptions {
export interface ValidatedCacheOptions extends CacheOptions {
useCache: boolean;
methods: string[];
- timeToLive: number;
- requestIdentificationFn: RequestIdentificationFn;
+ maxAge: number;
+ requestIdFunction: RequestIdFunction;
}
export interface CacheRequestExtension {
+ cacheSessionId?: string;
cacheOptions?: CacheOptions;
adapter: any;
status: number;
@@ -61,6 +62,7 @@ export interface CacheRequestExtension {
}
export interface CacheResponseRequest {
+ cacheSessionId?: string;
cacheOptions?: CacheOptions;
method: string;
}
diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs
index c1db9a929..08d036eab 100644
--- a/web-test-runner.config.mjs
+++ b/web-test-runner.config.mjs
@@ -39,10 +39,8 @@ export default {
playwrightLauncher({ product: 'chromium' }),
playwrightLauncher({ product: 'webkit' }),
],
- groups: packages.map(pkg => {
- return {
- name: pkg,
- files: `packages/${pkg}/test/**/*.test.js`,
- };
- }),
+ groups: packages.map(pkg => ({
+ name: pkg,
+ files: `packages/${pkg}/test/**/*.test.js`,
+ })),
};