@podium/client
Version:
Client for fetching podium component fragments over HTTP.
226 lines (195 loc) • 7.58 kB
JavaScript
import Metrics from '@metrics/client';
import abslog from 'abslog';
import assert from 'assert';
import HttpOutgoing from './http-outgoing.js';
import Response from './response.js';
import Resolver from './resolver.js';
import * as utils from './utils.js';
const inspect = Symbol.for('nodejs.util.inspect.custom');
/**
* @typedef {object} RequestFilterOptions
* @property {string[]} [deviceType] List of values for the `x-podium-device-type` HTTP request header.
*/
/**
* @typedef {object} PodiumClientResourceOptions
* @property {import('abslog').AbstractLoggerOptions} [logger]
* @property {string} clientName
* @property {string} name
* @property {string} uri To the podlet's `manifest.json`
* @property {number} timeout In milliseconds
* @property {number} maxAge
* @property {number} [retries]
* @property {boolean} [throwable]
* @property {boolean} [redirectable]
* @property {boolean} [rejectUnauthorized]
* @property {import('http').Agent} [httpAgent]
* @property {import('https').Agent} [httpsAgent]
* @property {RequestFilterOptions} [excludeBy] Used by `fetch` to conditionally skip fetching the podlet content based on values on the request.
* @property {RequestFilterOptions} [includeBy] Used by `fetch` to conditionally skip fetching the podlet content based on values on the request.
*/
export default class PodiumClientResource {
#resolver;
#options;
#metrics;
#state;
/**
* @constructor
* @param {import('ttl-mem-cache').default} registry
* @param {import('./state.js').default} state
* @param {PodiumClientResourceOptions} options
*/
// @ts-expect-error Deliberate for better error messages
constructor(registry, state, options = {}) {
assert(
registry,
'you must pass a "registry" object to the PodiumClientResource constructor',
);
assert(
state,
'you must pass a "state" object to the PodiumClientResource constructor',
);
const log = abslog(options.logger);
this.#resolver = new Resolver(registry, options);
this.#options = options;
this.#metrics = new Metrics();
this.#state = state;
this.#metrics.on('error', (error) => {
log.error(
'Error emitted by metric stream in @podium/client module',
error,
);
});
this.#resolver.metrics.pipe(this.#metrics);
}
get metrics() {
return this.#metrics;
}
get name() {
return this.#options.name;
}
get uri() {
return this.#options.uri;
}
/**
* Fetch the podlet's content, or fallback if the podlet is unavailable.
* The podlet response includes references to its CSS and JS assets which should be included in the final HTML document.
*
* @param {import('@podium/utils').HttpIncoming} incoming Instance of HttpIncoming
* @param {import('./http-outgoing.js').PodiumClientResourceOptions} [reqOptions={}] Optional parameters to the HTTP request, such as query parameters or HTTP request headers.
* @returns {Promise<import('./response.js').default>}
*
* @example
* ```js
* const incoming = res.locals.podium; // Express server example
* const header = await headerPodlet.fetch(incoming);
* incoming.podlets = [header]; // Register the podlet's JS and CSS assets with the layout's HTML template
* ```
*/
async fetch(incoming, reqOptions = {}) {
if (!utils.validateIncoming(incoming))
throw new TypeError(
'you must pass an instance of "HttpIncoming" as the first argument to the .fetch() method',
);
const outgoing = new HttpOutgoing(this.#options, reqOptions, incoming);
if (this.#options.excludeBy) {
/**
* @type {string[] | undefined}
*/
const excludedDeviceTypes = this.#options.excludeBy.deviceType;
if (Array.isArray(excludedDeviceTypes)) {
const deviceTypeHeader =
incoming.request.headers['x-podium-device-type'];
for (let i = 0; i < excludedDeviceTypes.length; i += 1) {
const shouldSkip =
excludedDeviceTypes[i] === deviceTypeHeader;
if (shouldSkip) {
return new Response({
headers: {},
content: '',
css: [],
js: [],
redirect: null,
});
}
}
}
}
if (this.#options.includeBy) {
/**
* @type {string[] | undefined}
*/
const includeDeviceTypes = this.#options.includeBy.deviceType;
if (Array.isArray(includeDeviceTypes)) {
const deviceTypeHeader =
incoming.request.headers['x-podium-device-type'];
const shouldRequest =
!deviceTypeHeader ||
includeDeviceTypes.includes(deviceTypeHeader);
if (!shouldRequest) {
return new Response({
headers: {},
content: '',
css: [],
js: [],
redirect: null,
});
}
}
}
this.#state.setInitializingState();
const { headers, redirect } = await this.#resolver.resolve(outgoing);
const chunks = [];
for await (const chunk of outgoing) {
chunks.push(chunk);
}
const content = !outgoing.redirect
? Buffer.concat(chunks).toString()
: '';
return new Response({
headers,
content,
css: outgoing.css,
js: outgoing.js,
redirect,
});
}
/**
* Stream the podlet's content, or fallback if the podlet is unavailable.
*
* @param {import('@podium/utils').HttpIncoming} incoming
* @param {import('./http-outgoing.js').PodiumClientResourceOptions} [reqOptions={}]
* @returns {import('./http-outgoing.js').default}
*/
stream(incoming, reqOptions = {}) {
if (!utils.validateIncoming(incoming))
throw new TypeError(
'you must pass an instance of "HttpIncoming" as the first argument to the .stream() method',
);
const outgoing = new HttpOutgoing(this.#options, reqOptions, incoming);
this.#state.setInitializingState();
this.#resolver.resolve(outgoing);
return outgoing;
}
/**
* Refresh the podlet's manifest and fallback in the cache.
*
* @param {import('@podium/utils').HttpIncoming} [incoming]
* @param {import('./http-outgoing.js').PodiumClientResourceOptions} [reqOptions={}]
* @returns {Promise<boolean>} `true` if succesful
*/
refresh(incoming, reqOptions = {}) {
const outgoing = new HttpOutgoing(this.#options, reqOptions, incoming);
this.#state.setInitializingState();
return this.#resolver.refresh(outgoing).then((obj) => obj);
}
[inspect]() {
return {
metrics: this.metrics,
name: this.name,
uri: this.uri,
};
}
get [Symbol.toStringTag]() {
return 'PodiumClientResource';
}
}