@fastly/esi
Version:
ESI implementation for JavaScript, using the modern fetch and streaming APIs.
390 lines (389 loc) • 12.9 kB
JavaScript
/*
* Copyright Fastly, Inc.
* Licensed under the MIT license. See LICENSE file for details.
*/
import { evaluateEsiVariable, parseAsNumber } from "./EsiVariables.js";
import { unquoteString } from "./util.js";
export class StringReader {
text = '';
length = 8;
offset = 0;
set(value) {
this.text = value;
this.length = value.length;
this.offset = 0;
}
get() {
return this.text.slice(this.offset);
}
addOffset(offset) {
this.offset += offset;
}
isEOF() {
return this.offset >= this.length;
}
reset() {
this.offset = 0;
}
}
export class StringLexer {
rules;
constructor(options = {}) {
this.rules = [];
if (options.tokenDefs != null) {
for (const [token, rule] of Object.entries(options.tokenDefs)) {
const regex = Array.isArray(rule) ? rule : [rule];
this.rules.push({ token, regex });
}
}
}
tokenize(text) {
const reader = new StringReader();
reader.set(text);
const parsedTokens = [];
while (!reader.isEOF()) {
const text = reader.get();
let matchedToken = undefined;
for (const rule of this.rules) {
for (const regex of rule.regex) {
const match = text.match(regex);
if (match != null) {
matchedToken = {
text: match[0],
type: rule.token,
};
break;
}
}
if (matchedToken != null) {
break;
}
}
if (matchedToken == null || matchedToken.text.length === 0) {
reader.addOffset(1);
continue;
}
parsedTokens.push(matchedToken);
reader.addOffset(matchedToken.text.length);
}
return parsedTokens;
}
}
// Shunting Yard algorithm
class ExpressionEvaluatorBase {
table;
constructor(table = {}) {
this.table = table;
}
onPop(token, output) {
return token;
}
evaluateTokens(tokens) {
const output = [];
const stack = [];
for (const token of tokens) {
switch (true) {
case this.isOpenParen(token): {
stack.push(token);
break;
}
case this.isCloseParen(token): {
let token;
while (stack.length > 0) {
token = stack.pop();
if (this.isOpenParen(token)) {
break;
}
const result = this.onPop(token, output);
output.push(result);
}
if (token == null || !this.isOpenParen(token)) {
throw new Error("Mismatched parentheses.");
}
break;
}
case this.getOperation(token) == null: {
output.push(token);
break;
}
default: {
while (stack.length > 0) {
let top = stack.at(-1);
if (this.isOpenParen(top)) {
break;
}
const o1 = this.getOperation(token);
const o2 = this.getOperation(top);
if (o1.precedence > o2.precedence ||
o1.precedence === o2.precedence &&
o1.associativity === "right") {
break;
}
const popped = stack.pop();
const result = this.onPop(popped, output);
output.push(result);
}
stack.push(token);
}
}
}
while (stack.length > 0) {
const popped = stack.pop();
if (this.isOpenParen(popped)) {
throw new Error("Mismatched parentheses.");
}
const result = this.onPop(popped, output);
output.push(result);
}
return output;
}
}
export class ExpressionEvaluator extends ExpressionEvaluatorBase {
isOpenParen(token) {
return token === '(';
}
isCloseParen(token) {
return token === ')';
}
getOperation(token) {
return this.table.hasOwnProperty(token) ? this.table[token] : undefined;
}
}
export class EsiExpressionEvaluator extends ExpressionEvaluatorBase {
static LEXER_TOKEN_DEFS = {
whitespace: /^\s+/,
literalString: /^'(\\'|[^'])*?'/,
literalNumber: /^(\d+|(\d*\.\d+))/,
literalBoolean: /^(true|false)/,
operator: /^(\(|\)|==|!=|>=|<=|>|<|!|&|\|)/,
esiVariable: /^\$\([-_A-Z0-9]+(\{[-_A-Za-z0-9]+})?(\|(([^\s']+)|('[^']*')))?\)/,
};
static PARSER_TABLE = {
'==': {
precedence: 4,
associativity: 'left',
},
'!=': {
precedence: 4,
associativity: 'left',
},
'<=': {
precedence: 4,
associativity: 'left',
},
'>=': {
precedence: 4,
associativity: 'left',
},
'<': {
precedence: 4,
associativity: 'left',
},
'>': {
precedence: 4,
associativity: 'left',
},
'!': {
precedence: 3,
associativity: 'right',
},
'&': {
precedence: 2,
associativity: 'left',
},
'|': {
precedence: 1,
associativity: 'left',
},
};
static stringLexer = new StringLexer({
tokenDefs: this.LEXER_TOKEN_DEFS
});
vars;
constructor(vars) {
super(EsiExpressionEvaluator.PARSER_TABLE);
this.vars = vars;
}
static COMPARISON_OPS = {
'==': (a, b) => a === b,
'!=': (a, b) => a !== b,
'<=': (a, b) => a <= b,
'>=': (a, b) => a >= b,
'<': (a, b) => a < b,
'>': (a, b) => a > b,
};
static LOGICAL_OPS = {
'&': (a, b) => a && b,
'|': (a, b) => a || b,
};
onPop(token, output) {
if (token.type !== 'operator') {
throw new Error('Unexpected! onPop should only be an operator');
}
const right = output.pop();
// Unary
if (token.value === '!') {
let result;
if (right.type === 'boolean') {
result = !right.value;
}
else {
// Attempting unary not on string, number, or undefined
result = undefined;
}
if (result === undefined) {
return {
type: 'undefined',
};
}
return {
type: 'boolean',
value: result,
};
}
// Binary
const left = output.pop();
let result = undefined;
const logicalOp = EsiExpressionEvaluator.LOGICAL_OPS[token.value];
if (logicalOp != null) {
if (left.type === 'boolean' && right.type === 'boolean') {
// "Logical operators ("&", "|", "!") can be used to qualify expressions, ..."
result = logicalOp(left.value, right.value);
}
// using this on other operand types will yield undefined results.
// "but cannot be used as comparitors themselves."
}
const comparisonOp = EsiExpressionEvaluator.COMPARISON_OPS[token.value];
if (comparisonOp != null) {
if (left.type === 'undefined' || right.type === 'undefined') {
// "If an operand is empty or undefined, the expression will always evaluate to false"
result = false;
}
else if (left.type === 'number' && right.type === 'number') {
// "If both operands are numeric, the expression is evaluated numerically."
result = comparisonOp(left.value, right.value);
}
else if ((left.type === 'number' && right.type === 'string') ||
(left.type === 'string' && right.type === 'number') ||
(left.type === 'string' && right.type === 'string')) {
// "If either binary operand is non-numeric, both operands are evaluated as strings."
result = comparisonOp(String(left.value), String(right.value));
}
// "The behavior of comparisons which incompatibly typed operators is undefined."
}
if (result != null) {
return {
type: 'boolean',
value: result,
};
}
return {
type: 'undefined',
};
}
tokenize(expression) {
const values = [];
for (const token of EsiExpressionEvaluator.stringLexer.tokenize(expression)) {
if (token.type === 'whitespace') {
continue;
}
switch (token.type) {
case 'literalString': {
values.push({
type: 'string',
value: unquoteString(token.text),
});
break;
}
case 'literalNumber': {
values.push({
type: 'number',
value: parseAsNumber(token.text),
});
break;
}
case 'literalBoolean': {
values.push({
type: 'boolean',
value: token.text === 'true',
});
break;
}
case 'operator': {
if (token.text === '(') {
values.push({
type: 'openParen'
});
break;
}
if (token.text === ')') {
values.push({
type: 'closeParen'
});
break;
}
values.push({
type: 'operator',
value: token.text,
});
break;
}
case 'esiVariable': {
const value = evaluateEsiVariable(token.text, this.vars);
if (value != null) {
const valueAsNumber = parseAsNumber(value);
if (valueAsNumber != null) {
values.push({
type: 'number',
value: valueAsNumber,
});
break;
}
if (value === 'true' || value === 'false') {
values.push({
type: 'boolean',
value: value === 'true',
});
break;
}
try {
const valueAsString = unquoteString(value);
values.push({
type: 'string',
value: valueAsString,
});
break;
}
catch (ex) {
}
}
values.push({
type: 'undefined',
});
break;
}
}
}
return values;
}
evaluate(expression) {
const parsedTokens = this.tokenize(expression);
const evaluated = this.evaluateTokens(parsedTokens);
if (evaluated.length > 1 || evaluated[0].type !== 'boolean') {
return false;
}
return evaluated[0].value;
}
getOperation(token) {
if (token.type !== 'operator') {
return undefined;
}
return this.table[token.value];
}
isCloseParen(token) {
return token.type === 'closeParen';
}
isOpenParen(token) {
return token.type === 'openParen';
}
}