@fastly/esi
Version:
ESI implementation for JavaScript, using the modern fetch and streaming APIs.
321 lines (320 loc) • 13.9 kB
JavaScript
/*
* 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);
}
}
}