UNPKG

@fastly/esi

Version:

ESI implementation for JavaScript, using the modern fetch and streaming APIs.

321 lines (320 loc) 13.9 kB
/* * Copyright Fastly, Inc. * Licensed under the MIT license. See LICENSE file for details. */ // Currently supported: // <esi:comment> // <esi:remove> // <esi:include> // <esi:try> <esi:attempt> <esi:except> // <esi:choose> <esi:when> <esi:otherwise> // <esi:vars> // ESI comments <!--esi and --> // ESI Variables // ESI Expressions // Currently unsupported: // <esi:inline> import { buildTransform, XmlElement } from "./XmlModel.js"; import { applyEsiVariables } from "./EsiVariables.js"; import { EsiExpressionEvaluator } from "./EsiExpressions.js"; export class EsiError extends Error { constructor(message) { super(message); } } export class EsiElementError extends EsiError { el; constructor(el, message) { super(message); this.el = el; } } export class EsiIncludeError extends EsiElementError { constructor(el, message) { super(el, message); } } export class EsiStructureError extends EsiElementError { constructor(el, message) { super(el, message); } } export default class EsiTransformer { // noinspection HttpUrlsUsage - this is a public constant static namespace = 'http://www.edge-delivery.org/esi/1.0'; static depthLimit = 10; url; headers; options; depth; expressionEvaluator; applyVars = false; /** * Construct an instance of EsiTransformer. * @param url Absolute URL of request that was used when fetching the stream * @param headers The request headers that were used when fetching the stream * @param options Transformer options * @param depth Depth of recursion */ constructor(url, headers, options, depth = 0) { this.url = new URL(url); this.headers = new Headers(headers); this.options = options ?? {}; this.depth = depth; } async transformChildElements(el) { const results = []; for (const child of el.children) { const result = await this.transformElementNode(child); if (result == null) { continue; } if (result instanceof XmlElement && result.localName === '_replace') { results.push(...result.children); } else { results.push(result); } } return results; } async transformElementNode(node) { if (typeof node === 'string') { return this.applyVars ? applyEsiVariables(node, this.options.vars) : node; } const transformFunc = buildTransform(node.document, async (el) => { if (el instanceof XmlElement && el.namespace === EsiTransformer.namespace) { if (el.localName === 'comment') { // Remove node entirely return null; // TODO: validation // * must not have any children // * text attr is optional } if (el.localName === 'remove') { // Remove node entirely return null; // TODO: validation // * must not have other esi elements in children // * no attrs } if (el.localName === 'include') { const srcs = []; const src = applyEsiVariables(el.attrs['src'].value, this.options.vars); const alt = applyEsiVariables(el.attrs['alt']?.value, this.options.vars); if (src != null) { srcs.push(src); } if (alt != null) { srcs.push(alt); } let esiIncludeResult = undefined; if (this.depth < EsiTransformer.depthLimit) { for (const src of srcs) { // The URL and headers to use for this include. const url = new URL(src, this.url); const headers = new Headers(this.headers); // add host header if host has changed const host = url.host.toLowerCase(); if (host !== this.url.host.toLowerCase()) { headers.set('host', host); } // esi:include is ALWAYS done using the GET verb const init = { method: 'GET', headers, }; const res = await (this.options.fetch ?? fetch)(String(url), init); if (res.status >= 200 && res.status < 300) { esiIncludeResult = { url, headers, res }; break; } } } if (esiIncludeResult == null) { if (this.options.handleIncludeError != null) { const event = { url: this.url, headers: this.headers, el, customErrorString: null, }; await this.options.handleIncludeError(event); if (event.customErrorString != null) { return event.customErrorString; } } const swallowErrors = applyEsiVariables(el.attrs['onerror']?.value, this.options.vars) === 'continue'; if (swallowErrors) { // Swallow and remove node entirely return null; } throw new EsiIncludeError(el, `Could not include ${el.serialize()}`); } if (this.options.processIncludeResponse == null) { return await esiIncludeResult.res.text(); } return await this.options.processIncludeResponse(esiIncludeResult); // TODO: validation // * src attr is required // * alt attr is optional } if (el.localName === 'try') { const attemptTags = el.children .filter(tag => { return tag instanceof XmlElement && tag.namespace === EsiTransformer.namespace && tag.localName === 'attempt'; }); if (attemptTags.length != 1) { throw new EsiStructureError(el, 'esi:try requires exactly one esi:attempt tag as a direct child'); } const attemptTag = attemptTags[0]; const exceptTags = el.children.filter(tag => { return tag instanceof XmlElement && tag.namespace === EsiTransformer.namespace && tag.localName === 'except'; }); if (exceptTags.length != 1) { throw new EsiStructureError(el, 'esi:try requires exactly one esi:except tag as a direct child'); } const exceptTag = exceptTags[0]; let applyVarsPrev = this.applyVars; try { this.applyVars = true; try { return await this.transformChildElements(attemptTag); } catch (ex) { if (!(ex instanceof EsiIncludeError)) { throw ex; } } return await this.transformChildElements(exceptTag); } finally { this.applyVars = applyVarsPrev; } } if (el.localName === 'attempt') { throw new EsiStructureError(el, 'esi:attempt must be direct child of esi:try'); } if (el.localName === 'except') { throw new EsiStructureError(el, 'esi:except must be direct child of esi:try'); } if (el.localName === 'vars') { let applyVarsPrev = this.applyVars; try { this.applyVars = true; return await this.transformChildElements(el); } finally { this.applyVars = applyVarsPrev; } } if (el.localName === 'choose') { this.expressionEvaluator ??= new EsiExpressionEvaluator(this.options.vars); const whenTags = el.children .filter(tag => { return tag instanceof XmlElement && tag.namespace === EsiTransformer.namespace && tag.localName === 'when'; }); if (whenTags.length === 0) { throw new EsiStructureError(el, 'esi:choose must have at least one esi:when as direct child'); } if (whenTags.some(whenTag => whenTag.attrs['test'] == null)) { throw new EsiStructureError(el, 'esi:when tags are required to have a test attribute.'); } const otherwiseTags = el.children .filter(tag => { return tag instanceof XmlElement && tag.namespace === EsiTransformer.namespace && tag.localName === 'otherwise'; }); if (otherwiseTags.length > 1) { throw new EsiStructureError(el, 'esi:choose must not have more than one esi:otherwise'); } const otherwiseTag = otherwiseTags.length === 1 ? otherwiseTags[0] : null; let activeBranch = null; for (const whenTag of whenTags) { if (this.expressionEvaluator.evaluate(whenTag.attrs['test'].value)) { activeBranch = whenTag; break; } } activeBranch ??= otherwiseTag; if (activeBranch == null) { return null; } let applyVarsPrev = this.applyVars; try { this.applyVars = true; return await this.transformChildElements(activeBranch); } finally { this.applyVars = applyVarsPrev; } return null; } if (el.localName === 'when') { throw new EsiStructureError(el, 'esi:when must be direct child of esi:choose'); } if (el.localName === 'otherwise') { throw new EsiStructureError(el, 'esi:otherwise must be direct child of esi:choose'); } throw new EsiStructureError(el, 'Unknown esi tag esi:' + el.localName); } }); return await transformFunc(node); } isInEsiComment = false; xmlStreamerBeforeProcess(streamerState) { let pos = 0; while (true) { if (!this.isInEsiComment) { pos = streamerState.bufferedString.indexOf('<!--esi', pos); if (pos < 0) { break; } streamerState.bufferedString = streamerState.bufferedString.slice(0, pos) + streamerState.bufferedString.slice(pos + 7); this.isInEsiComment = true; } if (this.isInEsiComment) { pos = streamerState.bufferedString.indexOf('-->', pos); if (pos < 0) { break; } streamerState.bufferedString = streamerState.bufferedString.slice(0, pos) + streamerState.bufferedString.slice(pos + 3); this.isInEsiComment = false; } } let sepPos = -1; if (this.isInEsiComment) { if (streamerState.bufferedString.endsWith('--')) { sepPos = streamerState.bufferedString.lastIndexOf('--'); } else if (streamerState.bufferedString.endsWith('-')) { sepPos = streamerState.bufferedString.lastIndexOf('-'); } } else { if (streamerState.bufferedString.endsWith('<') || streamerState.bufferedString.endsWith('<!') || streamerState.bufferedString.endsWith('<!-') || streamerState.bufferedString.endsWith('<!--') || streamerState.bufferedString.endsWith('<!--e') || streamerState.bufferedString.endsWith('<!--es')) { sepPos = streamerState.bufferedString.lastIndexOf('<'); } } if (sepPos > 0) { streamerState.postponedString = streamerState.bufferedString.slice(sepPos); streamerState.bufferedString = streamerState.bufferedString.slice(0, sepPos); } } }