UNPKG

@podium/client

Version:

Client for fetching podium component fragments over HTTP.

290 lines (253 loc) 9.14 kB
import EventEmitter from 'events'; import { uriStrict as validateUriStrict, name as validateName, } from '@podium/schemas'; import Metrics from '@metrics/client'; import abslog from 'abslog'; import Cache from 'ttl-mem-cache'; import http from 'http'; import https from 'https'; import Resource from './resource.js'; import State from './state.js'; const inspect = Symbol.for('nodejs.util.inspect.custom'); const HTTP_AGENT_OPTIONS = { keepAlive: true, maxSockets: 10, maxFreeSockets: 10, timeout: 60000, keepAliveMsecs: 30000, }; const HTTPS_AGENT_OPTIONS = { ...HTTP_AGENT_OPTIONS, maxCachedSessions: 10, }; const REJECT_UNAUTHORIZED = true; const HTTP_AGENT = new http.Agent(HTTP_AGENT_OPTIONS); const HTTPS_AGENT = new https.Agent(HTTPS_AGENT_OPTIONS); const RETRIES = 4; const TIMEOUT = 1000; // 1 seconds const MAX_AGE = Infinity; /** * @typedef {import('./resource.js').default} PodiumClientResource * @typedef {import('./resource.js').PodiumClientResourceOptions} PodiumClientResourceOptions * @typedef {import('./response.js').default} PodiumClientResponse * @typedef {import('./http-outgoing.js').PodiumRedirect} PodiumRedirect * @typedef {import('@podium/schemas').PodletManifestSchema} PodletManifest */ /** * @typedef {object} PodiumClientOptions * @property {string} name * @property {import('abslog').AbstractLoggerOptions} [logger] * @property {number} [retries=4] * @property {number} [timeout=1000] In milliseconds * @property {number} [maxAge=Infinity] * @property {boolean} [rejectUnauthorized=true] * @property {number} [resolveThreshold] * @property {number} [resolveMax] * @property {import('http').Agent} [httpAgent] * @property {import('https').Agent} [httpsAgent] */ /** * @typedef {object} RegisterOptions * @property {string} name A unique name for the podlet * @property {string} uri URL to the podlet's `manifest.json` * @property {number} [retries=4] Number of retries before serving fallback * @property {number} [timeout=1000] In milliseconds, the amount of time to wait before serving fallback. * @property {boolean} [throwable=false] Set to `true` and surround `fetch` in `try/catch` to serve different content in case podlet is unavailable. Will not server fallback content. * @property {boolean} [redirectable=false] Set to `true` to allow podlet to respond with a redirect. You need to look for the redirect response from the podlet and return a redirect response to the browser yourself. * @property {import('./resource.js').RequestFilterOptions} [excludeBy] Used by `fetch` to conditionally skip fetching the podlet content based on values on the request. * @property {import('./resource.js').RequestFilterOptions} [includeBy] Used by `fetch` to conditionally skip fetching the podlet content based on values on the request. */ export default class PodiumClient extends EventEmitter { #resources; #registry; #metrics; #counter; #histogram; #options; #state; /** * @constructor * @param {PodiumClientOptions} options */ // @ts-expect-error Deliberate default empty options for better error messages constructor(options = {}) { super(); const log = abslog(options.logger); if (validateName(options.name).error) { throw new Error( `The value, "${options.name}", for the required argument "name" on the Client constructor is not defined or not valid.`, ); } this.#options = { name: '', retries: RETRIES, timeout: TIMEOUT, logger: options.logger, maxAge: MAX_AGE, rejectUnauthorized: REJECT_UNAUTHORIZED, httpAgent: HTTP_AGENT, httpsAgent: HTTPS_AGENT, includeBy: undefined, excludeBy: undefined, ...options, }; this.#state = new State({ resolveThreshold: options.resolveThreshold, resolveMax: options.resolveMax, }); this.#state.on('state', (state) => { this.emit('state', state); }); this.#resources = new Map(); this.#registry = new Cache({ changefeed: true, ttl: options.maxAge, }); this.#registry.on('error', (error) => { log.error( 'Error emitted by the registry in @podium/client module', error, ); }); this.#registry.on('set', () => { this.#state.setUnstableState(); }); this.#metrics = new Metrics(); this.#metrics.on('error', (error) => { log.error( 'Error emitted by metric stream in @podium/client module', error, ); }); this[Symbol.iterator] = () => ({ items: Array.from(this.#resources).map((item) => item[1]), next: function next() { return { done: this.items.length === 0, value: this.items.shift(), }; }, }); this.#counter = this.#metrics.counter({ name: 'podium_client_registered_podlet_count', description: 'Number of podlets registered with the client', labels: { clientName: this.#options.name, resourceName: undefined, resourceUri: undefined, }, }); this.#histogram = this.#metrics.histogram({ name: 'podium_client_refresh_manifests', description: 'Time taken for podium client to refresh manifests', labels: { name: this.#options.name }, buckets: [0.001, 0.01, 0.1, 0.5, 1, 2, 10], }); } get registry() { return this.#registry; } get metrics() { return this.#metrics; } get state() { return this.#state.status; } /** * Register a podlet so you can fetch its contents later with {@link PodiumClientResource.fetch}. * * @param {RegisterOptions} options * @returns {PodiumClientResource} * * @example * ```js * const headerPodlet = layout.client.register({ * name: 'header', * uri: 'http://header/manifest.json', * }); * ``` */ register(options) { if (validateName(options.name).error) throw new Error( `The value, "${options.name}", for the required argument "name" on the .register() method is not defined or not valid.`, ); if (validateUriStrict(options.uri).error) throw new Error( `The value, "${options.uri}", for the required argument "uri" on the .register() method is not defined or not valid.`, ); if (this.#resources.has(options.name)) { throw new Error( `Resource with the name "${options.name}" has already been registered.`, ); } if (options.includeBy && options.excludeBy) { throw new Error( 'A podlet can only be registered with either includeBy or excludeBy, not both.', ); } const resourceOptions = { rejectUnauthorized: this.#options.rejectUnauthorized, clientName: this.#options.name, retries: this.#options.retries, timeout: this.#options.timeout, logger: this.#options.logger, maxAge: this.#options.maxAge, httpsAgent: this.#options.httpsAgent, httpAgent: this.#options.httpAgent, includeBy: this.#options.includeBy, excludeBy: this.#options.excludeBy, ...options, }; const resource = new Resource( this.#registry, this.#state, resourceOptions, ); this.#counter.inc({ labels: { resourceName: options.name, resourceUri: options.uri, }, }); resource.metrics.pipe(this.#metrics); Object.defineProperty(this, options.name, { get: () => this.#resources.get(options.name), set: () => { throw new Error('Cannot set read-only property.'); }, }); this.#resources.set(options.name, resource); return resource; } dump() { return this.#registry.dump(); } load(dump) { return this.#registry.load(dump); } /** * Refreshes the cached podlet manifest for all {@link register}ed podlets. */ async refreshManifests() { const end = this.#histogram.timer(); // Don't return this await Promise.all( Array.from(this.#resources).map((resource) => resource[1].refresh(), ), ); end(); } [inspect]() { return { metrics: this.metrics, state: this.state, }; } get [Symbol.toStringTag]() { return 'PodiumClient'; } }