@fastly/esi
Version:
ESI implementation for JavaScript, using the modern fetch and streaming APIs.
197 lines (196 loc) • 8.49 kB
JavaScript
/*
* Copyright Fastly, Inc.
* Licensed under the MIT license. See LICENSE file for details.
*/
import { XmlDocument, XmlElement } from './XmlModel.js';
import { xmlDecode } from "./xmlUtils.js";
import StreamerState from "./StreamerState.js";
export class XmlStreamerContext {
document;
options;
// Root level children
children = [];
// Stack of open elements
openElements = [];
streamerState;
constructor(document, options) {
this.document = document ?? new XmlDocument(null);
this.options = options ?? {};
this.streamerState = new StreamerState();
}
append(xmlString) {
this.streamerState.append(xmlString);
this.process();
}
process() {
this.streamerState.applyPostponedXmlString();
if (this.options.beforeProcess != null) {
this.options.beforeProcess(this.streamerState);
}
while (true) {
if (this.streamerState.bufferedString === '') {
break;
}
const parseResult = parseXmlStringChunk(this.streamerState.bufferedString, this.options);
this.streamerState.bufferedString = parseResult.remainingXmlString;
if (parseResult.type === 'unknown') {
break;
}
// Current top item in "open elements" stack
const topOpenElement = this.openElements.length > 0 ? this.openElements[this.openElements.length - 1] : null;
// Current "children" list we'd be adding to
const children = topOpenElement != null ? topOpenElement.children : this.children;
if (parseResult.type === 'text') {
addStringSegment(children, parseResult.content);
continue;
}
if (parseResult.type === 'element-self-close' ||
parseResult.type === 'element-open') {
const xmlElement = new XmlElement(this.document, parseResult.localFullname, parseResult.attrs);
xmlElement.parent = topOpenElement;
children.push(xmlElement);
if (parseResult.type === 'element-open') {
this.openElements.push(xmlElement);
}
continue;
}
if (parseResult.type === 'element-close') {
if (topOpenElement == null) {
throw new Error('closing-empty-stack');
}
if (topOpenElement.localFullname !== parseResult.localFullname) {
throw new Error('closing-unmatched');
}
this.openElements.pop();
continue;
}
throw new Error(`unexpected parseResult type`);
}
this.applyNamespaces();
}
flush(force) {
this.streamerState.applyPostponedXmlString();
// if there is anything in bufferedXmlString, this is added as string
if (this.streamerState.bufferedString !== '') {
// Current top item in "open elements" stack
const topOpenElement = this.openElements.length > 0 ? this.openElements[this.openElements.length - 1] : null;
// Current "children" list we'd be adding to
const children = topOpenElement != null ? topOpenElement.children : this.children;
addStringSegment(children, this.streamerState.bufferedString);
this.streamerState.bufferedString = '';
this.streamerState.postponedString = undefined;
}
if (force) {
// Close out all elements
this.openElements = [];
}
}
applyNamespaces() {
for (const child of this.children) {
if (child instanceof XmlElement) {
child.applyNamespaces();
}
}
}
}
const regexXmlTagOpenOrClose = /<((?<tagOpen>(?<tagOpenFullname>(?<tagOpenNamespace>[a-zA-Z][-a-zA-Z0-9]*:)?(?<tagOpenName>[a-zA-Z][-a-zA-Z0-9]*))(?<attrs>(\s+([a-zA-Z][-a-zA-Z0-9]*:)?([a-zA-Z][-a-zA-Z0-9]*)=(("[^"]*")|('[^']*')))*)\s*(?<selfClosing>\/)?)|(?<tagClose>\/(?<tagCloseFullname>(?<tagCloseNamespace>[a-zA-Z][-a-zA-Z0-9]*:)?(?<tagCloseName>[a-zA-Z][-a-zA-Z0-9]*))\s*))>/;
const regexXmlTagOpenOrCloseNoDefaultNS = /<((?<tagOpen>(?<tagOpenFullname>(?<tagOpenNamespace>[a-zA-Z][-a-zA-Z0-9]*:)(?<tagOpenName>[a-zA-Z][-a-zA-Z0-9]*))(?<attrs>(\s+([a-zA-Z][-a-zA-Z0-9]*:)?([a-zA-Z][-a-zA-Z0-9]*)=(("[^"]*")|('[^']*')))*)\s*(?<selfClosing>\/)?)|(?<tagClose>\/(?<tagCloseFullname>(?<tagCloseNamespace>[a-zA-Z][-a-zA-Z0-9]*:)(?<tagCloseName>[a-zA-Z][-a-zA-Z0-9]*))\s*))>/;
const regexXmlAttr = /(?<attrFullname>([a-zA-Z][-a-zA-Z0-9]*:)?[a-zA-Z][-a-zA-Z0-9]*)=(("(?<attrValue1>[^"]*)")|('(?<attrValue2>[^']*)'))/g;
const regexXmlTagMaybeOpenOrClose = /<((?<tagOpen>(?<tagOpenFullname>[a-zA-Z]))|(?<tagClose>\/(?<tagCloseFullname>[a-zA-Z])))[^>]*$/;
function addStringSegment(segments, seg) {
if (seg === '') {
return;
}
if (segments.length > 0 && typeof segments[segments.length - 1] === 'string') {
segments[segments.length - 1] = segments[segments.length - 1] + seg;
}
else {
segments.push(seg);
}
}
export function parseXmlStringChunk(xmlString, options) {
let remainingXmlString = xmlString;
const regex = options?.ignoreDefaultTags ? regexXmlTagOpenOrCloseNoDefaultNS : regexXmlTagOpenOrClose;
const match = remainingXmlString.match(regex);
if (match != null) {
const pos = match.index ?? 0;
if (pos > 0) {
// If there is stuff before the tag then that stuff is what counts.
const content = remainingXmlString.slice(0, pos);
remainingXmlString = remainingXmlString.slice(pos);
return {
type: 'text',
content,
remainingXmlString,
};
}
remainingXmlString = remainingXmlString.slice(match[0].length);
if (match.groups == null) {
throw new Error('Unexpected, match.groups is null');
}
if (match.groups['tagOpen'] != null) {
if (match.groups['tagOpenFullname'] == null) {
throw new Error('Unexpected (tagOpenFullname is null)');
}
const attrs = {};
if (match.groups['attrs'] != null) {
for (const attrMatch of match.groups['attrs'].matchAll(regexXmlAttr)) {
const attrName = attrMatch.groups?.['attrFullname'];
if (attrName != null) {
attrs[attrName] = xmlDecode(attrMatch.groups?.['attrValue1'] ?? attrMatch.groups?.['attrValue2'] ?? '');
}
}
}
if (match.groups['selfClosing'] != null) {
return {
type: "element-self-close",
localFullname: match.groups['tagOpenFullname'],
attrs,
remainingXmlString,
};
}
return {
type: "element-open",
localFullname: match.groups['tagOpenFullname'],
attrs,
remainingXmlString,
};
}
if (match.groups['tagClose'] != null) {
if (match.groups['tagCloseFullname'] == null) {
throw new Error('Unexpected (tagCloseFullname is null)');
}
return {
type: "element-close",
localFullname: match.groups['tagCloseFullname'],
remainingXmlString,
};
}
throw new Error('Unexpected (tagOpen and tagClose are both null)');
}
const matchTagMaybeOpenOrClose = remainingXmlString.match(regexXmlTagMaybeOpenOrClose);
if (matchTagMaybeOpenOrClose != null) {
const pos = matchTagMaybeOpenOrClose.index ?? 0;
if (pos > 0) {
// If there is stuff before the tag then that stuff is what counts.
const content = remainingXmlString.slice(0, pos);
remainingXmlString = remainingXmlString.slice(pos);
return {
type: 'text',
content,
remainingXmlString,
};
}
return {
type: 'unknown',
remainingXmlString,
};
}
const content = remainingXmlString;
remainingXmlString = '';
return {
type: 'text',
content,
remainingXmlString,
};
}