UNPKG

@fastly/esi

Version:

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

390 lines (389 loc) 12.9 kB
/* * 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'; } }