275 lines
12 KiB
JavaScript
275 lines
12 KiB
JavaScript
import EventEmitter from 'node:events';
|
|
import urlLib from 'node:url';
|
|
import crypto from 'node:crypto';
|
|
import stream, { PassThrough as PassThroughStream } from 'node:stream';
|
|
import normalizeUrl from 'normalize-url';
|
|
import getStream from 'get-stream';
|
|
import CachePolicy from 'http-cache-semantics';
|
|
import Response from 'responselike';
|
|
import Keyv from 'keyv';
|
|
import mimicResponse from 'mimic-response';
|
|
import { CacheError, RequestError } from './types.js';
|
|
class CacheableRequest {
|
|
constructor(cacheRequest, cacheAdapter) {
|
|
this.hooks = new Map();
|
|
this.request = () => (options, cb) => {
|
|
let url;
|
|
if (typeof options === 'string') {
|
|
url = normalizeUrlObject(urlLib.parse(options));
|
|
options = {};
|
|
}
|
|
else if (options instanceof urlLib.URL) {
|
|
url = normalizeUrlObject(urlLib.parse(options.toString()));
|
|
options = {};
|
|
}
|
|
else {
|
|
const [pathname, ...searchParts] = (options.path ?? '').split('?');
|
|
const search = searchParts.length > 0
|
|
? `?${searchParts.join('?')}`
|
|
: '';
|
|
url = normalizeUrlObject({ ...options, pathname, search });
|
|
}
|
|
options = {
|
|
headers: {},
|
|
method: 'GET',
|
|
cache: true,
|
|
strictTtl: false,
|
|
automaticFailover: false,
|
|
...options,
|
|
...urlObjectToRequestOptions(url),
|
|
};
|
|
options.headers = Object.fromEntries(entries(options.headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
const ee = new EventEmitter();
|
|
const normalizedUrlString = normalizeUrl(urlLib.format(url), {
|
|
stripWWW: false,
|
|
removeTrailingSlash: false,
|
|
stripAuthentication: false,
|
|
});
|
|
let key = `${options.method}:${normalizedUrlString}`;
|
|
// POST, PATCH, and PUT requests may be cached, depending on the response
|
|
// cache-control headers. As a result, the body of the request should be
|
|
// added to the cache key in order to avoid collisions.
|
|
if (options.body && options.method !== undefined && ['POST', 'PATCH', 'PUT'].includes(options.method)) {
|
|
if (options.body instanceof stream.Readable) {
|
|
// Streamed bodies should completely skip the cache because they may
|
|
// or may not be hashable and in either case the stream would need to
|
|
// close before the cache key could be generated.
|
|
options.cache = false;
|
|
}
|
|
else {
|
|
key += `:${crypto.createHash('md5').update(options.body).digest('hex')}`;
|
|
}
|
|
}
|
|
let revalidate = false;
|
|
let madeRequest = false;
|
|
const makeRequest = (options_) => {
|
|
madeRequest = true;
|
|
let requestErrored = false;
|
|
let requestErrorCallback = () => { };
|
|
const requestErrorPromise = new Promise(resolve => {
|
|
requestErrorCallback = () => {
|
|
if (!requestErrored) {
|
|
requestErrored = true;
|
|
resolve();
|
|
}
|
|
};
|
|
});
|
|
const handler = async (response) => {
|
|
if (revalidate) {
|
|
response.status = response.statusCode;
|
|
const revalidatedPolicy = CachePolicy.fromObject(revalidate.cachePolicy).revalidatedPolicy(options_, response);
|
|
if (!revalidatedPolicy.modified) {
|
|
response.resume();
|
|
await new Promise(resolve => {
|
|
// Skipping 'error' handler cause 'error' event should't be emitted for 304 response
|
|
response
|
|
.once('end', resolve);
|
|
});
|
|
const headers = convertHeaders(revalidatedPolicy.policy.responseHeaders());
|
|
response = new Response({ statusCode: revalidate.statusCode, headers, body: revalidate.body, url: revalidate.url });
|
|
response.cachePolicy = revalidatedPolicy.policy;
|
|
response.fromCache = true;
|
|
}
|
|
}
|
|
if (!response.fromCache) {
|
|
response.cachePolicy = new CachePolicy(options_, response, options_);
|
|
response.fromCache = false;
|
|
}
|
|
let clonedResponse;
|
|
if (options_.cache && response.cachePolicy.storable()) {
|
|
clonedResponse = cloneResponse(response);
|
|
(async () => {
|
|
try {
|
|
const bodyPromise = getStream.buffer(response);
|
|
await Promise.race([
|
|
requestErrorPromise,
|
|
new Promise(resolve => response.once('end', resolve)),
|
|
new Promise(resolve => response.once('close', resolve)), // eslint-disable-line no-promise-executor-return
|
|
]);
|
|
const body = await bodyPromise;
|
|
let value = {
|
|
url: response.url,
|
|
statusCode: response.fromCache ? revalidate.statusCode : response.statusCode,
|
|
body,
|
|
cachePolicy: response.cachePolicy.toObject(),
|
|
};
|
|
let ttl = options_.strictTtl ? response.cachePolicy.timeToLive() : undefined;
|
|
if (options_.maxTtl) {
|
|
ttl = ttl ? Math.min(ttl, options_.maxTtl) : options_.maxTtl;
|
|
}
|
|
if (this.hooks.size > 0) {
|
|
/* eslint-disable no-await-in-loop */
|
|
for (const key_ of this.hooks.keys()) {
|
|
value = await this.runHook(key_, value, response);
|
|
}
|
|
/* eslint-enable no-await-in-loop */
|
|
}
|
|
await this.cache.set(key, value, ttl);
|
|
}
|
|
catch (error) {
|
|
ee.emit('error', new CacheError(error));
|
|
}
|
|
})();
|
|
}
|
|
else if (options_.cache && revalidate) {
|
|
(async () => {
|
|
try {
|
|
await this.cache.delete(key);
|
|
}
|
|
catch (error) {
|
|
ee.emit('error', new CacheError(error));
|
|
}
|
|
})();
|
|
}
|
|
ee.emit('response', clonedResponse ?? response);
|
|
if (typeof cb === 'function') {
|
|
cb(clonedResponse ?? response);
|
|
}
|
|
};
|
|
try {
|
|
const request_ = this.cacheRequest(options_, handler);
|
|
request_.once('error', requestErrorCallback);
|
|
request_.once('abort', requestErrorCallback);
|
|
request_.once('destroy', requestErrorCallback);
|
|
ee.emit('request', request_);
|
|
}
|
|
catch (error) {
|
|
ee.emit('error', new RequestError(error));
|
|
}
|
|
};
|
|
(async () => {
|
|
const get = async (options_) => {
|
|
await Promise.resolve();
|
|
const cacheEntry = options_.cache ? await this.cache.get(key) : undefined;
|
|
if (cacheEntry === undefined && !options_.forceRefresh) {
|
|
makeRequest(options_);
|
|
return;
|
|
}
|
|
const policy = CachePolicy.fromObject(cacheEntry.cachePolicy);
|
|
if (policy.satisfiesWithoutRevalidation(options_) && !options_.forceRefresh) {
|
|
const headers = convertHeaders(policy.responseHeaders());
|
|
const response = new Response({ statusCode: cacheEntry.statusCode, headers, body: cacheEntry.body, url: cacheEntry.url });
|
|
response.cachePolicy = policy;
|
|
response.fromCache = true;
|
|
ee.emit('response', response);
|
|
if (typeof cb === 'function') {
|
|
cb(response);
|
|
}
|
|
}
|
|
else if (policy.satisfiesWithoutRevalidation(options_) && Date.now() >= policy.timeToLive() && options_.forceRefresh) {
|
|
await this.cache.delete(key);
|
|
options_.headers = policy.revalidationHeaders(options_);
|
|
makeRequest(options_);
|
|
}
|
|
else {
|
|
revalidate = cacheEntry;
|
|
options_.headers = policy.revalidationHeaders(options_);
|
|
makeRequest(options_);
|
|
}
|
|
};
|
|
const errorHandler = (error) => ee.emit('error', new CacheError(error));
|
|
if (this.cache instanceof Keyv) {
|
|
const cachek = this.cache;
|
|
cachek.once('error', errorHandler);
|
|
ee.on('error', () => cachek.removeListener('error', errorHandler));
|
|
ee.on('response', () => cachek.removeListener('error', errorHandler));
|
|
}
|
|
try {
|
|
await get(options);
|
|
}
|
|
catch (error) {
|
|
if (options.automaticFailover && !madeRequest) {
|
|
makeRequest(options);
|
|
}
|
|
ee.emit('error', new CacheError(error));
|
|
}
|
|
})();
|
|
return ee;
|
|
};
|
|
this.addHook = (name, fn) => {
|
|
if (!this.hooks.has(name)) {
|
|
this.hooks.set(name, fn);
|
|
}
|
|
};
|
|
this.removeHook = (name) => this.hooks.delete(name);
|
|
this.getHook = (name) => this.hooks.get(name);
|
|
this.runHook = async (name, ...args) => this.hooks.get(name)?.(...args);
|
|
if (cacheAdapter instanceof Keyv) {
|
|
this.cache = cacheAdapter;
|
|
}
|
|
else if (typeof cacheAdapter === 'string') {
|
|
this.cache = new Keyv({
|
|
uri: cacheAdapter,
|
|
namespace: 'cacheable-request',
|
|
});
|
|
}
|
|
else {
|
|
this.cache = new Keyv({
|
|
store: cacheAdapter,
|
|
namespace: 'cacheable-request',
|
|
});
|
|
}
|
|
this.request = this.request.bind(this);
|
|
this.cacheRequest = cacheRequest;
|
|
}
|
|
}
|
|
const entries = Object.entries;
|
|
const cloneResponse = (response) => {
|
|
const clone = new PassThroughStream({ autoDestroy: false });
|
|
mimicResponse(response, clone);
|
|
return response.pipe(clone);
|
|
};
|
|
const urlObjectToRequestOptions = (url) => {
|
|
const options = { ...url };
|
|
options.path = `${url.pathname || '/'}${url.search || ''}`;
|
|
delete options.pathname;
|
|
delete options.search;
|
|
return options;
|
|
};
|
|
const normalizeUrlObject = (url) =>
|
|
// If url was parsed by url.parse or new URL:
|
|
// - hostname will be set
|
|
// - host will be hostname[:port]
|
|
// - port will be set if it was explicit in the parsed string
|
|
// Otherwise, url was from request options:
|
|
// - hostname or host may be set
|
|
// - host shall not have port encoded
|
|
({
|
|
protocol: url.protocol,
|
|
auth: url.auth,
|
|
hostname: url.hostname || url.host || 'localhost',
|
|
port: url.port,
|
|
pathname: url.pathname,
|
|
search: url.search,
|
|
});
|
|
const convertHeaders = (headers) => {
|
|
const result = [];
|
|
for (const name of Object.keys(headers)) {
|
|
result[name.toLowerCase()] = headers[name];
|
|
}
|
|
return result;
|
|
};
|
|
export default CacheableRequest;
|
|
export * from './types.js';
|
|
export const onResponse = 'onResponse';
|
|
//# sourceMappingURL=index.js.map
|