@fastly/esi
Version:
ESI implementation for JavaScript, using the modern fetch and streaming APIs.
213 lines (212 loc) • 7.17 kB
JavaScript
/*
* Copyright Fastly, Inc.
* Licensed under the MIT license. See LICENSE file for details.
*/
import { quoteString, unquoteString } from "./util.js";
const NUMBER_TEST = /^(\d+(\.\d*)?|\.\d+)$/;
export function parseAsNumber(val) {
if (val === undefined || !NUMBER_TEST.test(val)) {
return undefined;
}
return parseInt('0' + val, 10);
}
export class EsiStringVariable {
value;
constructor(value) {
this.value = value;
}
getSubValue(_key) {
return undefined;
}
getValue() {
return quoteString(this.value);
}
}
export class EsiListVariable {
value;
fn;
map;
constructor(value, fn) {
this.value = value;
this.fn = fn;
}
getSubValue(key) {
if (this.map === undefined) {
this.map = this.fn(this.value);
}
return (this.map[key] ?? false) ? 'true' : 'false';
}
getValue() {
return quoteString(this.value);
}
}
export class EsiDictionaryVariable {
value;
fn;
map;
constructor(value, fn) {
this.value = value;
this.fn = fn;
}
getSubValue(key) {
if (this.map === undefined) {
this.map = this.fn(this.value);
}
const value = this.map[key] ?? '';
return quoteString(value);
}
getValue() {
return quoteString(this.value);
}
}
export class EsiAcceptLanguageVariable extends EsiListVariable {
constructor(value) {
super(value, (value) => {
const langs = value.split(',')
.map(seg => seg.split(';')[0].trim())
.filter(Boolean);
const map = {};
for (const lang of langs) {
map[decodeURIComponent(lang)] = true;
}
return map;
});
}
}
export class EsiCookieVariable extends EsiDictionaryVariable {
constructor(value) {
super(value, (value) => {
const cookieEntries = value.split(';')
.map(seg => {
const pieces = seg.split('=');
const key = pieces.shift();
return [key, pieces.join('=')];
});
const map = {};
for (const [key, value] of cookieEntries) {
if (key == null || value == null) {
continue;
}
const k = key.trim();
const v = value.trim();
if (k === '' || v === '') {
continue;
}
map[decodeURIComponent(k)] = decodeURIComponent(v);
}
return map;
});
}
}
export class EsiQueryStringVariable extends EsiDictionaryVariable {
constructor(value) {
super(value, (value) => {
const map = {};
for (const [k, v] of new URLSearchParams(value)) {
map[k] = v;
}
return map;
});
}
}
const USER_AGENT_REGEX = /^(?<browser>[^\/]+)\/(?<version>\d+(\.\d*))/;
export class EsiUserAgentVariable extends EsiDictionaryVariable {
constructor(value) {
super(value, (value) => {
let browser = 'OTHER';
let version = undefined;
let os = 'OTHER';
if (value.includes('Windows')) {
os = 'WIN';
}
else if (value.includes('Mac OS X') || value.includes('Mac_PowerPC')) {
os = 'MAC';
}
else if (value.includes('Linux') ||
value.includes('Unix') ||
value.includes('BSD') ||
value.includes('CrOS')) {
os = 'UNIX';
}
const matchResult = USER_AGENT_REGEX.exec(value);
if (matchResult != null) {
if (matchResult.groups?.['browser']?.toUpperCase() === 'MOZILLA') {
browser = 'MOZILLA';
}
version = matchResult.groups?.['version'];
}
if (value.includes('MSIE') || value.includes('Trident/')) {
browser = 'MSIE';
}
return { browser, version, os };
});
}
}
export class EsiVariables {
values = {};
constructor(url, headers) {
const httpAcceptLanguageValue = headers?.get('accept-language') ?? '';
this.values['HTTP_ACCEPT_LANGUAGE'] = new EsiAcceptLanguageVariable(httpAcceptLanguageValue);
const httpCookieValue = headers?.get('cookie') ?? '';
this.values['HTTP_COOKIE'] = new EsiCookieVariable(httpCookieValue);
const httpHostValue = headers?.get('host');
if (httpHostValue != null) {
this.values['HTTP_HOST'] = new EsiStringVariable(httpHostValue);
}
const httpRefererValue = headers?.get('referer');
if (httpRefererValue != null) {
this.values['HTTP_REFERER'] = new EsiStringVariable(httpRefererValue);
}
const httpUserAgentValue = headers?.get('user-agent');
if (httpUserAgentValue != null) {
this.values['HTTP_USER_AGENT'] = new EsiUserAgentVariable(httpUserAgentValue);
}
let queryStringValue = url?.search;
while (queryStringValue?.startsWith('?')) {
queryStringValue = queryStringValue.slice(1);
}
if (queryStringValue != null) {
this.values['QUERY_STRING'] = new EsiQueryStringVariable(queryStringValue);
}
}
getValue(name, subKey = null) {
if (subKey == null) {
return this.values[name]?.getValue();
}
return this.values[name]?.getSubValue(subKey);
}
}
function evaluateEsiVariableValue(groups, vars) {
const varName = groups['varName'];
const subkeyName = groups['subkeyName'] ?? null;
const value = vars?.getValue(varName, subkeyName);
if (value === undefined || value === '' || value === 'false') {
const defaultValue = groups['defaultValue1'] ?? groups['defaultValue2'];
if (defaultValue != null) {
return quoteString(defaultValue);
}
}
return value;
}
const ESI_VARIABLES_REGEX = /\$\((?<varName>[-_A-Z]+)(?:\{(?<subkeyName>[-_A-Za-z0-9]+)})?(?:\|(?:(?<defaultValue1>[^\s']+)|'(?<defaultValue2>[^']*)'))?\)/g;
export function applyEsiVariables(input, vars) {
if (input == null) {
return undefined;
}
return input.replace(ESI_VARIABLES_REGEX, (_, ...args) => {
const groups = args[args.length - 1];
const value = evaluateEsiVariableValue(groups, vars);
if (value === undefined || value === '' || value === 'true' || value === 'false') {
return '';
}
return unquoteString(value);
});
}
const ESI_VARIABLE_REGEX = /^\$\((?<varName>[-_A-Z]+)(?:\{(?<subkeyName>[-_A-Za-z0-9]+)})?(?:\|(?:(?<defaultValue1>[^\s']+)|'(?<defaultValue2>[^']*)'))?\)$/;
export function evaluateEsiVariable(input, vars) {
const match = ESI_VARIABLE_REGEX.exec(input ?? '');
if (match == null || match.groups == null) {
throw new Error('invalid variable format');
}
return evaluateEsiVariableValue(match?.groups, vars);
}