UNPKG

@podium/client

Version:

Client for fetching podium component fragments over HTTP.

385 lines (324 loc) 10.5 kB
import { PassThrough } from 'stream'; import assert from 'assert'; import { toPreloadAssetObjects, filterAssets } from './utils.js'; /** * @typedef {object} PodiumClientHttpOutgoingOptions * @property {string} name * @property {string} uri To the podlet's `manifest.json` * @property {number} timeout In milliseconds * @property {number} maxAge * @property {number} [retries=4] * @property {boolean} [throwable=false] * @property {boolean} [redirectable=false] * @property {boolean} [rejectUnauthorized=true] * @property {import('http').Agent} [httpAgent] * @property {import('https').Agent} [httpsAgent] */ /** * @typedef {object} PodiumClientResourceOptions * @property {string} [pathname] * @property {import('http').IncomingHttpHeaders} [headers] * @property {object} [query] */ /** * @typedef {object} PodiumRedirect * @property {number} statusCode; * @property {string} location; */ /** * @typedef {object} PodletProxySchema * @property {string} target * @property {string} name */ /** * @typedef {object} PodletManifest Similar to the schema's manifest, but with instances of AssetCss and AssetJs from `@podium/utils` and default values. * @property {string} name * @property {string} version * @property {string} content * @property {string} fallback * @property {Array<import('@podium/utils').AssetJs>} js * @property {Array<import('@podium/utils').AssetCss>} css * @property {Record<string, string> | Array<PodletProxySchema>} proxy * @property {string} team */ export default class PodletClientHttpOutgoing extends PassThrough { #rejectUnauthorized; #killRecursions; #killThreshold; #redirectable; #reqOptions; #isFallback = false; #throwable; /** @type {PodletManifest} */ #manifest; #incoming; /** @type {null | PodiumRedirect} */ #redirect = null; #timeout; #success; #headers; #maxAge; /** @type {'empty' | 'fresh' | 'cached' | 'stale'} */ #status; #name; #uri; #js; #css; /** * @constructor * @param {PodiumClientHttpOutgoingOptions} options * @param {PodiumClientResourceOptions} [reqOptions] * @param {import('@podium/utils').HttpIncoming} [incoming] */ // @ts-expect-error Deliberate default empty options for better error messages constructor(options = {}, reqOptions, incoming) { super(); const { rejectUnauthorized = true, throwable = false, redirectable = false, retries = 4, timeout, maxAge, name = '', uri, } = options; assert( uri, 'you must pass a URI in "options.uri" to the HttpOutgoing constructor', ); // If requests to https sites should reject on unsigned sertificates this.#rejectUnauthorized = rejectUnauthorized; this.#incoming = incoming; // Kill switch for breaking the recursive promise chain // in case it is never able to completely resolve this.#killRecursions = 0; this.#killThreshold = retries; // Options to be appended to the content request this.#reqOptions = { pathname: '', query: {}, headers: {}, ...reqOptions, }; // In the case of failure, should the resource throw or not this.#throwable = throwable; // Manifest which is either retrieved from the registry or // remote podlet (in other words its not saved in registry yet) this.#manifest = { // @ts-expect-error Internal property _fallback: '', _js: [], _css: [], }; // How long before a request should time out this.#timeout = timeout; // Done indicator to break the promise chain // Set to true when content is served this.#success = false; this.#headers = {}; // We want to automatically forward the traceparent and tracestate // HTTP headers as-is (no `podium-` prefix) for the automatic // association between OpenTelemetry traces in a distributed system. if (this.#incoming?.request?.headers?.['traceparent']) { this.#headers['traceparent'] = this.#incoming.request.headers['traceparent']; } if (this.#incoming?.request?.headers?.['tracestate']) { this.#headers['tracestate'] = this.#incoming.request.headers['tracestate']; } // How long the manifest should be cached before refetched this.#maxAge = maxAge; this.#status = 'empty'; // Name of the resource (name given to client) this.#name = name; // URI to manifest this.#uri = uri; // Whether the user can handle redirects manually. this.#redirectable = redirectable; } get js() { // return the internal js value or, fallback to the manifest for backwards compatibility return this.#js && this.#js.length ? this.#js : filterAssets('content', this.#manifest.js); } set js(value) { this.#js = value; } get css() { // return the internal css value or, fallback to the manifest for backwards compatibility return this.#css && this.#css.length ? this.#css : filterAssets('content', this.#manifest.css); } set css(value) { this.#css = value; } get rejectUnauthorized() { return this.#rejectUnauthorized; } get reqOptions() { return this.#reqOptions; } get throwable() { return this.#throwable; } get manifest() { return this.#manifest; } set manifest(obj) { this.#manifest = obj; } get fallback() { // @ts-expect-error Internal property return this.#manifest._fallback; } set fallback(value) { // @ts-expect-error Internal property this.#manifest._fallback = value; } get timeout() { return this.#timeout; } get success() { return this.#success; } set success(value) { this.#success = value; } get context() { return this.#incoming.context; } get headers() { return this.#headers; } set headers(value) { this.#headers = value; } get maxAge() { return this.#maxAge; } set maxAge(value) { this.#maxAge = value; } /** * What status the manifest is in. This is used to tell what actions need to * be performed throughout the resolving process to complete a request. * * The different statuses can be: * - `"empty"` - there is no manifest available - we are in process of fetching it * - `"fresh"` - the manifest has been fetched but is not stored in cache yet * - `"cached"` - the manifest was retrieved from cache * - `"stale"` - the manifest is outdated, a new manifest needs to be fetched */ get status() { return this.#status; } set status(value) { this.#status = value; } get name() { return this.#name; } get manifestUri() { return this.#uri; } get fallbackUri() { return this.#manifest.fallback; } get contentUri() { return this.#manifest.content; } /** * Kill switch for breaking the recursive promise chain in case it is never able to completely resolve. * This is true if the number of recursions matches the threshold. */ get kill() { return this.#killRecursions === this.#killThreshold; } /** * The number of recursions before the request should be {@link kill}ed */ get recursions() { return this.#killRecursions; } /** * Set the number of recursions before the request should be {@link kill}ed */ set recursions(value) { this.#killRecursions = value; } /** * When {@link redirectable} is `true` this is populated with redirect information so you can send a redirect response to the browser from your layout. * * @see https://podium-lib.io/docs/layout/handling_redirects */ get redirect() { return this.#redirect; } set redirect(value) { this.#redirect = value; } /** * Whether the podlet can signal redirects to the layout. * * @see https://podium-lib.io/docs/layout/handling_redirects */ get redirectable() { return this.#redirectable; } set redirectable(value) { this.#redirectable = value; } /** * True if the client has returned the podlet's fallback. * * @example * * ```js * if (outgoing.isFallback) console.log("Fallback!"); * ``` * * @see https://podium-lib.io/docs/podlet/fallbacks */ get isFallback() { return this.#isFallback; } pushFallback() { // @ts-expect-error Internal property this.push(this.#manifest._fallback); this.js = // @ts-expect-error Internal property this.#manifest._js && this.#manifest._js.length ? // @ts-expect-error Internal property filterAssets('fallback', this.#manifest._js) : filterAssets('fallback', this.#manifest.js); this.css = // @ts-expect-error Internal property this.#manifest._css && this.#manifest._css.length ? // @ts-expect-error Internal property filterAssets('fallback', this.#manifest._css) : filterAssets('fallback', this.#manifest.css); this.push(null); this.#isFallback = true; // assume the hints from the podlet have failed and fallback assets will be used this.hintsReceived = true; } writeEarlyHints(cb = () => {}) { if (this.#incoming.response.writeEarlyHints) { const preloads = toPreloadAssetObjects([ ...(this.js || []), ...(this.css || []), ]); const link = preloads.map((preload) => preload.toHeader()); if (link.length) { this.#incoming.response.writeEarlyHints({ link }, cb); } } } get [Symbol.toStringTag]() { return 'PodletClientHttpOutgoing'; } }